diff --git a/apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart b/apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart index 928bcfd9..0d02ecda 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart @@ -147,6 +147,7 @@ class ChatNavigationPage extends StatefulWidget { this.onChannelArchive, this.onNodeSelected, this.onResourceSelected, + this.onResourceChanged, this.onRequestClose, this.closeOnChannelSelected = true, this.todoApiService, @@ -177,6 +178,10 @@ class ChatNavigationPage extends StatefulWidget { /// [_ResourcePreviewPage] internally. final ValueChanged? onResourceSelected; + /// Called after a resource detail edit succeeds so callers can refresh list + /// summaries and updated timestamps. + final VoidCallback? onResourceChanged; + final bool closeOnChannelSelected; /// Optional service used to fetch todo items inside [_ResourcePreviewPage]. @@ -289,6 +294,7 @@ class _ChatNavigationPageState extends State resource: resource, todoApiService: widget.todoApiService, noteApiService: widget.noteApiService, + onResourceChanged: widget.onResourceChanged, ), ), ); @@ -637,11 +643,13 @@ class _ResourcePreviewPage extends StatefulWidget { required this.resource, this.todoApiService, this.noteApiService, + this.onResourceChanged, }); final ChatResourceItem resource; final TodoApiService? todoApiService; final NoteApiService? noteApiService; + final VoidCallback? onResourceChanged; @override State<_ResourcePreviewPage> createState() => _ResourcePreviewPageState(); @@ -649,9 +657,13 @@ class _ResourcePreviewPage extends StatefulWidget { class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { List? _items; - NoteDetail? _note; + TextEditingController? _noteController; + final Set _updatingTodoIds = {}; bool _loading = false; - String? _error; + bool _savingNote = false; + bool _showNotePreview = false; + String? _loadError; + String? _editError; @override void initState() { @@ -659,40 +671,52 @@ class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { if (widget.resource.type == ChatResourceType.todoList) { _fetchItems(); } else if (widget.resource.type == ChatResourceType.note) { + _noteController = + TextEditingController(text: widget.resource.notes ?? ''); _fetchNote(); } } + @override + void dispose() { + _noteController?.dispose(); + super.dispose(); + } + + List _sortTodoItems(List raw) { + return List.from(raw) + ..sort((a, b) { + if (a.isCompleted != b.isCompleted) { + return a.isCompleted ? 1 : -1; + } + return a.displayOrder.compareTo(b.displayOrder); + }); + } + Future _fetchItems() async { final service = widget.todoApiService; if (service == null) return; setState(() { _loading = true; - _error = null; + _loadError = null; }); try { final raw = await service.listTodos( listId: widget.resource.id, ); - // Sort a copy: incomplete first (by displayOrder), then completed. - final items = List.from(raw) - ..sort((a, b) { - if (a.isCompleted != b.isCompleted) { - return a.isCompleted ? 1 : -1; - } - return a.displayOrder.compareTo(b.displayOrder); - }); + final items = _sortTodoItems(raw); if (mounted) { setState(() { _items = items; _loading = false; + _loadError = null; }); } } catch (e) { if (mounted) { setState(() { - _error = e.toString(); + _loadError = e.toString(); _loading = false; }); } @@ -705,26 +729,207 @@ class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { setState(() { _loading = true; - _error = null; + _loadError = null; }); try { final note = await service.getNote(widget.resource.id); if (mounted) { setState(() { - _note = note; + _noteController?.text = note.body; _loading = false; + _loadError = null; }); } } catch (e) { if (mounted) { setState(() { - _error = e.toString(); + _loadError = e.toString(); _loading = false; }); } } } + Future _toggleTodoItem(TodoItem item, bool isCompleted) async { + final service = widget.todoApiService; + if (service == null || _updatingTodoIds.contains(item.id)) return; + + setState(() { + _updatingTodoIds.add(item.id); + _editError = null; + }); + try { + final updated = await service.updateTodo( + listId: item.listId, + id: item.id, + isCompleted: isCompleted, + ); + if (!mounted) return; + setState(() { + final current = _items ?? const []; + _items = _sortTodoItems( + current + .map((candidate) => + candidate.id == updated.id ? updated : candidate) + .toList(), + ); + _updatingTodoIds.remove(item.id); + _editError = null; + }); + widget.onResourceChanged?.call(); + } catch (e) { + if (!mounted) return; + setState(() { + _editError = e.toString(); + _updatingTodoIds.remove(item.id); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Todo update failed: $e')), + ); + } + } + + Future _saveNote() async { + final service = widget.noteApiService; + final controller = _noteController; + if (service == null || controller == null || _savingNote) return; + + setState(() { + _savingNote = true; + _editError = null; + }); + try { + final updated = await service.updateNote( + noteId: widget.resource.id, + body: controller.text, + ); + if (!mounted) return; + setState(() { + _noteController?.text = updated.body; + _savingNote = false; + _editError = null; + }); + widget.onResourceChanged?.call(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Note saved')), + ); + } catch (e) { + if (!mounted) return; + setState(() { + _editError = e.toString(); + _savingNote = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Note save failed: $e')), + ); + } + } + + void _applyMarkdownFormat(_MarkdownFormatAction action) { + final controller = _noteController; + if (controller == null) return; + + switch (action) { + case _MarkdownFormatAction.heading: + _prefixCurrentLine('## '); + return; + case _MarkdownFormatAction.bold: + _surroundSelection('**', '**', 'bold text'); + return; + case _MarkdownFormatAction.italic: + _surroundSelection('*', '*', 'italic text'); + return; + case _MarkdownFormatAction.unorderedList: + _prefixCurrentLine('- '); + return; + case _MarkdownFormatAction.orderedList: + _prefixCurrentLine('1. '); + return; + case _MarkdownFormatAction.checklist: + _prefixCurrentLine('- [ ] '); + return; + case _MarkdownFormatAction.quote: + _prefixCurrentLine('> '); + return; + case _MarkdownFormatAction.code: + _insertCodeMarkup(); + return; + } + } + + ({String text, int start, int end}) _currentNoteSelection() { + final controller = _noteController!; + final text = controller.text; + final selection = controller.selection; + final rawStart = selection.isValid ? selection.start : text.length; + final rawEnd = selection.isValid ? selection.end : text.length; + final start = rawStart.clamp(0, text.length).toInt(); + final end = rawEnd.clamp(0, text.length).toInt(); + return ( + text: text, + start: start < end ? start : end, + end: end > start ? end : start + ); + } + + void _surroundSelection( + String prefix, + String suffix, + String placeholder, + ) { + final controller = _noteController!; + final selection = _currentNoteSelection(); + final selected = selection.text.substring(selection.start, selection.end); + final inner = selected.isEmpty ? placeholder : selected; + final replacement = '$prefix$inner$suffix'; + controller.value = TextEditingValue( + text: selection.text.replaceRange( + selection.start, + selection.end, + replacement, + ), + selection: TextSelection( + baseOffset: selection.start + prefix.length, + extentOffset: selection.start + prefix.length + inner.length, + ), + ); + } + + void _prefixCurrentLine(String prefix) { + final controller = _noteController!; + final selection = _currentNoteSelection(); + final searchStart = selection.start == 0 ? 0 : selection.start - 1; + final lineStart = selection.text.lastIndexOf('\n', searchStart) + 1; + controller.value = TextEditingValue( + text: selection.text.replaceRange(lineStart, lineStart, prefix), + selection: TextSelection.collapsed( + offset: selection.end + prefix.length, + ), + ); + } + + void _insertCodeMarkup() { + final controller = _noteController!; + final selection = _currentNoteSelection(); + final selected = selection.text.substring(selection.start, selection.end); + final inner = selected.isEmpty ? 'code' : selected; + final isBlock = inner.contains('\n'); + final prefix = isBlock ? '```\n' : '`'; + final suffix = isBlock ? '\n```' : '`'; + final replacement = '$prefix$inner$suffix'; + controller.value = TextEditingValue( + text: selection.text.replaceRange( + selection.start, + selection.end, + replacement, + ), + selection: TextSelection( + baseOffset: selection.start + prefix.length, + extentOffset: selection.start + prefix.length + inner.length, + ), + ); + } + @override Widget build(BuildContext context) { final resource = widget.resource; @@ -747,6 +952,16 @@ class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { title: const Text('Notes'), subtitle: Text(notes), ), + if (_editError != null) + ListTile( + leading: const Icon(Icons.error_outline), + title: const Text('Update failed'), + subtitle: Text( + _editError!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), if (isTodoList) ...[ const Divider(), Padding( @@ -761,12 +976,12 @@ class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: CircularProgressIndicator()), ) - else if (_error != null) + else if (_loadError != null) ListTile( leading: const Icon(Icons.error_outline), title: const Text('Failed to load items'), subtitle: Text( - _error!, + _loadError!, maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -782,7 +997,15 @@ class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { subtitle: Text('This list has no todo items yet'), ) else - ..._items!.map((item) => _TodoItemTile(item: item)), + ..._items!.map( + (item) => _TodoItemTile( + item: item, + isUpdating: _updatingTodoIds.contains(item.id), + onChanged: widget.todoApiService == null + ? null + : (value) => _toggleTodoItem(item, value), + ), + ), ], if (isNote) ...[ const Divider(), @@ -798,12 +1021,12 @@ class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: CircularProgressIndicator()), ) - else if (_error != null) + else if (_loadError != null) ListTile( leading: const Icon(Icons.error_outline), title: const Text('Failed to load note'), subtitle: Text( - _error!, + _loadError!, maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -812,16 +1035,67 @@ class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { onPressed: _fetchNote, ), ) - else + else ...[ Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: SelectableText( - _note?.body ?? resource.notes ?? '', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - height: 1.45, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.edit_outlined), + label: Text('Edit'), ), + ButtonSegment( + value: true, + icon: Icon(Icons.visibility_outlined), + label: Text('Preview'), + ), + ], + selected: {_showNotePreview}, + onSelectionChanged: (selection) { + setState(() { + _showNotePreview = selection.first; + }); + }, + ), + ), + ), + if (_showNotePreview) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: _MarkdownNotePreview( + text: _noteController?.text ?? '', + ), + ) + else + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: _MarkdownNoteEditor( + controller: _noteController!, + onFormat: _applyMarkdownFormat, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: widget.noteApiService == null || _savingNote + ? null + : _saveNote, + icon: _savingNote + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Save'), + ), ), ), + ], ], const SizedBox(height: 24), ], @@ -830,11 +1104,304 @@ class _ResourcePreviewPageState extends State<_ResourcePreviewPage> { } } +enum _MarkdownFormatAction { + heading, + bold, + italic, + unorderedList, + orderedList, + checklist, + quote, + code, +} + +class _MarkdownNoteEditor extends StatelessWidget { + const _MarkdownNoteEditor({ + required this.controller, + required this.onFormat, + }); + + final TextEditingController controller; + final ValueChanged<_MarkdownFormatAction> onFormat; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Material( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _MarkdownToolbarButton( + icon: Icons.title, + tooltip: 'Heading', + onPressed: () => onFormat(_MarkdownFormatAction.heading), + ), + _MarkdownToolbarButton( + icon: Icons.format_bold, + tooltip: 'Bold', + onPressed: () => onFormat(_MarkdownFormatAction.bold), + ), + _MarkdownToolbarButton( + icon: Icons.format_italic, + tooltip: 'Italic', + onPressed: () => onFormat(_MarkdownFormatAction.italic), + ), + _MarkdownToolbarButton( + icon: Icons.format_list_bulleted, + tooltip: 'Bulleted list', + onPressed: () => + onFormat(_MarkdownFormatAction.unorderedList), + ), + _MarkdownToolbarButton( + icon: Icons.format_list_numbered, + tooltip: 'Numbered list', + onPressed: () => + onFormat(_MarkdownFormatAction.orderedList), + ), + _MarkdownToolbarButton( + icon: Icons.checklist, + tooltip: 'Checklist', + onPressed: () => onFormat(_MarkdownFormatAction.checklist), + ), + _MarkdownToolbarButton( + icon: Icons.format_quote, + tooltip: 'Quote', + onPressed: () => onFormat(_MarkdownFormatAction.quote), + ), + _MarkdownToolbarButton( + icon: Icons.code, + tooltip: 'Code', + onPressed: () => onFormat(_MarkdownFormatAction.code), + ), + ], + ), + ), + ), + TextField( + controller: controller, + minLines: 8, + maxLines: null, + keyboardType: TextInputType.multiline, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.all(12), + ), + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.45, + fontFamily: 'monospace', + ), + ), + ], + ), + ); + } +} + +class _MarkdownToolbarButton extends StatelessWidget { + const _MarkdownToolbarButton({ + required this.icon, + required this.tooltip, + required this.onPressed, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: tooltip, + icon: Icon(icon), + onPressed: onPressed, + ); + } +} + +class _MarkdownNotePreview extends StatelessWidget { + const _MarkdownNotePreview({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final lines = text.split('\n'); + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final line in lines) _MarkdownPreviewLine(line: line), + ], + ), + ), + ); + } +} + +class _MarkdownPreviewLine extends StatelessWidget { + const _MarkdownPreviewLine({required this.line}); + + final String line; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final trimmed = line.trimLeft(); + final indent = line.length - trimmed.length; + if (trimmed.isEmpty) { + return const SizedBox(height: 12); + } + if (trimmed.startsWith('# ')) { + return _PreviewText( + trimmed.substring(2), + style: theme.textTheme.titleLarge, + ); + } + if (trimmed.startsWith('## ')) { + return _PreviewText( + trimmed.substring(3), + style: theme.textTheme.titleMedium, + ); + } + if (trimmed.startsWith('### ')) { + return _PreviewText( + trimmed.substring(4), + style: theme.textTheme.titleSmall, + ); + } + if (trimmed.startsWith('> ')) { + return Padding( + padding: EdgeInsets.only(left: indent.toDouble()), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: theme.colorScheme.primary, + width: 3, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: _PreviewText( + trimmed.substring(2), + style: theme.textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + ), + ), + ), + ), + ); + } + final checklist = _stripListPrefix(trimmed, '- [ ] ') ?? + _stripListPrefix(trimmed, '- [x] ') ?? + _stripListPrefix(trimmed, '- [X] '); + if (checklist != null) { + return _PreviewListLine( + indent: indent, + marker: trimmed.startsWith('- [ ] ') ? '☐' : '☑', + text: checklist, + ); + } + final unordered = + _stripListPrefix(trimmed, '- ') ?? _stripListPrefix(trimmed, '* '); + if (unordered != null) { + return _PreviewListLine( + indent: indent, + marker: '•', + text: unordered, + ); + } + final ordered = RegExp(r'^\d+\.\s+').firstMatch(trimmed); + if (ordered != null) { + return _PreviewListLine( + indent: indent, + marker: trimmed.substring(0, ordered.end).trim(), + text: trimmed.substring(ordered.end), + ); + } + return Padding( + padding: EdgeInsets.only(left: indent.toDouble()), + child: _PreviewText(trimmed), + ); + } + + static String? _stripListPrefix(String text, String prefix) { + return text.startsWith(prefix) ? text.substring(prefix.length) : null; + } +} + +class _PreviewListLine extends StatelessWidget { + const _PreviewListLine({ + required this.indent, + required this.marker, + required this.text, + }); + + final int indent; + final String marker; + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(left: indent.toDouble()), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 28, child: Text(marker)), + Expanded(child: _PreviewText(text)), + ], + ), + ); + } +} + +class _PreviewText extends StatelessWidget { + const _PreviewText(this.text, {this.style}); + + final String text; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return SelectableText( + text, + style: style ?? Theme.of(context).textTheme.bodyMedium, + ); + } +} + /// A single row in the todo-list detail view. class _TodoItemTile extends StatelessWidget { - const _TodoItemTile({required this.item}); + const _TodoItemTile({ + required this.item, + required this.isUpdating, + this.onChanged, + }); final TodoItem item; + final bool isUpdating; + final ValueChanged? onChanged; static String _formatDate(DateTime dt) { // Format as YYYY-MM-DD HH:mm (local time). @@ -849,13 +1416,17 @@ class _TodoItemTile extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - leading: Icon( - item.isCompleted - ? Icons.check_circle_outline - : Icons.radio_button_unchecked, - color: item.isCompleted ? Theme.of(context).colorScheme.primary : null, - ), + return CheckboxListTile( + value: item.isCompleted, + onChanged: isUpdating || onChanged == null + ? null + : (value) => onChanged!(value ?? false), + secondary: isUpdating + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, title: Text( item.title, style: item.isCompleted diff --git a/apps/mobile_chat_app/lib/features/chat/chat_screen.dart b/apps/mobile_chat_app/lib/features/chat/chat_screen.dart index cdef181a..01a99993 100644 --- a/apps/mobile_chat_app/lib/features/chat/chat_screen.dart +++ b/apps/mobile_chat_app/lib/features/chat/chat_screen.dart @@ -33,6 +33,7 @@ import 'note_api_service.dart'; import 'site_publish_api_service.dart'; import 'todo_api_service.dart'; import 'widgets/composer_bar.dart'; +import 'widgets/composer_pasted_image.dart'; import 'widgets/message_list.dart'; import '../../services/authenticated_api_client.dart'; @@ -2552,6 +2553,31 @@ class _ChatScreenState extends State { } } + Future _attachPastedImageToDraft(ComposerPastedImage image) async { + if (!mounted || _isUploadingAttachment || _isSending) return; + final filename = image.filename.trim().isEmpty + ? 'pasted-image.png' + : image.filename.trim(); + final mimeType = + image.mimeType.trim().isEmpty ? 'image/png' : image.mimeType.trim(); + final generation = ++_imageUploadGeneration; + setState(() { + _isUploadingAttachment = true; + _draftUpload = ComposerDraftUpload( + filename: filename, + mimeType: mimeType, + dataBase64: image.dataBase64, + isUploading: true, + ); + }); + await _uploadDraftImage( + filename: filename, + mimeType: mimeType, + dataBase64: image.dataBase64, + generation: generation, + ); + } + Future _uploadDraftImage({ required String filename, required String mimeType, @@ -3227,6 +3253,7 @@ class _ChatScreenState extends State { closeOnChannelSelected: closeOnChannelSelected, todoApiService: _todoApiService, noteApiService: _noteApiService, + onResourceChanged: _refreshResources, onActionSelected: (action) { switch (action) { case ChatNavigationAction.appSettings: @@ -3541,6 +3568,8 @@ class _ChatScreenState extends State { onSend: _isSending ? null : _sendMessage, onAttachImage: _isUploadingAttachment ? null : _attachImageToDraft, + onPasteImage: + _isUploadingAttachment ? null : _attachPastedImageToDraft, onCancelDraftUpload: _cancelDraftImageUpload, onRetryDraftUpload: _retryDraftImageUpload, onRemoveAttachment: _removePendingAttachment, diff --git a/apps/mobile_chat_app/lib/features/chat/note_api_service.dart b/apps/mobile_chat_app/lib/features/chat/note_api_service.dart index 33cac9fb..e916f696 100644 --- a/apps/mobile_chat_app/lib/features/chat/note_api_service.dart +++ b/apps/mobile_chat_app/lib/features/chat/note_api_service.dart @@ -133,6 +133,29 @@ class NoteApiService { jsonDecode(response.body) as Map); } + Future updateNote({ + required String noteId, + String? title, + String? body, + bool? isPublished, + }) async { + final patch = {}; + if (title != null) patch['title'] = title; + if (body != null) patch['body'] = body; + if (isPublished != null) patch['isPublished'] = isPublished; + + final response = await _apiClient.patch( + _noteUri(noteId), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(patch), + ); + if (response.statusCode != 200) { + throw Exception('Failed to update note: ${response.statusCode}'); + } + return NoteDetail.fromJson( + jsonDecode(response.body) as Map); + } + void dispose() { if (_ownsClient) { _apiClient.close(); diff --git a/apps/mobile_chat_app/lib/features/chat/widgets/composer_bar.dart b/apps/mobile_chat_app/lib/features/chat/widgets/composer_bar.dart index c717e3a7..c9b5d8af 100644 --- a/apps/mobile_chat_app/lib/features/chat/widgets/composer_bar.dart +++ b/apps/mobile_chat_app/lib/features/chat/widgets/composer_bar.dart @@ -3,14 +3,25 @@ import 'dart:convert'; import 'package:chat_domain/chat_domain.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../auth/auth_service.dart'; import '../../settings/llm_config_service.dart'; import '../chat_message.dart'; +import 'composer_paste_image.dart'; +import 'composer_pasted_image.dart'; /// Actions available in the composer popup menu. enum ComposerMenuAction { model, info } +class _SubmitComposerIntent extends Intent { + const _SubmitComposerIntent(); +} + +class _InsertComposerNewlineIntent extends Intent { + const _InsertComposerNewlineIntent(); +} + class ComposerAtAction { const ComposerAtAction({ required this.value, @@ -58,6 +69,7 @@ class ComposerBar extends StatefulWidget { this.draftUpload, this.onSend, this.onAttachImage, + this.onPasteImage, this.onCancelDraftUpload, this.onRetryDraftUpload, this.onRemoveAttachment, @@ -81,6 +93,7 @@ class ComposerBar extends StatefulWidget { final ComposerDraftUpload? draftUpload; final void Function(String text)? onSend; final VoidCallback? onAttachImage; + final void Function(ComposerPastedImage image)? onPasteImage; final VoidCallback? onCancelDraftUpload; final VoidCallback? onRetryDraftUpload; final void Function(String mediaId)? onRemoveAttachment; @@ -106,6 +119,7 @@ class _ComposerBarState extends State with SingleTickerProviderStateMixin { final _controller = TextEditingController(); final _focusNode = FocusNode(); + late final ComposerPasteImageSubscription _pasteImageSubscription; late AnimationController _spinController; bool _hasDraft = false; @@ -118,6 +132,10 @@ class _ComposerBarState extends State )..repeat(); _focusNode.addListener(() => setState(() {})); _controller.addListener(_onDraftChanged); + _pasteImageSubscription = listenForComposerPastedImages( + focusNode: _focusNode, + onImage: _handlePastedImage, + ); } void _onDraftChanged() { @@ -153,6 +171,29 @@ class _ComposerBarState extends State }); } + void _handlePastedImage(ComposerPastedImage image) { + if (!mounted || + widget.onPasteImage == null || + widget.draftUpload != null || + widget.isStreaming || + widget.onSend == null) { + return; + } + widget.onPasteImage!(image); + } + + void _insertNewLineAtCursor() { + final value = _controller.value; + final selection = value.selection; + final start = selection.isValid ? selection.start : value.text.length; + final end = selection.isValid ? selection.end : value.text.length; + final nextText = value.text.replaceRange(start, end, '\n'); + _controller.value = TextEditingValue( + text: nextText, + selection: TextSelection.collapsed(offset: start + 1), + ); + } + void _insertSlashCommand(String command) { final trimmed = command.trim(); if (trimmed.isEmpty) return; @@ -179,6 +220,7 @@ class _ComposerBarState extends State @override void dispose() { + _pasteImageSubscription.cancel(); _spinController.dispose(); _controller.removeListener(_onDraftChanged); _controller.dispose(); @@ -251,30 +293,56 @@ class _ComposerBarState extends State ), ), ), - TextField( - controller: _controller, - focusNode: _focusNode, - enabled: true, - maxLines: 5, - minLines: 1, - textInputAction: TextInputAction.send, - onSubmitted: (_) => _submit(), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: chatColors.onMessageAssistant), - decoration: InputDecoration( - hintText: 'Ask Bricks to create something…', - hintStyle: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith(color: chatColors.composerPlaceholder), - border: InputBorder.none, - contentPadding: const EdgeInsets.fromLTRB( - BricksSpacing.md, - 6, - BricksSpacing.md, - 2, + Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.enter): + _SubmitComposerIntent(), + SingleActivator(LogicalKeyboardKey.enter, shift: true): + _InsertComposerNewlineIntent(), + }, + child: Actions( + actions: >{ + _SubmitComposerIntent: + CallbackAction<_SubmitComposerIntent>( + onInvoke: (_) { + _submit(); + return null; + }, + ), + _InsertComposerNewlineIntent: + CallbackAction<_InsertComposerNewlineIntent>( + onInvoke: (_) { + _insertNewLineAtCursor(); + return null; + }, + ), + }, + child: TextField( + controller: _controller, + focusNode: _focusNode, + enabled: true, + maxLines: 5, + minLines: 1, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _submit(), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: chatColors.onMessageAssistant), + decoration: InputDecoration( + hintText: 'Ask Bricks to create something…', + hintStyle: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: chatColors.composerPlaceholder), + border: InputBorder.none, + contentPadding: const EdgeInsets.fromLTRB( + BricksSpacing.md, + 6, + BricksSpacing.md, + 2, + ), + ), ), ), ), diff --git a/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image.dart b/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image.dart new file mode 100644 index 00000000..16a20cfa --- /dev/null +++ b/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image.dart @@ -0,0 +1,15 @@ +import 'package:flutter/widgets.dart'; + +import 'composer_paste_image_stub.dart' + if (dart.library.html) 'composer_paste_image_web.dart' as impl; +import 'composer_pasted_image.dart'; + +ComposerPasteImageSubscription listenForComposerPastedImages({ + required FocusNode focusNode, + required ValueChanged onImage, +}) { + return impl.listenForComposerPastedImages( + focusNode: focusNode, + onImage: onImage, + ); +} diff --git a/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_stub.dart b/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_stub.dart new file mode 100644 index 00000000..14284daf --- /dev/null +++ b/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_stub.dart @@ -0,0 +1,18 @@ +import 'package:flutter/widgets.dart'; + +import 'composer_pasted_image.dart'; + +class _NoopComposerPasteImageSubscription + implements ComposerPasteImageSubscription { + const _NoopComposerPasteImageSubscription(); + + @override + void cancel() {} +} + +ComposerPasteImageSubscription listenForComposerPastedImages({ + required FocusNode focusNode, + required ValueChanged onImage, +}) { + return const _NoopComposerPasteImageSubscription(); +} diff --git a/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_web.dart b/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_web.dart new file mode 100644 index 00000000..82f7f8ce --- /dev/null +++ b/apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_web.dart @@ -0,0 +1,87 @@ +// ignore_for_file: deprecated_member_use + +import 'dart:convert'; +import 'dart:html' as html; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; + +import 'composer_pasted_image.dart'; + +class _WebComposerPasteImageSubscription + implements ComposerPasteImageSubscription { + _WebComposerPasteImageSubscription(this._listener) { + html.document.addEventListener('paste', _listener); + } + + final html.EventListener _listener; + + @override + void cancel() { + html.document.removeEventListener('paste', _listener); + } +} + +ComposerPasteImageSubscription listenForComposerPastedImages({ + required FocusNode focusNode, + required ValueChanged onImage, +}) { + void listener(html.Event event) { + if (!focusNode.hasFocus || event is! html.ClipboardEvent) return; + final items = event.clipboardData?.items; + if (items == null) return; + + final itemCount = items.length ?? 0; + for (var index = 0; index < itemCount; index++) { + final item = items[index]; + final mimeType = item.type?.toLowerCase() ?? ''; + if (!mimeType.startsWith('image/')) continue; + + final file = item.getAsFile(); + if (file == null) continue; + event.preventDefault(); + + final reader = html.FileReader(); + reader.onLoad.first.then((_) { + final result = reader.result; + Uint8List bytes; + if (result is ByteBuffer) { + bytes = Uint8List.view(result); + } else if (result is Uint8List) { + bytes = result; + } else { + return; + } + final extension = _extensionForMimeType(mimeType); + final name = file.name.trim().isEmpty + ? 'pasted-image.$extension' + : file.name.trim(); + onImage( + ComposerPastedImage( + filename: name, + mimeType: mimeType, + dataBase64: base64Encode(bytes), + ), + ); + }); + reader.readAsArrayBuffer(file); + return; + } + } + + return _WebComposerPasteImageSubscription(listener); +} + +String _extensionForMimeType(String mimeType) { + switch (mimeType) { + case 'image/jpeg': + return 'jpg'; + case 'image/webp': + return 'webp'; + case 'image/gif': + return 'gif'; + case 'image/png': + default: + return 'png'; + } +} diff --git a/apps/mobile_chat_app/lib/features/chat/widgets/composer_pasted_image.dart b/apps/mobile_chat_app/lib/features/chat/widgets/composer_pasted_image.dart new file mode 100644 index 00000000..973b55ea --- /dev/null +++ b/apps/mobile_chat_app/lib/features/chat/widgets/composer_pasted_image.dart @@ -0,0 +1,15 @@ +class ComposerPastedImage { + const ComposerPastedImage({ + required this.filename, + required this.mimeType, + required this.dataBase64, + }); + + final String filename; + final String mimeType; + final String dataBase64; +} + +abstract class ComposerPasteImageSubscription { + void cancel(); +} diff --git a/apps/mobile_chat_app/test/chat_navigation_page_test.dart b/apps/mobile_chat_app/test/chat_navigation_page_test.dart index 3779b2ce..4c6b76fe 100644 --- a/apps/mobile_chat_app/test/chat_navigation_page_test.dart +++ b/apps/mobile_chat_app/test/chat_navigation_page_test.dart @@ -1,6 +1,91 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mobile_chat_app/features/chat/chat_navigation_page.dart'; +import 'package:mobile_chat_app/features/chat/note_api_service.dart'; +import 'package:mobile_chat_app/features/chat/todo_api_service.dart'; + +final _resourceDate = DateTime.utc(2026, 7, 3, 9); + +class _FakeTodoApiService extends TodoApiService { + _FakeTodoApiService(this.items); + + List items; + bool? lastIsCompleted; + bool failNextUpdate = false; + + @override + Future> listTodos({ + required String listId, + bool includeCompleted = true, + }) async { + return items; + } + + @override + Future updateTodo({ + required String listId, + required String id, + String? title, + String? notes, + bool? isCompleted, + }) async { + if (failNextUpdate) { + failNextUpdate = false; + throw Exception('todo update failed'); + } + lastIsCompleted = isCompleted; + final current = items.singleWhere((item) => item.id == id); + final updated = TodoItem( + id: current.id, + listId: current.listId, + title: title ?? current.title, + notes: notes ?? current.notes, + isCompleted: isCompleted ?? current.isCompleted, + displayOrder: current.displayOrder, + createdAt: current.createdAt, + updatedAt: _resourceDate.add(const Duration(minutes: 1)), + ); + items = items.map((item) => item.id == id ? updated : item).toList(); + return updated; + } +} + +class _FakeNoteApiService extends NoteApiService { + _FakeNoteApiService(this.detail); + + NoteDetail detail; + String? lastBody; + bool failNextUpdate = false; + + @override + Future getNote(String noteId) async { + return detail; + } + + @override + Future updateNote({ + required String noteId, + String? title, + String? body, + bool? isPublished, + }) async { + if (failNextUpdate) { + failNextUpdate = false; + throw Exception('note save failed'); + } + lastBody = body; + detail = NoteDetail( + id: detail.id, + title: title ?? detail.title, + body: body ?? detail.body, + isPublished: isPublished ?? detail.isPublished, + lineCount: (body ?? detail.body).split('\n').length, + createdAt: detail.createdAt, + updatedAt: _resourceDate.add(const Duration(minutes: 1)), + ); + return detail; + } +} Widget _buildPage({ ValueChanged? onActionSelected, @@ -11,6 +96,9 @@ Widget _buildPage({ bool closeOnChannelSelected = true, List nodes = const [], List resources = const [], + TodoApiService? todoApiService, + NoteApiService? noteApiService, + VoidCallback? onResourceChanged, TextDirection textDirection = TextDirection.ltr, }) => MaterialApp( @@ -30,6 +118,9 @@ Widget _buildPage({ selectedChannelId: 'default', nodes: nodes, resources: resources, + todoApiService: todoApiService, + noteApiService: noteApiService, + onResourceChanged: onResourceChanged, onChannelSelected: onChannelSelected, onChannelRename: onChannelRename, onChannelArchive: onChannelArchive, @@ -485,6 +576,252 @@ void main() { expect(find.text('# Research preview'), findsWidgets); }); + testWidgets('todo preview marks an item done and refreshes resources', + (tester) async { + var refreshCount = 0; + final todoService = _FakeTodoApiService([ + TodoItem( + id: 'todo_item_1', + listId: 'todo_1', + title: 'Draft worksheet', + isCompleted: false, + displayOrder: 0, + createdAt: _resourceDate, + updatedAt: _resourceDate, + ), + ]); + + await tester.pumpWidget(_buildPage( + todoApiService: todoService, + onResourceChanged: () => refreshCount++, + resources: [ + ChatResourceItem( + id: 'todo_1', + type: ChatResourceType.todoList, + title: 'My Todo List', + updatedAt: _resourceDate, + ), + ], + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resources')); + await tester.pumpAndSettle(); + await tester.tap(find.text('My Todo List')); + await tester.pumpAndSettle(); + + expect(find.text('Draft worksheet'), findsOneWidget); + expect(find.textContaining('Pending'), findsOneWidget); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + + expect(todoService.lastIsCompleted, isTrue); + expect(refreshCount, 1); + expect(find.textContaining('Completed'), findsOneWidget); + }); + + testWidgets('todo update failure keeps current items visible', + (tester) async { + final todoService = _FakeTodoApiService([ + TodoItem( + id: 'todo_item_1', + listId: 'todo_1', + title: 'Draft worksheet', + isCompleted: false, + displayOrder: 0, + createdAt: _resourceDate, + updatedAt: _resourceDate, + ), + ]) + ..failNextUpdate = true; + + await tester.pumpWidget(_buildPage( + todoApiService: todoService, + resources: [ + ChatResourceItem( + id: 'todo_1', + type: ChatResourceType.todoList, + title: 'My Todo List', + updatedAt: _resourceDate, + ), + ], + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resources')); + await tester.pumpAndSettle(); + await tester.tap(find.text('My Todo List')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(Checkbox)); + await tester.pumpAndSettle(); + + expect(find.text('Draft worksheet'), findsOneWidget); + expect(find.textContaining('Pending'), findsOneWidget); + expect(find.text('Failed to load items'), findsNothing); + expect(find.textContaining('Todo update failed'), findsWidgets); + }); + + testWidgets('note preview edits and saves markdown body', (tester) async { + var refreshCount = 0; + final noteService = _FakeNoteApiService( + NoteDetail( + id: 'note_1', + title: 'Research Note', + body: '# Research preview', + isPublished: true, + lineCount: 1, + createdAt: _resourceDate, + updatedAt: _resourceDate, + ), + ); + + await tester.pumpWidget(_buildPage( + noteApiService: noteService, + onResourceChanged: () => refreshCount++, + resources: [ + ChatResourceItem( + id: 'note_1', + type: ChatResourceType.note, + title: 'Research Note', + updatedAt: _resourceDate, + notes: '# Research preview', + ), + ], + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resources')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Research Note')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '# Updated\n- item'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(noteService.lastBody, '# Updated\n- item'); + expect(refreshCount, 1); + expect(find.text('Note saved'), findsOneWidget); + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, '# Updated\n- item'); + }); + + testWidgets('note preview has markdown toolbar and preview mode', + (tester) async { + final noteService = _FakeNoteApiService( + NoteDetail( + id: 'note_1', + title: 'Research Note', + body: 'Plain note', + isPublished: true, + lineCount: 1, + createdAt: _resourceDate, + updatedAt: _resourceDate, + ), + ); + + await tester.pumpWidget(_buildPage( + noteApiService: noteService, + resources: [ + ChatResourceItem( + id: 'note_1', + type: ChatResourceType.note, + title: 'Research Note', + updatedAt: _resourceDate, + notes: 'Plain note', + ), + ], + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resources')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Research Note')); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Bold'), findsOneWidget); + expect(find.byTooltip('Checklist'), findsOneWidget); + + await tester.tap(find.byType(TextField)); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Plain note', + selection: TextSelection.collapsed(offset: 0), + ), + ); + await tester.pump(); + await tester.tap(find.byTooltip('Checklist')); + await tester.pumpAndSettle(); + + var textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, '- [ ] Plain note'); + + await tester.enterText(find.byType(TextField), 'Plain note'); + tester.testTextInput.updateEditingValue( + const TextEditingValue( + text: 'Plain note', + selection: TextSelection(baseOffset: 0, extentOffset: 10), + ), + ); + await tester.pump(); + await tester.tap(find.byTooltip('Bold')); + await tester.pumpAndSettle(); + + textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, '**Plain note**'); + + await tester.tap(find.text('Preview')); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + expect(find.text('**Plain note**'), findsOneWidget); + }); + + testWidgets('note save failure keeps editor visible', (tester) async { + final noteService = _FakeNoteApiService( + NoteDetail( + id: 'note_1', + title: 'Research Note', + body: '# Research preview', + isPublished: true, + lineCount: 1, + createdAt: _resourceDate, + updatedAt: _resourceDate, + ), + )..failNextUpdate = true; + + await tester.pumpWidget(_buildPage( + noteApiService: noteService, + resources: [ + ChatResourceItem( + id: 'note_1', + type: ChatResourceType.note, + title: 'Research Note', + updatedAt: _resourceDate, + notes: '# Research preview', + ), + ], + )); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Resources')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Research Note')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '# Updated'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Failed to load note'), findsNothing); + expect(find.textContaining('Note save failed'), findsWidgets); + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, '# Updated'); + }); + testWidgets('tapping New Channel fires createChannel action', (tester) async { ChatNavigationAction? received; diff --git a/apps/mobile_chat_app/test/composer_bar_test.dart b/apps/mobile_chat_app/test/composer_bar_test.dart index b0f00e9b..01f82159 100644 --- a/apps/mobile_chat_app/test/composer_bar_test.dart +++ b/apps/mobile_chat_app/test/composer_bar_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mobile_chat_app/features/chat/widgets/composer_bar.dart'; @@ -14,7 +15,8 @@ Widget _buildBar( String? activeModelLabel, List slashCommands = const [], List atActions = const [], - void Function(String value)? onAtActionSelected}) => + void Function(String value)? onAtActionSelected, + void Function(String text)? onSend}) => MaterialApp( home: Scaffold( body: Column( @@ -30,6 +32,7 @@ Widget _buildBar( onAtActionSelected: onAtActionSelected, onOpenModelSelection: onOpenModelSelection, onShowInfo: onShowInfo, + onSend: onSend, ), ], ), @@ -374,6 +377,48 @@ void main() { final textField = tester.widget(find.byType(TextField)); expect(textField.focusNode?.hasFocus, isTrue); }); + + testWidgets('Shift+Enter inserts newline without sending', (tester) async { + var sendCount = 0; + + await tester.pumpWidget( + _buildBar( + onSend: (_) => sendCount++, + ), + ); + await tester.pump(); + + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'hello'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); + + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, 'hello\n'); + expect(sendCount, 0); + }); + + testWidgets('Enter sends the current draft', (tester) async { + String? sent; + + await tester.pumpWidget( + _buildBar( + onSend: (text) => sent = text, + ), + ); + await tester.pump(); + + await tester.tap(find.byType(TextField)); + await tester.enterText(find.byType(TextField), 'hello'); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(_settle); + + expect(sent, 'hello'); + final textField = tester.widget(find.byType(TextField)); + expect(textField.controller?.text, isEmpty); + }); }); group('ComposerBar – input copy', () { diff --git a/docs/code_maps/feature_map.yaml b/docs/code_maps/feature_map.yaml index f9ef31ae..e945f94a 100644 --- a/docs/code_maps/feature_map.yaml +++ b/docs/code_maps/feature_map.yaml @@ -79,6 +79,7 @@ products: smoke_checks: - 发送一条消息后可以看到消息列表更新。 - 发送消息后输入框应保持可输入并恢复焦点,流式回复期间仍可继续编辑下一条草稿。 + - 输入框内按 `Shift+Enter` 插入换行且不发送;普通 `Enter` 仍发送当前草稿。 - 宽屏内联 Navigation 打开时,拖动右侧分隔手柄应能在最小/最大宽度范围内调整侧边栏宽度。 - 宽屏内联 Navigation 打开时,Navigation 与 chat 区域之间应显示一条可拖拽的垂直分界线。 - 将路由切到 plugin/OpenClaw 节点后发送消息,在插件离线时应呈现“已发送但未完成(未读)”。 @@ -159,6 +160,8 @@ products: route_hint: apps/mobile_chat_app/lib/features/chat/chat_screen.dart - widget: ComposerBar attachment strip route_hint: apps/mobile_chat_app/lib/features/chat/widgets/composer_bar.dart + - widget: ComposerBar web clipboard image paste handler + route_hint: apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_web.dart - widget: MessageList media attachment rendering route_hint: apps/mobile_chat_app/lib/features/chat/widgets/message_list.dart - service: ChatHistoryApiService.uploadImage/respond mediaAttachmentIds @@ -183,6 +186,7 @@ products: route_hint: apps/node_backend/src/routes/chat.ts smoke_checks: - 选择图片后,输入框上方立即显示本地缩略图与上传进度;失败后保留缩略图并提供重试和移除。 + - 在输入框聚焦时粘贴剪贴板图片,应进入同一套上传中、失败重试、上传完成附件流程;若异步读取完成时 composer/page 已销毁,不应触发上传状态更新。 - 上传成功后,输入框上方显示带 Authorization header 的 media preview,而不是只显示占位 icon。 - 发送带图片的消息后,user 气泡显示图片附件并保持原有投递状态。 - 点击/点按聊天图片附件会通过 primary pointer 打开全屏预览;Safari/Chrome 桌面浏览器均应可用,右上角关闭按钮返回聊天。 @@ -332,6 +336,8 @@ products: name: 对话式待办列表与待办事项 user_value: 用户可用自然语言让 AI 管理待办列表(topic 分组)及列表下的待办项;每个待办项必须属于一个父列表,不能脱离列表存在。 entry_paths: + - widget: ChatNavigationPage todo resource preview + route_hint: apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart - service: TodoApiService route_hint: apps/mobile_chat_app/lib/features/chat/todo_api_service.dart - service: AuthenticatedApiClient @@ -354,11 +360,12 @@ products: - 用户说"帮我创建一个工作待办列表",AI 调用 todolist_create 并确认。 - 用户说"帮我在工作列表里添加:完成报告",AI 调用 todo_create(带 listId)并确认。 - 用户说"查看我的工作列表",AI 调用 todolist_get,返回列表及子项。 + - Resources tab 中打开 todo list 后,勾选待办项应调用 PATCH 并 visibly 切换为完成/未完成状态;PATCH 失败时保留原状态和当前列表。 - DELETE /api/resources/todo-lists/:listId 返回 { deleted: true },同时级联删除所有子项。 - feature_id: conversational_notes name: 对话式 Markdown Notes - user_value: 用户可让 AI 把聊天中检索或生成的长 Markdown 资料保存为独立 Note,并在 Resources 中只读查看。 + user_value: 用户可让 AI 把聊天中检索或生成的长 Markdown 资料保存为独立 Note,并在 Resources 中查看和编辑 Markdown 内容。 entry_paths: - service: NoteApiService route_hint: apps/mobile_chat_app/lib/features/chat/note_api_service.dart @@ -379,7 +386,8 @@ products: smoke_checks: - 用户说"把刚才找到的资料保存成 Note",AI 调用 note_create,body 保留 Markdown 原文。 - Resources tab 显示 Note 资源,选择 Notes 过滤器后只显示 Notes。 - - 点击 Note 资源后打开只读预览,展示完整 body 或列表中的 preview fallback。 + - 点击 Note 资源后打开 Markdown 编辑区,展示完整 body 或列表中的 preview fallback,并提供常用 Markdown 格式按钮与 Preview 切换。 + - 修改 Note body 后点击 Save,应调用 PATCH 并保留编辑后的 Markdown 内容;保存失败时编辑器仍保留当前草稿。 - note_read_lines 读取指定行号范围时只返回该范围及总行数。 - note_append_lines 超过 10000 行上限时返回明确错误且内容不变。 diff --git a/docs/code_maps/logic_map.yaml b/docs/code_maps/logic_map.yaml index 631bea44..a8c681b9 100644 --- a/docs/code_maps/logic_map.yaml +++ b/docs/code_maps/logic_map.yaml @@ -115,7 +115,7 @@ index: - build.sh doc_index: - docs/tasks/doing/2026-07-03-13-42-CST-workbook-lab-repo-strategy-research.md - - docs/tasks/doing/2026-07-03-14-54-CST-puzzle-pack-maker-app-shell.md + - docs/tasks/done/2026-07-03-14-54-CST-puzzle-pack-maker-app-shell.md test_index: - apps/puzzle_pack_maker/test/widget_test.dart - apps/node_backend/src/routes/auth.test.ts @@ -189,6 +189,7 @@ index: - docs/tasks/done/2026-05-19-14-27-CST-tool-call-thinking-group.md - docs/tasks/done/2026-05-19-17-38-CST-subsection-rename-menu.md - docs/tasks/done/2026-05-21-10-41-CST-channel-new-ui-harness.md + - docs/tasks/done/2026-07-03-09-58-CST-chat-input-shift-enter-newline.md - docs/plans/2026-06-23-16-24-CST-desktop-sidebar-divider-visibility.md - docs/testing/local-chat-fixture-db.md test_index: @@ -228,6 +229,7 @@ index: - scoped display name - slash-commands - route-aware composer actions + - Shift+Enter newline - markdown-table - long-polling (deprecated, replaced by SSE) - SSE (Server-Sent Events) @@ -272,6 +274,7 @@ index: - 宽屏侧边栏 resize handle 若命中区域太窄或未设为 opaque hit test,用户会以为拖拽调宽功能失效。 - 如果抽屉式导航与宽屏内联导航共用关闭策略,点击频道可能在宽屏下误收起侧栏,或在窄屏下无法关闭全屏抽屉。 - 发送消息后若 streaming 状态禁用 TextField,会打断连续输入工作流并丢失键盘焦点。 + - composer keyboard shortcuts 必须区分 plain Enter 与 Shift+Enter;若回退到 TextField.onSubmitted-only,桌面/web 多行输入会被误发送。 - 输入框 focus 态若重新引入强边框,会破坏暗色 composer 的安静层级并制造不必要的视觉噪音。 - 输入框底部 action 若混用不同 enabled 颜色,用户会误解部分按钮被禁用或处于不同优先级。 - 标题区子区切换与右上角管理菜单职责若混淆,可能导致用户误触或切换不可达。 @@ -337,9 +340,15 @@ index: - apps/mobile_chat_app/lib/features/chat/media_download_web.dart - apps/mobile_chat_app/lib/features/chat/chat_screen.dart - apps/mobile_chat_app/lib/features/chat/widgets/composer_bar.dart + - apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image.dart + - apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_stub.dart + - apps/mobile_chat_app/lib/features/chat/widgets/composer_paste_image_web.dart + - apps/mobile_chat_app/lib/features/chat/widgets/composer_pasted_image.dart - apps/mobile_chat_app/lib/features/chat/widgets/message_list.dart doc_index: - docs/tasks/done/2026-06-29-23-06-CST-media-upload-preview-multimodal.md + - docs/tasks/done/2026-07-02-12-49-CST-paste-image-upload-flow.md + - docs/tasks/done/2026-07-03-17-41-CST-pr-286-code-review-fixes.md - docs/tasks/doing/2026-06-29-14-32-CST-gemini-image-video-generation-architecture.md - docs/tasks/doing/2026-06-29-16-13-CST-dokku-channel-media-deployment-plan.md test_index: @@ -351,6 +360,7 @@ index: - apps/node_backend/src/routes/chat.test.ts - apps/mobile_chat_app/test/message_list_test.dart - apps/mobile_chat_app/test/chat_history_api_service_test.dart + - apps/mobile_chat_app/test/composer_bar_test.dart keywords: - media_assets - BRICKS_SANDBOX_ROOT @@ -374,6 +384,7 @@ index: - full-screen media preview - authenticated media download - FilePicker + - clipboard paste image - Image.network Authorization change_risks: - media_assets 必须按 user_id 授权读取;若只按 media id 查找,可能泄露其他用户上传/生成图片。 @@ -381,6 +392,8 @@ index: - Flutter Image.network 预览依赖 Authorization header;若 AuthService token hydration 失败,图片附件会显示 broken image 但消息文本仍可见。 - 桌面 Safari 对聊天图片 tile 的高阶 tap 手势可能不稳定;缩略图打开必须保留 primary pointer down/up 链路和可见 Open image 入口,避免只依赖 GestureDetector.onTap。 - Web 下载不能直接导航到 `/download`,必须用当前 token 拉取 Blob 后触发浏览器下载;否则受保护 media 会返回 401。 + - Web 粘贴图片监听只应在 composer 获得焦点时处理 image clipboard item;否则可能拦截页面其他输入区域的正常粘贴。 + - Web clipboard image reads finish asynchronously; callbacks must check mounted state before calling parent upload paths. - Gemini 多模态输入只应把当前用户可访问、当前 channel 的 ready image asset 转为 image part;错误地传递跨 channel 或失败 asset 会造成隐私或 provider 错误风险。 - Gemini image generation 和 Veo reference images 只能读取当前用户、当前 channel、ready image asset;跨 channel 或非 image media 必须被拒绝。 - Veo operation 完成后必须及时下载 provider video URI 到 Bricks-owned storage;只保存 provider URI 会受 2 天 provider retention 影响。 @@ -860,12 +873,16 @@ index: - apps/node_backend/src/services/localAgentLoopService.ts - apps/node_backend/src/routes/resources.ts - apps/mobile_chat_app/lib/features/chat/todo_api_service.dart + - apps/mobile_chat_app/lib/features/chat/chat_navigation_page.dart - apps/mobile_chat_app/lib/services/authenticated_api_client.dart doc_index: - docs/tasks/done/2026-05-16-02-20-+00-todos-tables-highlights-tools.md - docs/tasks/done/2026-05-19-13-02-CST-highlight-list-and-auth-api-architecture.md + - docs/tasks/done/2026-07-03-15-47-CST-editable-todo-and-note-resources.md + - docs/tasks/done/2026-07-03-17-41-CST-pr-286-code-review-fixes.md test_index: - apps/node_backend/src/services/localAgentLoopService.test.ts + - apps/mobile_chat_app/test/chat_navigation_page_test.dart keywords: - todo - todo list @@ -875,15 +892,17 @@ index: - INTERNAL_TOOL_TODO_CREATE - todolist.create - todo.create + - editable todo resource change_risks: - todo.create 等所有 todo item 工具均要求传入 listId;若 AI 不提供 listId 将返回 invalid_args。 - todoListService.deleteTodoList 依赖 DB CASCADE 删除子 asset_todos;若 FK 未正确迁移,cascade 不生效。 - 修改 INTERNAL_TOOLS 常量列表若遗漏新工具会导致 tool_not_allowed 错误。 - todoService 的 updateTodo 使用动态 SET 子句;若新增字段须同步 DTO 和路由 PATCH 处理。 - TodoApiService 不再接收 token 参数;资源预览和聊天页加载应依赖 AuthenticatedApiClient 的统一缺 token/401 错误路径。 + - Resources todo checkbox 更新失败时必须保留原状态并显示可重试错误,否则用户会误以为状态已持久化。 - feature_id: conversational_notes - capability: 用户通过自然语言让 AI 将聊天中检索或生成的长 Markdown 资料保存到独立 Note;AI 调用 note_* 内部工具,客户端在 Resources 中只读预览 + capability: 用户通过自然语言让 AI 将聊天中检索或生成的长 Markdown 资料保存到独立 Note;AI 调用 note_* 内部工具,客户端在 Resources 中查看和编辑 Markdown body code_index: - apps/node_backend/src/db/migrations/024_create_asset_notes.sql - apps/node_backend/src/services/noteService.ts @@ -898,6 +917,8 @@ index: doc_index: - docs/tasks/done/2026-06-16-15-32-+08-note-content-type-implementation.md - docs/tasks/done/2026-06-12-14-00-+08-note-content-type.md + - docs/tasks/done/2026-07-03-15-47-CST-editable-todo-and-note-resources.md + - docs/tasks/done/2026-07-03-17-41-CST-pr-286-code-review-fixes.md test_index: - apps/node_backend/src/routes/resources.test.ts - apps/node_backend/src/services/localAgentLoopService.test.ts @@ -913,7 +934,9 @@ index: - note_delete_lines - resources.notes - Markdown note - - read-only preview + - editable note resource + - Markdown editor + - Markdown preview toggle - line range - 10000 line limit change_risks: @@ -922,7 +945,9 @@ index: - Line operations are 1-based and inclusive; off-by-one errors can overwrite or delete the wrong Markdown block. - append/replace mutations enforce a 10000-line cap in service and route/tool paths; bypassing service logic would skip the guard. - ChatScreen relies on `resources.notes` typed invalidation to refresh the Resources tab after agent tool calls. - - The Resources preview loads full Note body on tap; very large notes may need pagination or markdown rendering optimization in a later iteration. + - The Resources preview loads full Note body on tap; very large notes may need pagination or markdown editing optimization in a later iteration. + - Note body edits use whole-document PATCH; future partial-line editing must preserve line-number semantics and avoid overwriting concurrent changes without detection. + - Note save failures must keep the Markdown editor and unsaved draft visible so the user can retry or copy their changes. - feature_id: asset_tables capability: 用户通过自然语言让 AI 管理动态 JSONB 表格(O(1) 列操作,行软删除);AI 调用 table_* 工具 diff --git a/docs/tasks/doing/2026-06-30-13-09-CST-media-preview-download-actions.md b/docs/tasks/done/2026-06-30-13-09-CST-media-preview-download-actions.md similarity index 100% rename from docs/tasks/doing/2026-06-30-13-09-CST-media-preview-download-actions.md rename to docs/tasks/done/2026-06-30-13-09-CST-media-preview-download-actions.md diff --git a/docs/tasks/doing/2026-06-30-14-33-CST-media-fullscreen-download-action.md b/docs/tasks/done/2026-06-30-14-33-CST-media-fullscreen-download-action.md similarity index 100% rename from docs/tasks/doing/2026-06-30-14-33-CST-media-fullscreen-download-action.md rename to docs/tasks/done/2026-06-30-14-33-CST-media-fullscreen-download-action.md diff --git a/docs/tasks/doing/2026-06-30-15-02-CST-auth-limiter-scope.md b/docs/tasks/done/2026-06-30-15-02-CST-auth-limiter-scope.md similarity index 100% rename from docs/tasks/doing/2026-06-30-15-02-CST-auth-limiter-scope.md rename to docs/tasks/done/2026-06-30-15-02-CST-auth-limiter-scope.md diff --git a/docs/tasks/doing/2026-06-30-15-23-CST-flutter-web-static-cache.md b/docs/tasks/done/2026-06-30-15-23-CST-flutter-web-static-cache.md similarity index 100% rename from docs/tasks/doing/2026-06-30-15-23-CST-flutter-web-static-cache.md rename to docs/tasks/done/2026-06-30-15-23-CST-flutter-web-static-cache.md diff --git a/docs/tasks/doing/2026-06-30-16-39-CST-dokku-preview-github-deployment-button.md b/docs/tasks/done/2026-06-30-16-39-CST-dokku-preview-github-deployment-button.md similarity index 100% rename from docs/tasks/doing/2026-06-30-16-39-CST-dokku-preview-github-deployment-button.md rename to docs/tasks/done/2026-06-30-16-39-CST-dokku-preview-github-deployment-button.md diff --git a/docs/tasks/backlog/2026-07-02-12-49-CST-paste-image-upload-flow.md b/docs/tasks/done/2026-07-02-12-49-CST-paste-image-upload-flow.md similarity index 100% rename from docs/tasks/backlog/2026-07-02-12-49-CST-paste-image-upload-flow.md rename to docs/tasks/done/2026-07-02-12-49-CST-paste-image-upload-flow.md diff --git a/docs/tasks/backlog/2026-07-03-09-58-CST-chat-input-shift-enter-newline.md b/docs/tasks/done/2026-07-03-09-58-CST-chat-input-shift-enter-newline.md similarity index 100% rename from docs/tasks/backlog/2026-07-03-09-58-CST-chat-input-shift-enter-newline.md rename to docs/tasks/done/2026-07-03-09-58-CST-chat-input-shift-enter-newline.md diff --git a/docs/tasks/doing/2026-07-03-14-54-CST-puzzle-pack-maker-app-shell.md b/docs/tasks/done/2026-07-03-14-54-CST-puzzle-pack-maker-app-shell.md similarity index 100% rename from docs/tasks/doing/2026-07-03-14-54-CST-puzzle-pack-maker-app-shell.md rename to docs/tasks/done/2026-07-03-14-54-CST-puzzle-pack-maker-app-shell.md diff --git a/docs/tasks/done/2026-07-03-15-47-CST-editable-todo-and-note-resources.md b/docs/tasks/done/2026-07-03-15-47-CST-editable-todo-and-note-resources.md new file mode 100644 index 00000000..6a6d2d8c --- /dev/null +++ b/docs/tasks/done/2026-07-03-15-47-CST-editable-todo-and-note-resources.md @@ -0,0 +1,16 @@ +# Editable Todo and Note Resources + +## Background + +Bricks needs lightweight editable resource types that users can keep inside their working context. The first two resource types are todo and note. + +## Requirement + +Todo and note resources must be editable. A todo resource lets the user mark an item done or undone. A note resource lets the user type and edit text using lightweight Markdown editing controls and a preview mode. + +## Acceptance Criteria + +- Given a todo resource, when the user marks it done, then the todo visibly changes to the done state. +- Given a done todo resource, when the user marks it undone, then the todo visibly returns to the undone state. +- Given a note resource, when the user types into the note editor, then the note content is editable in place. +- Given a note resource, when the note editor is shown, then it provides lightweight Markdown formatting controls and a preview mode. diff --git a/docs/tasks/done/2026-07-03-17-14-CST-task-status-cleanup.md b/docs/tasks/done/2026-07-03-17-14-CST-task-status-cleanup.md new file mode 100644 index 00000000..493ce319 --- /dev/null +++ b/docs/tasks/done/2026-07-03-17-14-CST-task-status-cleanup.md @@ -0,0 +1,18 @@ +# Task Status Cleanup + +## Background + +Some task files in `docs/tasks/doing/` described implementation slices that had already landed in code. The task lifecycle needed to be corrected so the remaining `doing` list reflects unfinished work. + +## Completed + +- Reviewed the active task files under `docs/tasks/doing/`. +- Cross-checked completed slices against code paths, tests, code maps, and recent git history. +- Moved completed task files from `docs/tasks/doing/` to `docs/tasks/done/`. +- Updated the code map task reference for Puzzle Pack Maker after moving its task file. +- Included the existing editable todo/note backlog requirement in the tracked task set. + +## Validation + +- `ruby -e "require 'psych'; Psych.load_file('docs/code_maps/feature_map.yaml'); puts 'feature_map yaml ok'"` +- `ruby -e "require 'psych'; Psych.load_file('docs/code_maps/logic_map.yaml'); puts 'logic_map yaml ok'"` diff --git a/docs/tasks/done/2026-07-03-17-41-CST-pr-286-code-review-fixes.md b/docs/tasks/done/2026-07-03-17-41-CST-pr-286-code-review-fixes.md new file mode 100644 index 00000000..1b8c4539 --- /dev/null +++ b/docs/tasks/done/2026-07-03-17-41-CST-pr-286-code-review-fixes.md @@ -0,0 +1,16 @@ +# PR 286 Code Review Fixes + +## Background + +PR 286 implements composer paste image support, Shift+Enter newlines, editable todo resources, and editable note resources. A follow-up review should catch regressions before the PR is ready for merge. + +## Requirement + +Review the implementation and fix issues that could break lifecycle safety, resource editing UX, or the recorded task acceptance criteria. + +## Acceptance Criteria + +- Pasted image callbacks do not trigger parent upload state after the composer or page is disposed. +- Todo and note edit failures keep the current editable content visible. +- The note editor exposes lightweight Markdown editing controls and a preview mode. +- Focused widget tests and analyzer checks pass after the fixes.