diff --git a/lib/widgets/tool_call_chip.dart b/lib/widgets/tool_call_chip.dart index 884a18f..ba61774 100644 --- a/lib/widgets/tool_call_chip.dart +++ b/lib/widgets/tool_call_chip.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../core/constants.dart'; /// Human-readable labels for each backend tool. Kept in sync with /// `_TOOL_LABELS` in `fabledassistant/services/generation_task.py` so the @@ -62,9 +65,43 @@ IconData _iconFor(String fn) { return Icons.auto_awesome; } +/// Pull the destination route for this tool call from its `result` payload, +/// if the tool produced something we can navigate to. Returns `null` for +/// read-only / no-target tools so the chip stays visible but non-tappable. +/// +/// Backend tool results use the shape: +/// `{success, type: "note"|"task"|"event"|"project"|..., data: {id, ...}}` +/// which is defined alongside each tool handler (see +/// `services/tools/notes.py`, `calendar.py`, etc.). +String? _routeForToolCall(Map tc) { + final result = tc['result']; + if (result is! Map) return null; + if (result['success'] != true) return null; + final type = result['type'] as String?; + final data = result['data']; + if (type == null || data is! Map) return null; + final id = data['id']; + if (id is! int) return null; + switch (type) { + case 'note': + return Routes.noteDetail.replaceFirst(':id', '$id'); + case 'task': + return Routes.taskEdit.replaceFirst(':id', '$id'); + case 'event': + case 'event_updated': + // No single-event route on mobile — fall back to the calendar. + return Routes.calendar; + case 'project': + return Routes.projectTasks.replaceFirst(':id', '$id'); + default: + return null; + } +} + /// Small status pill rendered inside an assistant message bubble for each /// tool invocation. Mirrors the web app's ToolCallCard header at a glance — -/// icon + label + success/error tint. +/// icon + label + success/error tint — and, when the tool produced a +/// navigable entity (note, task, event, project), tapping the chip opens it. class ToolCallChip extends StatelessWidget { final Map toolCall; const ToolCallChip({super.key, required this.toolCall}); @@ -77,12 +114,29 @@ class ToolCallChip extends StatelessWidget { final isError = status == 'error'; final label = _toolLabels[function] ?? function; + final route = isError ? null : _routeForToolCall(toolCall); + final bg = isError ? scheme.errorContainer.withValues(alpha: 0.55) : scheme.primary.withValues(alpha: 0.12); final fg = isError ? scheme.onErrorContainer : scheme.primary; - return Container( + // Pull the entity title from the result payload so the chip can show + // "Created note: Grocery List" instead of a generic label. Falls back + // to the generic label when the tool didn't return a titled entity. + String? entityTitle; + final result = toolCall['result']; + if (result is Map) { + final data = result['data']; + if (data is Map) { + final t = data['title']; + if (t is String && t.isNotEmpty) entityTitle = t; + } + } + final displayText = + entityTitle != null ? '$label: $entityTitle' : label; + + final chip = Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: bg, @@ -94,16 +148,34 @@ class ToolCallChip extends StatelessWidget { children: [ Icon(_iconFor(function), size: 13, color: fg), const SizedBox(width: 5), - Text( - label, - style: TextStyle( - fontSize: 11, - color: fg, - fontWeight: FontWeight.w500, + Flexible( + child: Text( + displayText, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, ), ), + if (route != null) ...[ + const SizedBox(width: 4), + Icon(Icons.arrow_forward, size: 11, color: fg), + ], ], ), ); + + if (route == null) return chip; + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(10), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => context.push(route), + child: chip, + ), + ); } }