diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 062a931..0b5bdc8 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -8,6 +8,12 @@ _(nothing active — pick the next batch from below)_ ## Deferred / future refinements +- [ ] **GraphRAG — search/filter conversations.** The chat list (P13d-2b) shows every active chat; once a user + accumulates many, add a search field (by title/preview) and/or pinning. *(From P13d-2b.)* +- [ ] **GraphRAG — bulk chat management + swipe gestures.** Multi-select archive/delete and a swipe-to-archive + gesture on list rows (today each action is a per-row overflow-menu tap + confirm). *(From P13d-2b.)* +- [ ] **GraphRAG — refresh a chat's title from its content.** The title is derived once from the first question + (renamable); consider an LLM/heuristic re-title as a conversation evolves. *(From P13d-2b.)* - [ ] **GraphRAG — store only the *cited* sources.** P13d-2a persists **all** retrieved sources as a turn's citations (and renders every `[n]` the model emits); it doesn't prune to the subset the answer actually cites. If answers reference few sources, post-parse the `[n]` markers and persist only those (smaller diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 39bbfcc..afbc218 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -1003,6 +1003,17 @@ entries, or verify after P11c lands.)* - [ ] With generation **not** set up (eligible device, no model), sending shows the **on-ramp** snackbar and routes to AI settings. +### P13d-2b — Conversation list + manage *(install `app-arm64-v8a-debug.apk`; needs a capable device + a downloaded generation model)* +- [ ] Ask questions in **two separate chats** (open Ask → New chat each time) → the Dashboard "Ask" entry now + opens a **conversation list** showing both, **most-recent-first**, each with a **preview** of its last + message and a relative time. +- [ ] **Reopen** a chat from the list → its prior turns show, and a **new question continues with retained + context** (the answer reflects the earlier turns) — **fully offline**. +- [ ] **Rename** a chat (row menu → Rename) → the new title persists in the list and on the open chat's app bar. +- [ ] **Archive** a chat → it leaves the main list; it appears under **Archived chats** (app-bar overflow) and + can be **Unarchived** back; **Delete** removes a chat (and its messages) for good (after a confirm). +- [ ] With no chats yet, the list shows an **empty state** whose CTA starts a chat. + ### P13 (later subphases) - [ ] **Transcription / summarization / translation / OCR** each work (capability-gated) and write results back to the item. diff --git a/docs/design/P13-PLAN.md b/docs/design/P13-PLAN.md index eb92fa6..39de241 100644 --- a/docs/design/P13-PLAN.md +++ b/docs/design/P13-PLAN.md @@ -258,11 +258,22 @@ Incapable / low tiers fall back to an ephemeral **retrieval-only** answer (d-3). - **Exit / review:** ask a natural-language question on a capable device → a streamed, grounded, cited answer **offline**; the turn persists; citations navigate. APK spot-check. ✓ (CI parts) · APK owed -#### `[ ]` P13d-2b — Conversation list + manage *(native)* +#### `[~]` P13d-2b — Conversation list + manage *(native)* - A conversation **list** with **continue / rename / archive / delete**; resuming a chat re-feeds the bounded history into each new turn's prompt. +- **Status:** implemented (CI-green; APK spot-check owed). **List-first** entry — the Dashboard tile (`/ask`) + now opens a **`ConversationsScreen`** (most-recent-first, "New chat" FAB, empty-state CTA), with continue + (`/ask/chat/:id`), new chat (`/ask/chat`), and an **`ArchivedChatsScreen`** (`/ask/archived`). `ChatRepository` + gains `watchChatList({archived})` (one query with a latest-message preview subquery), `renameChat`, + `setArchived`, `deleteChat` (messages cascade), `watchChatTitle` + `activeChatsProvider`/`archivedChatsProvider`/ + `chatTitleProvider`. `AskController` is now a **family keyed by `String? chatId`** — a seeded id continues a + thread (history feeds back), `null` creates on first send (the d-2a behaviour); `AskScreen` takes `chatId` and + shows the live, renamable title. **No schema change** (v14 already carried `title`/`archivedAt`); no new deps. + Tests: repo (recency/preview ordering, active-vs-archived split, archive toggle, rename incl. blank-ignored, + cascade delete), the resumable controller (continue feeds prior turns; no new chat), and a `ConversationsScreen` + widget test (rows/preview, empty-state CTA, row Rename/Archive/Delete menu). - **Exit / review:** prior chats list, reopen and continue with retained context, and archive/delete/rename - behave; covered where CI can (provider/repository) + an APK spot-check for the flow. + behave; covered where CI can (provider/repository) + an APK spot-check for the flow. ✓ (CI parts) · APK owed #### `[ ]` P13d-3 — Low-tier fallback + tier-aware depth + RAM co-residency *(native; APK)* - On ineligible / low tiers (`ragAvailability == retrievalOnly`), fall back to an ephemeral **retrieval-only** diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 5a6ede1..b3684fa 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -13,6 +13,7 @@ import 'package:grabbit/features/library/presentation/entity_hub_screen.dart'; import 'package:grabbit/features/library/presentation/home_screen.dart'; import 'package:grabbit/features/library/presentation/item_detail_screen.dart'; import 'package:grabbit/features/ai/presentation/ask_screen.dart'; +import 'package:grabbit/features/ai/presentation/conversations_screen.dart'; import 'package:grabbit/features/ai/presentation/graph_view_screen.dart'; import 'package:grabbit/features/library/presentation/media_studio_screen.dart'; import 'package:grabbit/features/library/presentation/metadata_edit_screen.dart'; @@ -259,8 +260,29 @@ GoRouter appRouter(Ref ref) { path: '/ask', name: 'ask', parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const ConversationsScreen(), + ), + // Static `/ask/chat` is registered before the `:id` route so a new chat + // never matches as a conversation id. + GoRoute( + path: '/ask/chat', + name: 'ask-new', + parentNavigatorKey: _rootNavigatorKey, builder: (context, state) => const AskScreen(), ), + GoRoute( + path: '/ask/chat/:id', + name: 'ask-chat', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => + AskScreen(chatId: state.pathParameters['id']), + ), + GoRoute( + path: '/ask/archived', + name: 'ask-archived', + parentNavigatorKey: _rootNavigatorKey, + builder: (context, state) => const ArchivedChatsScreen(), + ), ], ); } diff --git a/lib/features/ai/data/chat_repository.dart b/lib/features/ai/data/chat_repository.dart index dcd1516..d1ea33d 100644 --- a/lib/features/ai/data/chat_repository.dart +++ b/lib/features/ai/data/chat_repository.dart @@ -3,10 +3,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:grabbit/core/db/database.dart'; import 'package:grabbit/core/db/database_provider.dart'; -/// Persistence for the "Ask your library" GraphRAG chat (P13d-2a): a thread of +/// A conversation as shown in the chat list (P13d-2b): the chat's id/title, when +/// it was last touched, the latest message as a preview, and whether it's +/// archived. A lightweight projection (not the full [Chat] row) so the list query +/// stays a single statement with no per-row follow-up reads. +class ChatListItem { + const ChatListItem({ + required this.id, + required this.title, + required this.updatedAt, + required this.preview, + required this.archived, + }); + + final String id; + final String title; + final DateTime updatedAt; + final String? preview; + final bool archived; +} + +/// Persistence for the "Ask your library" GraphRAG chat (P13d-2a/b): a thread of /// [Chat]s, each a list of ordered [ChatMessage]s. Drift stays canonical; this -/// repo only reads/writes the `chats` + `chat_messages` tables. The conversation -/// list + rename/archive/delete land in d-2b on the same schema. +/// repo only reads/writes the `chats` + `chat_messages` tables. d-2b adds the +/// conversation list + rename/archive/delete on the same v14 schema. class ChatRepository { ChatRepository(this._db); @@ -71,6 +91,68 @@ class ChatRepository { ..where((t) => t.chatId.equals(chatId)) ..orderBy([(t) => OrderingTerm.asc(t.id)])) .get(); + + /// Live conversation list (P13d-2b), most-recent-first, filtered by + /// [archived]. One reactive statement with a correlated subquery for each + /// chat's latest message (the list preview) — no per-row follow-up reads. + Stream> watchChatList({required bool archived}) { + final archivedClause = archived + ? 'archived_at IS NOT NULL' + : 'archived_at IS NULL'; + return _db + .customSelect( + // `chats.*` so Drift's generated mapper decodes the row (incl. the + // DateTime format); `preview` is the latest message via a correlated + // subquery — one statement, no per-row follow-up read. + 'SELECT chats.*, ' + '(SELECT content FROM chat_messages m WHERE m.chat_id = chats.id ' + 'ORDER BY m.id DESC LIMIT 1) AS preview ' + 'FROM chats WHERE $archivedClause ORDER BY updated_at DESC', + readsFrom: {_db.chats, _db.chatMessages}, + ) + .watch() + .map((rows) { + return rows.map((row) { + final chat = _db.chats.map(row.data); + return ChatListItem( + id: chat.id, + title: chat.title, + updatedAt: chat.updatedAt, + preview: row.read('preview'), + archived: archived, + ); + }).toList(); + }); + } + + /// Live title for [chatId] (null if the chat doesn't exist yet) — drives the + /// chat screen's app bar so a rename reflects immediately. + Stream watchChatTitle(String chatId) => + (_db.select(_db.chats)..where((t) => t.id.equals(chatId))) + .watchSingleOrNull() + .map((c) => c?.title); + + /// Renames [chatId]; a blank title is ignored (keeps the derived one). + Future renameChat(String chatId, String title) async { + final trimmed = title.trim(); + if (trimmed.isEmpty) return; + await (_db.update(_db.chats)..where((t) => t.id.equals(chatId))).write( + ChatsCompanion(title: Value(trimmed)), + ); + } + + /// Archives (hides from the active list, kept + restorable) or unarchives + /// [chatId] by setting/clearing `archivedAt`. + Future setArchived(String chatId, bool archived) async { + await (_db.update(_db.chats)..where((t) => t.id.equals(chatId))).write( + ChatsCompanion(archivedAt: Value(archived ? DateTime.now() : null)), + ); + } + + /// Deletes [chatId]; its messages cascade (the `chat_messages` FK). + Future deleteChat(String chatId) async { + await (_db.delete(_db.chats)..where((t) => t.id.equals(chatId))).go(); + } } final chatRepositoryProvider = Provider( @@ -82,3 +164,19 @@ final chatRepositoryProvider = Provider( final chatMessagesProvider = StreamProvider.family, String>( (ref, chatId) => ref.watch(chatRepositoryProvider).watchMessages(chatId), ); + +/// Active (non-archived) conversations for the chat list (P13d-2b), +/// most-recent-first. +final activeChatsProvider = StreamProvider>( + (ref) => ref.watch(chatRepositoryProvider).watchChatList(archived: false), +); + +/// Archived conversations (kept + restorable), most-recent-first. +final archivedChatsProvider = StreamProvider>( + (ref) => ref.watch(chatRepositoryProvider).watchChatList(archived: true), +); + +/// Live title of a single conversation, for the chat screen's app bar. +final chatTitleProvider = StreamProvider.family( + (ref, chatId) => ref.watch(chatRepositoryProvider).watchChatTitle(chatId), +); diff --git a/lib/features/ai/presentation/ask_controller.dart b/lib/features/ai/presentation/ask_controller.dart index e5193b5..2c300a7 100644 --- a/lib/features/ai/presentation/ask_controller.dart +++ b/lib/features/ai/presentation/ask_controller.dart @@ -25,10 +25,14 @@ class AskState { /// grounded answer through the local LLM, and persists the turn with its /// citations. No sources → a graceful "couldn't find" reply, no LLM call. The /// caller gates on generation readiness (`aiSummaryAction`) before [send]. +/// +/// Keyed by [chatId] (P13d-2b): a non-null id **continues** that conversation — +/// its persisted history feeds back into the next turn — while `null` starts a +/// fresh chat, created on the first [send] (the d-2a behaviour). @riverpod class AskController extends _$AskController { @override - AskState build() => const AskState(); + AskState build(String? chatId) => AskState(chatId: chatId); Future send(String question) async { final q = question.trim(); diff --git a/lib/features/ai/presentation/ask_screen.dart b/lib/features/ai/presentation/ask_screen.dart index 56fef12..870eaa9 100644 --- a/lib/features/ai/presentation/ask_screen.dart +++ b/lib/features/ai/presentation/ask_screen.dart @@ -14,9 +14,13 @@ import 'package:grabbit/features/settings/presentation/settings_controller.dart' /// The "Ask your library" GraphRAG chat (P13d-2a): ask a natural-language /// question and get a grounded, streamed answer that cites library items. Each /// turn re-retrieves fresh sources + a bounded slice of history. Reached from the -/// Dashboard; generation-gated (an on-ramp routes to AI settings when no model). +/// conversation list; generation-gated (an on-ramp routes to AI settings when no +/// model). [chatId] continues an existing conversation (P13d-2b); `null` starts a +/// new one, created on the first send. class AskScreen extends ConsumerStatefulWidget { - const AskScreen({super.key}); + const AskScreen({super.key, this.chatId}); + + final String? chatId; @override ConsumerState createState() => _AskScreenState(); @@ -35,7 +39,9 @@ class _AskScreenState extends ConsumerState { Future _onSend() async { final text = _input.text.trim(); - if (text.isEmpty || ref.read(askControllerProvider).busy) return; + if (text.isEmpty || ref.read(askControllerProvider(widget.chatId)).busy) { + return; + } final messenger = ScaffoldMessenger.of(context); final router = GoRouter.of(context); @@ -65,11 +71,22 @@ class _AskScreenState extends ConsumerState { await router.push('/settings/ai'); case AiSummaryAction.summarizeNow: _input.clear(); - await ref.read(askControllerProvider.notifier).send(text); + await ref + .read(askControllerProvider(widget.chatId).notifier) + .send(text); _scrollToBottom(); } } + /// "Ask your library" for a new chat; the (live, renamable) conversation title + /// when continuing an existing one. + String _appBarTitle(AskState state) { + final chatId = state.chatId; + if (chatId == null) return 'Ask your library'; + return ref.watch(chatTitleProvider(chatId)).asData?.value ?? + 'Ask your library'; + } + void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scroll.hasClients) { @@ -85,10 +102,10 @@ class _AskScreenState extends ConsumerState { @override Widget build(BuildContext context) { final tokens = GrabBitTokens.of(context); - final state = ref.watch(askControllerProvider); + final state = ref.watch(askControllerProvider(widget.chatId)); return Scaffold( - appBar: AppBar(title: const Text('Ask your library')), + appBar: AppBar(title: Text(_appBarTitle(state))), body: SafeArea( child: Column( children: [ diff --git a/lib/features/ai/presentation/conversations_screen.dart b/lib/features/ai/presentation/conversations_screen.dart new file mode 100644 index 0000000..63ad7d2 --- /dev/null +++ b/lib/features/ai/presentation/conversations_screen.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:grabbit/core/widgets/async_fade.dart'; +import 'package:grabbit/core/widgets/confirm_dialog.dart'; +import 'package:grabbit/core/widgets/content_bounds.dart'; +import 'package:grabbit/core/widgets/empty_state.dart'; +import 'package:grabbit/core/widgets/error_view.dart'; +import 'package:grabbit/core/widgets/skeleton.dart'; +import 'package:grabbit/features/ai/data/chat_repository.dart'; +import 'package:grabbit/features/notifications/presentation/notification_style.dart'; + +/// The "Ask your library" conversation list (P13d-2b): past chats, +/// most-recent-first, that you can continue / rename / archive / delete. The +/// Dashboard entry lands here; "New chat" (and the empty-state CTA) open a fresh +/// chat at `/ask/chat`. Archived threads live behind `/ask/archived`. +class ConversationsScreen extends ConsumerWidget { + const ConversationsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chats = ref.watch(activeChatsProvider); + return Scaffold( + appBar: AppBar( + title: const Text('Ask your library'), + actions: [ + PopupMenuButton( + tooltip: 'More', + onSelected: (v) { + if (v == 'archived') context.push('/ask/archived'); + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'archived', child: Text('Archived chats')), + ], + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.push('/ask/chat'), + icon: const Icon(Icons.add), + label: const Text('New chat'), + ), + body: ContentBounds( + child: AsyncFade( + value: chats, + loading: () => const ListSkeleton(), + error: (e, _) => ErrorView( + message: 'Failed to load your chats: $e', + onRetry: () => ref.invalidate(activeChatsProvider), + ), + data: (list) => list.isEmpty + ? EmptyState( + icon: Icons.auto_awesome_outlined, + title: 'Ask your library', + message: + 'Ask a question and get an answer grounded in your ' + 'downloads, with links to the items it used.', + action: FilledButton.icon( + onPressed: () => context.push('/ask/chat'), + icon: const Icon(Icons.add), + label: const Text('Start a chat'), + ), + ) + : ListView(children: [for (final c in list) _ChatTile(item: c)]), + ), + ), + ); + } +} + +/// The archived conversations (kept + restorable). Same list, but per-row +/// actions are Unarchive / Delete. +class ArchivedChatsScreen extends ConsumerWidget { + const ArchivedChatsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chats = ref.watch(archivedChatsProvider); + return Scaffold( + appBar: AppBar(title: const Text('Archived chats')), + body: ContentBounds( + child: AsyncFade( + value: chats, + loading: () => const ListSkeleton(), + error: (e, _) => ErrorView( + message: 'Failed to load archived chats: $e', + onRetry: () => ref.invalidate(archivedChatsProvider), + ), + data: (list) => list.isEmpty + ? const EmptyState( + icon: Icons.archive_outlined, + title: 'No archived chats', + message: + 'Chats you archive are kept here and can be restored.', + ) + : ListView(children: [for (final c in list) _ChatTile(item: c)]), + ), + ), + ); + } +} + +/// One conversation row: title, a preview of the latest message + when it was +/// last touched, and an overflow menu whose actions depend on whether the chat +/// is archived. Tapping continues the conversation. +class _ChatTile extends ConsumerWidget { + const _ChatTile({required this.item}); + + final ChatListItem item; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scheme = Theme.of(context).colorScheme; + final preview = item.preview?.replaceAll(RegExp(r'\s+'), ' ').trim(); + return ListTile( + leading: CircleAvatar( + backgroundColor: scheme.secondaryContainer, + foregroundColor: scheme.onSecondaryContainer, + child: const Icon(Icons.forum_outlined), + ), + title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + preview == null || preview.isEmpty + ? relativeTime(item.updatedAt) + : '$preview · ${relativeTime(item.updatedAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: PopupMenuButton( + tooltip: 'More', + onSelected: (value) => _onAction(context, ref, value), + itemBuilder: (context) => item.archived + ? const [ + PopupMenuItem(value: 'unarchive', child: Text('Unarchive')), + PopupMenuItem(value: 'delete', child: Text('Delete')), + ] + : const [ + PopupMenuItem(value: 'rename', child: Text('Rename')), + PopupMenuItem(value: 'archive', child: Text('Archive')), + PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + onTap: () => context.push('/ask/chat/${item.id}'), + ); + } + + Future _onAction( + BuildContext context, + WidgetRef ref, + String value, + ) async { + final repo = ref.read(chatRepositoryProvider); + switch (value) { + case 'rename': + final name = await _promptName(context, item.title); + if (name == null) return; + await repo.renameChat(item.id, name); + if (context.mounted) _notify(context, 'Renamed'); + case 'archive': + await repo.setArchived(item.id, true); + if (context.mounted) _notify(context, 'Chat archived'); + case 'unarchive': + await repo.setArchived(item.id, false); + if (context.mounted) _notify(context, 'Chat restored'); + case 'delete': + final ok = await confirm( + context, + title: 'Delete chat?', + message: + 'Delete "${item.title}"? This conversation can\'t be ' + 'recovered.', + confirmLabel: 'Delete', + destructive: true, + ); + if (!ok) return; + await repo.deleteChat(item.id); + if (context.mounted) _notify(context, 'Chat deleted'); + } + } + + Future _promptName(BuildContext context, String initial) { + final controller = TextEditingController(text: initial); + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Rename chat'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(hintText: 'Title'), + onSubmitted: (v) { + if (v.trim().isNotEmpty) Navigator.of(dialogContext).pop(v.trim()); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final v = controller.text.trim(); + if (v.isNotEmpty) Navigator.of(dialogContext).pop(v); + }, + child: const Text('Rename'), + ), + ], + ), + ); + } +} + +void _notify(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); +} diff --git a/test/features/ai/ask_controller_test.dart b/test/features/ai/ask_controller_test.dart index fee927d..e1f9339 100644 --- a/test/features/ai/ask_controller_test.dart +++ b/test/features/ai/ask_controller_test.dart @@ -49,9 +49,12 @@ class FakeGenerationEngine implements GenerationEngine { } /// Retriever returning a canned context (overrides the real embed→search path). +/// Records the [history] it was last handed so tests can assert prior turns feed +/// back when continuing a conversation. class FakeRagRetriever extends RagRetriever { FakeRagRetriever(super.ref, this._ctx); final RagContext _ctx; + List lastHistory = const []; @override Future retrieve( @@ -60,7 +63,10 @@ class FakeRagRetriever extends RagRetriever { int historyCharBudget = 1500, int maxSources = 6, int k = 30, - }) async => _ctx; + }) async { + lastHistory = history; + return _ctx; + } } RagContext _ctxWithSources(String question) => RagContext( @@ -107,9 +113,9 @@ void main() { engine: engine, ); - await c.read(askControllerProvider.notifier).send('what concerts?'); + await c.read(askControllerProvider(null).notifier).send('what concerts?'); - final state = c.read(askControllerProvider); + final state = c.read(askControllerProvider(null)); expect(state.chatId, isNotNull); expect(state.busy, isFalse); expect(state.streaming, isNull); @@ -130,9 +136,9 @@ void main() { final engine = FakeGenerationEngine(); final c = makeContainer(ctx: _emptyCtx('huh?'), engine: engine); - await c.read(askControllerProvider.notifier).send('huh?'); + await c.read(askControllerProvider(null).notifier).send('huh?'); - final state = c.read(askControllerProvider); + final state = c.read(askControllerProvider(null)); final repo = ChatRepository(db); final msgs = await repo.messagesForChat(state.chatId!); expect(msgs.map((m) => m.role), [kRoleUser, kRoleAssistant]); @@ -145,9 +151,9 @@ void main() { final engine = FakeGenerationEngine(fail: true); final c = makeContainer(ctx: _ctxWithSources('q'), engine: engine); - await c.read(askControllerProvider.notifier).send('q'); + await c.read(askControllerProvider(null).notifier).send('q'); - final state = c.read(askControllerProvider); + final state = c.read(askControllerProvider(null)); expect(state.error, contains("Couldn't answer")); expect(state.busy, isFalse); @@ -155,4 +161,39 @@ void main() { final msgs = await repo.messagesForChat(state.chatId!); expect(msgs.map((m) => m.role), [kRoleUser]); // user only; no assistant row }); + + test('continues an existing chat, feeding prior turns as history', () async { + // Seed a prior, completed turn in an existing conversation. + final repo = ChatRepository(db); + final chatId = await repo.createChat('Concerts'); + await repo.appendMessage(chatId, role: kRoleUser, content: 'first?'); + await repo.appendMessage( + chatId, + role: kRoleAssistant, + content: 'first answer', + ); + + final engine = FakeGenerationEngine(output: 'follow-up answer [1].'); + final c = makeContainer(ctx: _ctxWithSources('and then?'), engine: engine); + + // Keyed by the existing id → state is seeded with it (no new chat). + expect(c.read(askControllerProvider(chatId)).chatId, chatId); + + await c.read(askControllerProvider(chatId).notifier).send('and then?'); + + // Same conversation, now four messages (no new chat created). + final all = await repo.messagesForChat(chatId); + expect(all.map((m) => m.content), [ + 'first?', + 'first answer', + 'and then?', + 'follow-up answer [1].', + ]); + expect((await db.select(db.chats).get()).length, 1); + + // The prior turn was fed back as history; the in-flight question is not. + final retriever = c.read(ragRetrieverProvider) as FakeRagRetriever; + expect(retriever.lastHistory.map((t) => t.question), ['first?']); + expect(retriever.lastHistory.map((t) => t.answer), ['first answer']); + }); } diff --git a/test/features/ai/chat_repository_test.dart b/test/features/ai/chat_repository_test.dart index 26a3ebd..94dd41a 100644 --- a/test/features/ai/chat_repository_test.dart +++ b/test/features/ai/chat_repository_test.dart @@ -63,4 +63,82 @@ void main() { final msgs = await repo.watchMessages(id).first; expect(msgs.map((m) => m.content), ['q', 'a']); }); + + test( + 'watchChatList orders by recency, carries the latest message as preview', + () async { + // Two chats; make the second the more-recently-updated one. + final a = await repo.createChat('A'); + await repo.appendMessage(a, role: kRoleUser, content: 'a-q'); + await repo.appendMessage(a, role: kRoleAssistant, content: 'a-answer'); + await (db.update(db.chats)..where((t) => t.id.equals(a))).write( + ChatsCompanion(updatedAt: Value(DateTime.utc(2001))), + ); + + final b = await repo.createChat('B'); + await repo.appendMessage(b, role: kRoleUser, content: 'b-q'); + await (db.update(db.chats)..where((t) => t.id.equals(b))).write( + ChatsCompanion(updatedAt: Value(DateTime.utc(2002))), + ); + + final list = await repo.watchChatList(archived: false).first; + expect(list.map((c) => c.id), [b, a]); // most-recent-first + expect(list.first.preview, 'b-q'); // latest message + expect(list.last.preview, 'a-answer'); + expect(list.every((c) => c.archived), isFalse); + }, + ); + + test('watchChatList splits active vs archived', () async { + final active = await repo.createChat('active'); + final archived = await repo.createChat('archived'); + await repo.setArchived(archived, true); + + final activeList = await repo.watchChatList(archived: false).first; + final archivedList = await repo.watchChatList(archived: true).first; + expect(activeList.map((c) => c.id), [active]); + expect(archivedList.map((c) => c.id), [archived]); + expect(archivedList.single.archived, isTrue); + }); + + test('setArchived moves a chat between the two lists and back', () async { + final id = await repo.createChat('c'); + await repo.setArchived(id, true); + expect( + (await repo.watchChatList(archived: false).first).map((c) => c.id), + isEmpty, + ); + expect((await repo.watchChatList(archived: true).first).map((c) => c.id), [ + id, + ]); + + await repo.setArchived(id, false); + final chat = await (db.select( + db.chats, + )..where((t) => t.id.equals(id))).getSingle(); + expect(chat.archivedAt, isNull); + expect((await repo.watchChatList(archived: false).first).map((c) => c.id), [ + id, + ]); + }); + + test('renameChat updates the title; a blank rename is ignored', () async { + final id = await repo.createChat('original'); + await repo.renameChat(id, ' new title '); + expect(await repo.watchChatTitle(id).first, 'new title'); + + await repo.renameChat(id, ' '); + expect(await repo.watchChatTitle(id).first, 'new title'); + }); + + test('deleteChat removes the chat and cascades its messages', () async { + final id = await repo.createChat('doomed'); + await repo.appendMessage(id, role: kRoleUser, content: 'q'); + await repo.appendMessage(id, role: kRoleAssistant, content: 'a'); + + await repo.deleteChat(id); + + expect((await db.select(db.chats).get()), isEmpty); + expect((await db.select(db.chatMessages).get()), isEmpty); + }); } diff --git a/test/features/ai/conversations_screen_test.dart b/test/features/ai/conversations_screen_test.dart new file mode 100644 index 0000000..50f9494 --- /dev/null +++ b/test/features/ai/conversations_screen_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:grabbit/features/ai/data/chat_repository.dart'; +import 'package:grabbit/features/ai/presentation/conversations_screen.dart'; + +void main() { + Future pump(WidgetTester tester, List chats) async { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + ProviderScope( + overrides: [ + activeChatsProvider.overrideWith((ref) => Stream.value(chats)), + ], + child: const MaterialApp(home: ConversationsScreen()), + ), + ); + await tester.pump(); + } + + testWidgets('empty list shows the start-a-chat CTA', (tester) async { + await pump(tester, const []); + expect(find.text('Start a chat'), findsOneWidget); + }); + + testWidgets('renders a row per conversation with title + preview', ( + tester, + ) async { + await pump(tester, [ + ChatListItem( + id: 'chat_1', + title: 'Concert clips', + updatedAt: DateTime.now(), + preview: 'It was great', + archived: false, + ), + ]); + expect(find.text('Concert clips'), findsOneWidget); + expect(find.textContaining('It was great'), findsOneWidget); + expect(find.text('Start a chat'), findsNothing); + }); + + testWidgets('an active row offers rename / archive / delete', (tester) async { + await pump(tester, [ + ChatListItem( + id: 'chat_1', + title: 'Concert clips', + updatedAt: DateTime.now(), + preview: 'hi', + archived: false, + ), + ]); + + // The row's overflow menu (the AppBar also has one, for "Archived chats"). + await tester.tap( + find.descendant( + of: find.byType(ListTile), + matching: find.byType(PopupMenuButton), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Rename'), findsOneWidget); + expect(find.text('Archive'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + }); +}