perf(learn): a conversation deleted goes at once, with an Undo

Deleting a kept conversation asked first and then waited while its
turns were blanked and deleted. It now goes as a card or a note does:
ConversationDeletions, a DelayedDeletions opened with the person's
data, takes it off the history at once (the list leaves out what is
pending), offers Undo for a moment on the sheet (hasSnackBars), and
blanks and deletes it once that is over; a deletion that fails shows
the conversation again, as every DelayedDeletions does. The question
before deleting goes, as Undo replaces it.

Whats-New: A conversation with an assistant is deleted at once, and Undo brings it back.
Whats-New-De: Eine Unterhaltung mit einem Assistenten wird sofort gelöscht, und „Rückgängig“ holt sie zurück.
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-07 23:07:23 +01:00
commit 891e9dfd75
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 106 additions and 71 deletions

File diff suppressed because one or more lines are too long

View file

@ -1778,8 +1778,7 @@
"assistantHistoryExplained": "Karte erklärt", "assistantHistoryExplained": "Karte erklärt",
"assistantHistoryChecked": "Auf Fehler geprüft", "assistantHistoryChecked": "Auf Fehler geprüft",
"assistantHistoryDelete": "Unterhaltung löschen", "assistantHistoryDelete": "Unterhaltung löschen",
"assistantHistoryDeleteMessage": "„{title}“ verschwindet von allen deinen Geräten.", "assistantHistoryDeleted": "Unterhaltung gelöscht",
"assistantHistoryDeleteConfirm": "Löschen",
"assistantGone": "Ein nicht mehr angebotener Assistent", "assistantGone": "Ein nicht mehr angebotener Assistent",
"assistantGoneNote": "Dieser Assistent wird dir hier nicht mehr angeboten, also lässt sich die Unterhaltung nicht fortsetzen.", "assistantGoneNote": "Dieser Assistent wird dir hier nicht mehr angeboten, also lässt sich die Unterhaltung nicht fortsetzen.",
"assistantNotKept": "Nicht gespeichert: Dieses Gerät kann Unterhaltungen nicht verschlüsseln, also ist diese weg, sobald du sie schließt.", "assistantNotKept": "Nicht gespeichert: Dieses Gerät kann Unterhaltungen nicht verschlüsseln, also ist diese weg, sobald du sie schließt.",

View file

@ -2117,9 +2117,7 @@
"assistantHistoryChecked": "Checked for mistakes", "assistantHistoryChecked": "Checked for mistakes",
"@assistantHistoryChecked": {"description": "A conversation that checked what was written (D54)."}, "@assistantHistoryChecked": {"description": "A conversation that checked what was written (D54)."},
"assistantHistoryDelete": "Delete conversation", "assistantHistoryDelete": "Delete conversation",
"assistantHistoryDeleteMessage": "“{title}” goes from all your devices.", "assistantHistoryDeleted": "Conversation deleted",
"@assistantHistoryDeleteMessage": {"placeholders": {"title": {"type": "String"}}},
"assistantHistoryDeleteConfirm": "Delete",
"assistantGone": "An assistant no longer offered", "assistantGone": "An assistant no longer offered",
"assistantGoneNote": "This assistant is no longer offered to you here, so the conversation can't go on.", "assistantGoneNote": "This assistant is no longer offered to you here, so the conversation can't go on.",
"assistantNotKept": "Not kept: this device can't encrypt conversations, so this one is gone once you close it.", "assistantNotKept": "Not kept: this device can't encrypt conversations, so this one is gone once you close it.",

View file

@ -10497,17 +10497,11 @@ abstract class AppLocalizations {
/// **'Delete conversation'** /// **'Delete conversation'**
String get assistantHistoryDelete; String get assistantHistoryDelete;
/// No description provided for @assistantHistoryDeleteMessage. /// No description provided for @assistantHistoryDeleted.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'“{title}” goes from all your devices.'** /// **'Conversation deleted'**
String assistantHistoryDeleteMessage(String title); String get assistantHistoryDeleted;
/// No description provided for @assistantHistoryDeleteConfirm.
///
/// In en, this message translates to:
/// **'Delete'**
String get assistantHistoryDeleteConfirm;
/// No description provided for @assistantGone. /// No description provided for @assistantGone.
/// ///

View file

@ -6634,12 +6634,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get assistantHistoryDelete => 'Unterhaltung löschen'; String get assistantHistoryDelete => 'Unterhaltung löschen';
@override @override
String assistantHistoryDeleteMessage(String title) { String get assistantHistoryDeleted => 'Unterhaltung gelöscht';
return '„$title“ verschwindet von allen deinen Geräten.';
}
@override
String get assistantHistoryDeleteConfirm => 'Löschen';
@override @override
String get assistantGone => 'Ein nicht mehr angebotener Assistent'; String get assistantGone => 'Ein nicht mehr angebotener Assistent';

View file

@ -6574,12 +6574,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get assistantHistoryDelete => 'Delete conversation'; String get assistantHistoryDelete => 'Delete conversation';
@override @override
String assistantHistoryDeleteMessage(String title) { String get assistantHistoryDeleted => 'Conversation deleted';
return '“$title” goes from all your devices.';
}
@override
String get assistantHistoryDeleteConfirm => 'Delete';
@override @override
String get assistantGone => 'An assistant no longer offered'; String get assistantGone => 'An assistant no longer offered';

View file

@ -26,6 +26,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart'; import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/photo_picker.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/photo_picker.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/sync_conversation_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/sync_conversation_gateway.dart';
@ -567,6 +568,7 @@ Future<PersonDependencies> _openPersonData(
final pathDeletions = LearningPathDeletions(paths); final pathDeletions = LearningPathDeletions(paths);
final stepDeletions = StepDeletions(paths); final stepDeletions = StepDeletions(paths);
final pdfDeletions = PdfDeletions(pdfSources); final pdfDeletions = PdfDeletions(pdfSources);
final conversationDeletions = ConversationDeletions(conversations);
final publishing = PublishRepository( final publishing = PublishRepository(
ServerpodPublishGateway(client), ServerpodPublishGateway(client),
FilePublishedStore(File(p.join(folder.path, 'published.json'))), FilePublishedStore(File(p.join(folder.path, 'published.json'))),
@ -628,6 +630,7 @@ Future<PersonDependencies> _openPersonData(
pathDeletions: pathDeletions, pathDeletions: pathDeletions,
stepDeletions: stepDeletions, stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions, pdfDeletions: pdfDeletions,
conversationDeletions: conversationDeletions,
pomodoroRuns: FilePomodoroRunStore( pomodoroRuns: FilePomodoroRunStore(
File(p.join(folder.path, 'pomodoro.json')), File(p.join(folder.path, 'pomodoro.json')),
), ),
@ -666,6 +669,7 @@ Future<PersonDependencies> _openPersonData(
await pathDeletions.dispose(); await pathDeletions.dispose();
await stepDeletions.dispose(); await stepDeletions.dispose();
await pdfDeletions.dispose(); await pdfDeletions.dispose();
await conversationDeletions.dispose();
await discussions.dispose(); await discussions.dispose();
await counting.dispose(); await counting.dispose();
await studysetProgress.dispose(); await studysetProgress.dispose();

View file

@ -3,6 +3,7 @@ import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_checks.dart'; import 'package:halkyon_learn_flutter/src/core/grammar/grammar_checks.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
@ -96,6 +97,9 @@ class PersonDependencies({
/// The PDF deletions that can still be undone. /// The PDF deletions that can still be undone.
required final PdfDeletions pdfDeletions, required final PdfDeletions pdfDeletions,
/// The deletions of kept conversations that can still be undone.
required final ConversationDeletions conversationDeletions,
/// Where this device keeps the person's Pomodoro timer. /// Where this device keeps the person's Pomodoro timer.
required final PomodoroRunStore pomodoroRuns, required final PomodoroRunStore pomodoroRuns,

View file

@ -0,0 +1,13 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
/// The person's deletions of kept conversations (ADR 0021, D48), which can
/// be undone for a moment (see [DelayedDeletions]), as a card's or a note's:
/// the conversation leaves the history at once, and is blanked and deleted
/// with its turns once the time to undo is over.
class ConversationDeletions(
ConversationRepository conversations, {
super.undoWindow,
}) extends DelayedDeletions {
this : super(conversations.delete, logName: 'Conversations');
}

View file

@ -16,8 +16,8 @@ import 'package:material_ui/material_ui.dart';
/// The person's kept conversations (ADR 0021, D48), the newest first, those /// The person's kept conversations (ADR 0021, D48), the newest first, those
/// asked on [studysetId] before the rest, each with who answers and how far /// asked on [studysetId] before the rest, each with who answers and how far
/// it looks: opening one continues it in its sheet; deleting one asks /// it looks: opening one continues it in its sheet; deleting one takes it
/// first. /// off at once, with an Undo for a moment.
Future<void> showConversationHistory( Future<void> showConversationHistory(
BuildContext context, { BuildContext context, {
String? studysetId, String? studysetId,
@ -42,6 +42,11 @@ class _HistoryState() extends State<_History> {
context, context,
).conversations.watch(); ).conversations.watch();
/// The conversations being deleted, which the list leaves out at once.
late final _deleting = PersonDependencies.of(
context,
).conversationDeletions.watchPending();
/// Every studyset's title, for the conversations asked on one. /// Every studyset's title, for the conversations asked on one.
late final Future<Map<String, String>> _titles = late final Future<Map<String, String>> _titles =
PersonDependencies.of( PersonDependencies.of(
@ -70,37 +75,49 @@ class _HistoryState() extends State<_History> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return SheetScaffold( return SheetScaffold(
title: l10n.assistantHistory, title: l10n.assistantHistory,
body: StreamBuilder<List<AssistantConversation>>( // A deletion's Undo shows on it, not behind its barrier.
stream: _conversations, hasSnackBars: true,
builder: (context, snapshot) => switch (snapshot.data) { body: StreamBuilder<Set<String>>(
null => const Padding( stream: _deleting,
padding: EdgeInsets.all(24), builder: (context, deleting) =>
child: Center(child: AppLoadingIndicator()), StreamBuilder<List<AssistantConversation>>(
), stream: _conversations,
[] => Padding( builder: (context, snapshot) => switch (snapshot.data
padding: const EdgeInsets.symmetric(vertical: 24), ?.where(
child: EmptyListMessage( (conversation) =>
icon: LucideIcons.messagesSquare, !(deleting.data?.contains(conversation.id) ?? false),
title: l10n.assistantHistoryEmpty, )
hint: l10n.assistantHistoryEmptyHint, .toList()) {
), null => const Padding(
), padding: EdgeInsets.all(24),
final all => FutureBuilder<Map<String, String>>( child: Center(child: AppLoadingIndicator()),
future: _titles, ),
builder: (context, titles) => Column( [] => Padding(
children: [ padding: const EdgeInsets.symmetric(vertical: 24),
for (final conversation in _ordered(all)) child: EmptyListMessage(
_ConversationTile( icon: LucideIcons.messagesSquare,
conversation: conversation, title: l10n.assistantHistoryEmpty,
studysetTitle: switch (conversation.scope.studysetId) { hint: l10n.assistantHistoryEmptyHint,
final id? => titles.data?[id],
null => null,
},
), ),
], ),
final all => FutureBuilder<Map<String, String>>(
future: _titles,
builder: (context, titles) => Column(
children: [
for (final conversation in _ordered(all))
_ConversationTile(
conversation: conversation,
studysetTitle:
switch (conversation.scope.studysetId) {
final id? => titles.data?[id],
null => null,
},
),
],
),
),
},
), ),
),
},
), ),
); );
} }
@ -152,7 +169,7 @@ class const _ConversationTile({
trailing: IconButton( trailing: IconButton(
tooltip: l10n.assistantHistoryDelete, tooltip: l10n.assistantHistoryDelete,
icon: const Icon(LucideIcons.trash2), icon: const Icon(LucideIcons.trash2),
onPressed: () => unawaited(_delete(context)), onPressed: () => _delete(context),
), ),
onTap: () { onTap: () {
// The conversation's sheet over the screen, not over this list, // The conversation's sheet over the screen, not over this list,
@ -176,16 +193,17 @@ class const _ConversationTile({
); );
} }
Future<void> _delete(BuildContext context) async { /// Takes the conversation off at once and offers to undo that for a
/// moment, as a card or a note; it is blanked and deleted once that is
/// over, the deletion outliving this list.
void _delete(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final conversations = PersonDependencies.of(context).conversations; final deletions = PersonDependencies.of(context).conversationDeletions
if (await confirmDestructiveAction( ..delete(conversation.id);
showUndoSnackBar(
context, context,
title: l10n.assistantHistoryDelete, message: l10n.assistantHistoryDeleted,
message: l10n.assistantHistoryDeleteMessage(conversation.title), onUndo: () => deletions.undo(conversation.id),
confirmLabel: l10n.assistantHistoryDeleteConfirm, );
)) {
await conversations.delete(conversation.id);
}
} }
} }

View file

@ -764,8 +764,8 @@ void main() {
}); });
testWidgets('kept conversations are listed in the field, those about ' testWidgets('kept conversations are listed in the field, those about '
'where the person is first; one opens in its sheet, and goes after ' 'where the person is first; one opens in its sheet, and goes at once, '
'asking (D48)', (tester) async { 'with an Undo (D48)', (tester) async {
final conversations = ConversationRepository(kept); final conversations = ConversationRepository(kept);
addTearDown(conversations.dispose); addTearDown(conversations.dispose);
final id = await conversations.start( final id = await conversations.start(
@ -799,13 +799,24 @@ void main() {
await tester.tap(find.byTooltip('Conversations')); await tester.tap(find.byTooltip('Conversations'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Gone at once, with an Undo for a moment, as a card or a note.
await tester.tap(find.byTooltip('Delete conversation')); await tester.tap(find.byTooltip('Delete conversation'));
await tester.pump();
expect(find.text('Why て-form?'), findsNothing);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Delete')); expect(find.text('Conversation deleted'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Why て-form?'), findsOneWidget);
await tester.tap(find.byTooltip('Delete conversation'));
await tester.pump();
expect(find.text('No conversations yet'), findsOneWidget); expect(find.text('No conversations yet'), findsOneWidget);
expect(kept.blanked, isEmpty, reason: 'Undo is still offered');
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(kept.blanked, [id]); expect(kept.blanked, [id]);
expect(find.text('No conversations yet'), findsOneWidget);
}); });
testWidgets("a device that can't seal answers, and says it keeps nothing " testWidgets("a device that can't seal answers, and says it keeps nothing "

View file

@ -23,6 +23,7 @@ import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.da
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart'; import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_files.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/assistant_lookups.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/photo_picker.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/photo_picker.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
@ -345,6 +346,7 @@ Dependencies testDependencies({
final pathDeletions = LearningPathDeletions(paths); final pathDeletions = LearningPathDeletions(paths);
final stepDeletions = StepDeletions(paths); final stepDeletions = StepDeletions(paths);
final pdfDeletions = PdfDeletions(pdfSources); final pdfDeletions = PdfDeletions(pdfSources);
final conversationDeletions = ConversationDeletions(conversations);
final studysetTabs = StudysetTabs( final studysetTabs = StudysetTabs(
studysetTabStore ?? MemoryStudysetTabStore(), studysetTabStore ?? MemoryStudysetTabStore(),
); );
@ -376,6 +378,7 @@ Dependencies testDependencies({
pathDeletions: pathDeletions, pathDeletions: pathDeletions,
stepDeletions: stepDeletions, stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions, pdfDeletions: pdfDeletions,
conversationDeletions: conversationDeletions,
pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(), pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(),
studysetTabs: studysetTabs, studysetTabs: studysetTabs,
privateOffer: privateOffer ?? MemoryPrivateOfferStore(), privateOffer: privateOffer ?? MemoryPrivateOfferStore(),
@ -427,6 +430,7 @@ Dependencies testDependencies({
await pathDeletions.dispose(); await pathDeletions.dispose();
await stepDeletions.dispose(); await stepDeletions.dispose();
await pdfDeletions.dispose(); await pdfDeletions.dispose();
await conversationDeletions.dispose();
await studysetProgress.dispose(); await studysetProgress.dispose();
await studysets.dispose(); await studysets.dispose();
await tags.dispose(); await tags.dispose();