Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ class ChatAcceptedTask {
final String acceptedAt;
}

class ChatForkResult {
const ChatForkResult({
required this.threadId,
required this.channelId,
required this.forkedSessionId,
});

final String threadId;
final String channelId;
final String forkedSessionId;
}

class ChatChannelNameSetting {
const ChatChannelNameSetting({
required this.channelId,
Expand Down Expand Up @@ -134,6 +146,7 @@ class ChatHistoryApiService {
Uri get _scopesUri => Uri.parse('$_base/api/chat/scopes');
Uri get _scopeSettingsUri => Uri.parse('$_base/api/chat/scope-settings');
Uri get _channelNamesUri => Uri.parse('$_base/api/chat/channel-names');
Uri get _forkUri => Uri.parse('$_base/api/chat/fork');

ChatTaskState? _parseTaskState(Object? value) {
if (value is! String || value.isEmpty) return null;
Expand Down Expand Up @@ -521,6 +534,32 @@ class ChatHistoryApiService {
.toList(growable: false);
}

Future<ChatForkResult> forkThread({
required String parentSessionId,
required String forkMessageId,
required String newThreadId,
}) async {
final response = await _apiClient.post(
_forkUri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'parentSessionId': parentSessionId,
'forkMessageId': forkMessageId,
'newThreadId': newThreadId,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to fork thread (${response.statusCode})');
}
final raw = jsonDecode(response.body);
if (raw is! Map) throw Exception('Invalid fork response');
return ChatForkResult(
threadId: raw['threadId'] as String,
channelId: raw['channelId'] as String,
forkedSessionId: raw['forkedSessionId'] as String,
);
}

Future<int> upsertMessages({
required List<ChatMessage> messages,
}) async {
Expand Down
103 changes: 102 additions & 1 deletion apps/mobile_chat_app/lib/features/chat/chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class ChatScreen extends StatefulWidget {

class _ChatScreenState extends State<ChatScreen> {
final List<ChatMessage> _messages = [];
final Set<String> _archivedMessageIds = {};
bool _isSending = false;
bool _isStreaming = false;
bool _loadingAgents = true;
Expand Down Expand Up @@ -847,6 +848,7 @@ class _ChatScreenState extends State<ChatScreen> {
_activeChannelId = id;
_activeSubSection = 'main';
_messages.clear();
_archivedMessageIds.clear();
_latestCheckpointCursor = null;
_lastSyncedSeq = 0;
});
Expand Down Expand Up @@ -1055,6 +1057,7 @@ class _ChatScreenState extends State<ChatScreen> {
_activeChannelId = resolvedChannelId;
_activeSubSection = restoredSubSection;
_messages.clear();
_archivedMessageIds.clear();
_latestCheckpointCursor = null;
_lastSyncedSeq = 0;
});
Expand Down Expand Up @@ -1133,6 +1136,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (!mounted || _isScopeStale()) return;
setState(() {
_messages.clear();
_archivedMessageIds.clear();
_highlights = const {};
_textHighlights = const [];
_latestCheckpointCursor = null;
Expand Down Expand Up @@ -1228,6 +1232,89 @@ class _ChatScreenState extends State<ChatScreen> {
}
}

void _handleArchiveRound(ChatMessage message) {
final messageId = message.messageId;
if (messageId == null) return;
// Find the user message that immediately precedes this assistant message.
String? precedingUserMessageId;
bool foundTarget = false;
for (int i = _messages.length - 1; i >= 0; i--) {
final msg = _messages[i];
if (!foundTarget) {
if (msg.messageId == messageId) foundTarget = true;
} else if (msg.role == 'user') {
precedingUserMessageId = msg.messageId;
break;
}
}
setState(() {
_archivedMessageIds.add(messageId);
if (precedingUserMessageId != null) {
_archivedMessageIds.add(precedingUserMessageId);
}
});
}

void _handleArchiveReply(ChatMessage message) {
final messageId = message.messageId;
if (messageId == null) return;
setState(() {
_archivedMessageIds.add(messageId);
});
}

Future<void> _handleFork(ChatMessage message) async {
final messageId = message.messageId;
if (messageId == null) return;
final parentSessionId = _sessionIdForScope;
final newThreadId = _newId('fork');
try {
await _chatHistoryApiService.forkThread(
parentSessionId: parentSessionId,
forkMessageId: messageId,
newThreadId: newThreadId,
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fork failed: $e')),
);
return;
}
if (!mounted) return;
final section = ChatSubSection(
id: newThreadId,
parentChannelId: _activeChannelId,
name: _timestampName(prefix: 'fork'),
createdAt: DateTime.now(),
);
setState(() {
final items = _channelSubSections.putIfAbsent(
_activeChannelId,
() => <ChatSubSection>[],
);
items.add(section);
_activeSubSection = newThreadId;
_lastActiveSubSectionByChannel[_activeChannelId] = newThreadId;
_messages.clear();
_archivedMessageIds.clear();
_latestCheckpointCursor = null;
_lastSyncedSeq = 0;
});
_configureActiveScopeSync();
}

Future<void> _handleBranch(ChatMessage message) async {
// Branch forks from the user message itself — the new thread inherits
// context up to and including this user message from the parent.
await _handleFork(message);
}

void _handleResend(ChatMessage message) {
if (message.content.trim().isEmpty) return;
_sendMessage(message.content);
}

String _subSectionKey(String channelId, String sectionId) =>
'$channelId::$sectionId';

Expand Down Expand Up @@ -2221,6 +2308,7 @@ class _ChatScreenState extends State<ChatScreen> {
_activeSubSection = id;
_lastActiveSubSectionByChannel[_activeChannelId] = id;
_messages.clear();
_archivedMessageIds.clear();
_latestCheckpointCursor = null;
_lastSyncedSeq = 0;
});
Expand Down Expand Up @@ -2298,6 +2386,7 @@ class _ChatScreenState extends State<ChatScreen> {
_lastActiveSubSectionByChannel[channelId] = 'main';
_activeSubSection = 'main';
_messages.clear();
_archivedMessageIds.clear();
_latestCheckpointCursor = null;
_lastSyncedSeq = 0;
});
Expand All @@ -2324,6 +2413,7 @@ class _ChatScreenState extends State<ChatScreen> {
_activeSubSection = subSectionId;
_lastActiveSubSectionByChannel[_activeChannelId] = subSectionId;
_messages.clear();
_archivedMessageIds.clear();
_latestCheckpointCursor = null;
_lastSyncedSeq = 0;
});
Expand Down Expand Up @@ -2950,10 +3040,21 @@ class _ChatScreenState extends State<ChatScreen> {
children: [
Expanded(
child: MessageList(
messages: _messages,
messages: _archivedMessageIds.isEmpty
? _messages
: _messages
.where((m) =>
m.messageId == null ||
!_archivedMessageIds.contains(m.messageId))
.toList(),
highlights: _highlights,
onHighlight: _handleHighlight,
onDeleteHighlight: _handleDeleteHighlight,
onArchiveRound: _handleArchiveRound,
onArchiveReply: _handleArchiveReply,
onFork: _handleFork,
onBranch: _handleBranch,
onResend: _handleResend,
),
),
Builder(
Expand Down
48 changes: 28 additions & 20 deletions apps/mobile_chat_app/lib/features/chat/widgets/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ class MessageList extends StatefulWidget {
this.onDeleteHighlight,
this.onArchiveRound,
this.onArchiveReply,
this.onMoveToThread,
this.onFork,
this.onBranch,
this.onResend,
});

final List<ChatMessage> messages;
Expand All @@ -66,14 +68,20 @@ class MessageList extends StatefulWidget {
/// Called when the user taps Remove highlight in the floating highlight menu.
final void Function(String highlightId)? onDeleteHighlight;

/// Called when the user selects "归档此轮" from the assistant message menu.
/// Called when the user selects "Archive Round" from the assistant message menu.
final void Function(ChatMessage message)? onArchiveRound;

/// Called when the user selects "归档此回复" from the assistant message menu.
/// Called when the user selects "Archive Reply" from the assistant message menu.
final void Function(ChatMessage message)? onArchiveReply;

/// Called when the user selects "移入Thread" from the assistant message menu.
final void Function(ChatMessage message)? onMoveToThread;
/// Called when the user selects "Fork" from the assistant message menu.
final void Function(ChatMessage message)? onFork;

/// Called when the user selects "Branch" from the user message context menu.
final void Function(ChatMessage message)? onBranch;

/// Called when the user selects "Resend" from the user message context menu.
final void Function(ChatMessage message)? onResend;

@override
State<MessageList> createState() => _MessageListState();
Expand Down Expand Up @@ -439,10 +447,10 @@ class _MessageListState extends State<MessageList> {
).showSnackBar(const SnackBar(content: Text('Copied')));
break;
case 'branch':
widget.onBranch?.call(message);
break;
case 'resend':
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Coming soon')));
widget.onResend?.call(message);
break;
}
}
Expand All @@ -454,8 +462,8 @@ class _MessageListState extends State<MessageList> {
}) async {
final hasArchiveRound = widget.onArchiveRound != null;
final hasArchiveReply = widget.onArchiveReply != null;
final hasMoveToThread = widget.onMoveToThread != null;
if (!hasArchiveRound && !hasArchiveReply && !hasMoveToThread) return;
final hasFork = widget.onFork != null;
if (!hasArchiveRound && !hasArchiveReply && !hasFork) return;
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final result = await showGeneralDialog<String>(
context: context,
Expand All @@ -468,7 +476,7 @@ class _MessageListState extends State<MessageList> {
screenSize: overlay.size,
showArchiveRound: hasArchiveRound,
showArchiveReply: hasArchiveReply,
showMoveToThread: hasMoveToThread,
showFork: hasFork,
),
);
if (!context.mounted || result == null) return;
Expand All @@ -479,8 +487,8 @@ class _MessageListState extends State<MessageList> {
case 'archive_reply':
widget.onArchiveReply?.call(message);
break;
case 'move_to_thread':
widget.onMoveToThread?.call(message);
case 'fork':
widget.onFork?.call(message);
break;
}
}
Expand Down Expand Up @@ -2670,8 +2678,8 @@ class _UserMessageContextMenu extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_MenuItem(label: 'Copy', value: 'copy'),
_MenuItem(label: 'Branch (coming soon)', value: 'branch'),
_MenuItem(label: 'Resend (coming soon)', value: 'resend'),
_MenuItem(label: 'Branch', value: 'branch'),
_MenuItem(label: 'Resend', value: 'resend'),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
Expand Down Expand Up @@ -2739,14 +2747,14 @@ class _AssistantMessageActionMenu extends StatelessWidget {
required this.screenSize,
required this.showArchiveRound,
required this.showArchiveReply,
required this.showMoveToThread,
required this.showFork,
});

final Offset position;
final Size screenSize;
final bool showArchiveRound;
final bool showArchiveReply;
final bool showMoveToThread;
final bool showFork;

static const double _menuWidth = 200.0;
static const double _itemHeight = 48.0;
Expand All @@ -2755,9 +2763,9 @@ class _AssistantMessageActionMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final items = [
if (showArchiveRound) const _MenuItem(label: '归档此轮', value: 'archive_round'),
if (showArchiveReply) const _MenuItem(label: '归档此回复', value: 'archive_reply'),
if (showMoveToThread) const _MenuItem(label: '移入Thread', value: 'move_to_thread'),
if (showArchiveRound) const _MenuItem(label: 'Archive Round', value: 'archive_round'),
if (showArchiveReply) const _MenuItem(label: 'Archive Reply', value: 'archive_reply'),
if (showFork) const _MenuItem(label: 'Fork', value: 'fork'),
];
final menuHeight = _itemHeight * items.length;

Expand Down
12 changes: 6 additions & 6 deletions apps/mobile_chat_app/test/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Widget _build(
void Function(String)? onDeleteHighlight,
void Function(ChatMessage)? onArchiveRound,
void Function(ChatMessage)? onArchiveReply,
void Function(ChatMessage)? onMoveToThread,
void Function(ChatMessage)? onFork,
}) =>
MaterialApp(
theme: theme,
Expand All @@ -54,7 +54,7 @@ Widget _build(
onDeleteHighlight: onDeleteHighlight,
onArchiveRound: onArchiveRound,
onArchiveReply: onArchiveReply,
onMoveToThread: onMoveToThread,
onFork: onFork,
),
),
),
Expand Down Expand Up @@ -1637,22 +1637,22 @@ void main() {
expect(received?.messageId, 'a-action');
});

testWidgets('tapping move_to_thread calls onMoveToThread with message',
testWidgets('tapping fork calls onFork with message',
(tester) async {
ChatMessage? received;
await tester.pumpWidget(
_build(
[_assistantMsg()],
onMoveToThread: (m) => received = m,
onFork: (m) => received = m,
),
);
await tester.pumpAndSettle();

await tester.tap(find.byIcon(Icons.more_horiz));
await tester.pumpAndSettle();

expect(find.text('移入Thread'), findsOneWidget);
await tester.tap(find.text('移入Thread'));
expect(find.text('Fork'), findsOneWidget);
await tester.tap(find.text('Fork'));
await tester.pumpAndSettle();

expect(received?.messageId, 'a-action');
Expand Down
Loading