From 8379340dd3e8da39acf107cb8adac92f9f9b7094 Mon Sep 17 00:00:00 2001 From: Ryan Cormack Date: Sun, 22 Mar 2026 21:53:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20improve=20mobile=20UX=20=E2=80=94=20?= =?UTF-8?q?settings,=20searchable=20repos,=20scrollable=20tables,=20collap?= =?UTF-8?q?sible=20tool=20messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses multiple UX issues reported when using the app on Android/iOS: - Add SettingsService (SharedPreferences) with configurable enter key behavior; defaults to enter=newline on mobile, enter=send on web - Add Settings screen accessible via gear icon in the app bar - Replace repo selector dropdown with searchable Autocomplete widget while keeping multi-select chip behavior - Wrap Chats and Tasks tables in horizontal ScrollView to prevent column overlap on narrow screens - Collapse toolUse and toolResult messages to a single line by default with tap-to-expand; expanded content uses SelectableText so text selection doesn't trigger collapse - Add expandable/collapsible text for long message segments (>6 lines) - Improve SessionMessage.fromJson to fall back to JSON encoding for unrecognized content shapes, reducing "(no content)" placeholders New files: lib/services/settings_service.dart lib/views/settings_view.dart Modified: pubspec.yaml, main.dart, app_shell.dart, home_view.dart, message_input_bar.dart, kiro_api.dart, formatted_content_view.dart, session_detail_view.dart, task_detail_view.dart --- .kiro/plans/ux-fixes-2026-03-22.md | 131 ++++++++++++++++++++++++++ ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 43 +++++++++ lib/main.dart | 5 + lib/services/kiro_api.dart | 69 +++++++++++--- lib/services/settings_service.dart | 20 ++++ lib/views/app_shell.dart | 10 ++ lib/views/formatted_content_view.dart | 72 ++++++++++++-- lib/views/home_view.dart | 73 +++++++++----- lib/views/message_input_bar.dart | 10 +- lib/views/session_detail_view.dart | 92 ++++++++++++++++-- lib/views/settings_view.dart | 31 ++++++ lib/views/task_detail_view.dart | 92 ++++++++++++++++-- pubspec.lock | 72 ++++++++++++-- pubspec.yaml | 1 + 16 files changed, 653 insertions(+), 70 deletions(-) create mode 100644 .kiro/plans/ux-fixes-2026-03-22.md create mode 100644 ios/Podfile create mode 100644 lib/services/settings_service.dart create mode 100644 lib/views/settings_view.dart diff --git a/.kiro/plans/ux-fixes-2026-03-22.md b/.kiro/plans/ux-fixes-2026-03-22.md new file mode 100644 index 0000000..dae7b4d --- /dev/null +++ b/.kiro/plans/ux-fixes-2026-03-22.md @@ -0,0 +1,131 @@ +# Implementation Plan — Kiro Mobile UX Fixes + +**Date:** 2026-03-22 +**Status:** Approved + +## Problem Statement + +Six UX issues across the Create, Tasks, and Session Detail screens make the app frustrating to use on mobile — an unscrollable repo dropdown, awkward newline behavior, overlapping table columns, truncated message content, excessive "(no content)" placeholders, and the same newline issue in the reply input. + +## Requirements + +1. Repo selector on Create screen → searchable autocomplete with multi-select chips +2. Enter key behavior → configurable via a new Settings screen (gear icon in app bar), default to "enter = new line" on mobile +3. Tasks table → horizontally scrollable to prevent column overlap +4. Session detail messages → expandable/collapsible for long text blocks +5. "(no content)" → reduce by improving content extraction in `SessionMessage.fromJson` (fall back to raw JSON instead of null) +6. Reply input bar → respect the same enter key setting from #2 + +## Background + +- The repo selector is a `PopupMenuButton` in `_RepoDropdown` (`home_view.dart`) — no filtering capability +- There's no settings/preferences system; no settings screen or persistent storage for user prefs +- The Tasks table in `TasksTab` has 6 `FlexColumnWidth` columns in a `Table` widget — no horizontal scroll +- `SessionMessage.fromJson` (`kiro_api.dart:608-635`) only extracts content from `text.content`, `text` (string), or first `toolResult.content[0].text` — anything else results in `content = null` +- `ContentFormatter.format()` maps `null`/empty content to `"(no content)"` +- `MessageInputBar` uses `textInputAction: TextInputAction.send` + `onSubmitted` — Enter always sends +- The app bar actions are in `app_shell.dart` line ~225: refresh, API metrics, debug log, logout + +## Proposed Solution + +Build a lightweight settings service (persisted via `SharedPreferences`), wire it into the app bar, and fix each screen issue incrementally. + +```mermaid +flowchart TD + A[Settings Service] --> B[Settings Screen] + A --> C[Create Tab - Input] + A --> D[MessageInputBar] + E[Repo Autocomplete] --> F[Create Tab - Repo Selector] + G[Horizontal Scroll] --> H[Tasks Table] + I[Better Content Parsing] --> J[SessionMessage.fromJson] + J --> K[Fewer 'no content'] + L[Expandable Text] --> M[FormattedContentView] +``` + +## Task Breakdown + +### Task 1: Create the Settings service with persistent storage + +- **Objective:** Build a minimal `SettingsService` (ChangeNotifier) that stores user preferences using `SharedPreferences` +- Add `shared_preferences` to `pubspec.yaml` +- Create `lib/services/settings_service.dart` with a `SettingsService` class that exposes a `bool enterToSend` property (default `false` on mobile, `true` on web to preserve current behavior) +- Initialize it in `main.dart` and provide it via `Provider` +- **Test:** Unit test that `SettingsService` reads/writes the enter key preference +- **Demo:** Service initializes and persists a preference value across restarts + +### Task 2: Add Settings screen and gear icon to app bar + +- **Objective:** Create a `SettingsView` screen and add a gear icon to the app bar in `app_shell.dart` +- Create `lib/views/settings_view.dart` with a simple list tile toggle for "Enter sends message" (SwitchListTile) +- Add a `Icons.settings` IconButton to the app bar actions in `app_shell.dart` (between debug log and logout) +- Tapping it pushes the `SettingsView` via `Navigator.push` +- **Test:** Widget test that the settings screen renders and the toggle updates the setting +- **Demo:** Gear icon visible in app bar, tapping opens settings, toggling the switch persists the preference + +### Task 3: Wire enter key setting into Create tab prompt field + +- **Objective:** Make the Create tab's prompt `TextField` respect the enter key setting +- In `CreateTab` (`home_view.dart`), read `SettingsService` from context +- When `enterToSend` is true, Enter submits (current behavior); when false, Enter inserts a newline +- Update the hint text ("New line shift+enter" / "New line enter") to reflect the current setting +- **Test:** Widget test that Enter inserts newline when setting is off, and submits when on +- **Demo:** Changing the setting in Settings immediately changes Enter behavior in the Create prompt + +### Task 4: Wire enter key setting into MessageInputBar + +- **Objective:** Make `MessageInputBar` respect the same setting +- Read `SettingsService` in `MessageInputBar` +- When `enterToSend` is false, remove `textInputAction: TextInputAction.send` and `onSubmitted`, so Enter inserts a newline; add a send button (already exists) as the primary send mechanism +- When `enterToSend` is true, keep current behavior +- **Test:** Widget test for both modes +- **Demo:** In a session detail view, Enter inserts newline or sends based on the setting + +### Task 5: Replace repo dropdown with searchable autocomplete + +- **Objective:** Replace `_RepoDropdown` (`PopupMenuButton`) with a typeahead/autocomplete widget +- Use Flutter's built-in `Autocomplete` widget (or `RawAutocomplete`) to show a text field that filters repos as the user types +- Keep the existing multi-select chip behavior — selecting a repo adds a chip, the autocomplete field clears and is ready for the next selection +- Filter out already-selected repos from suggestions +- **Test:** Widget test that typing filters the list and selecting adds a chip +- **Demo:** On Create screen, typing in the repo field filters repos in real-time, selecting adds a chip + +### Task 6: Make Tasks table horizontally scrollable + +- **Objective:** Wrap the Tasks tab table in a horizontal scroll view to prevent column overlap +- In `TasksTab.build()` (`home_view.dart`), wrap the `Table` widget in a `SingleChildScrollView(scrollDirection: Axis.horizontal)` with a constrained minimum width (e.g., 700px) so columns have breathing room +- Apply the same fix to the Chats tab table for consistency +- **Test:** Widget test that the table renders inside a horizontal scroll view +- **Demo:** Tasks and Chats tables scroll horizontally on narrow screens, no column overlap + +### Task 7: Improve content extraction to reduce "(no content)" + +- **Objective:** Make `SessionMessage.fromJson` more resilient so fewer messages end up with null content +- In `kiro_api.dart`, when `rawContent` is a Map but doesn't match known patterns, fall back to `jsonEncode(rawContent)` instead of leaving `content` as null +- When `rawContent` is a String, use it directly +- When `rawContent` is a List, encode it as JSON +- This ensures the `ContentFormatter` gets *something* to work with rather than showing "(no content)" +- **Test:** Unit test `SessionMessage.fromJson` with various content shapes (string, unknown map structure, list, nested tool results) +- **Demo:** Messages that previously showed "(no content)" now show their actual content (even if as raw JSON) + +### Task 8: Add expandable/collapsible sections for long message content + +- **Objective:** Make long text blocks in session messages expandable +- In `FormattedContentView` (`formatted_content_view.dart`), for `SegmentType.text` segments longer than a threshold (e.g., 300 chars or ~6 lines), show a truncated preview with a "Show more" button +- Tapping expands to full content, with a "Show less" to collapse +- This mirrors the existing expand/collapse pattern already used in `_JsonBlock` +- **Test:** Widget test that long text is truncated with a "Show more" button, and tapping expands it +- **Demo:** In a session view, long agent messages show truncated with "Show more", tapping reveals full content + +## Key Files + +| File | Changes | +|------|---------| +| `pubspec.yaml` | Add `shared_preferences` | +| `lib/services/settings_service.dart` | **New** — SettingsService | +| `lib/views/settings_view.dart` | **New** — Settings screen | +| `lib/main.dart` | Initialize & provide SettingsService | +| `lib/views/app_shell.dart` | Add settings gear icon to app bar | +| `lib/views/home_view.dart` | Autocomplete repo selector, enter key in CreateTab, horizontal scroll on tables | +| `lib/views/message_input_bar.dart` | Enter key setting support | +| `lib/services/kiro_api.dart` | Improve SessionMessage.fromJson content extraction | +| `lib/views/formatted_content_view.dart` | Expandable text segments | diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/main.dart b/lib/main.dart index ce8145f..0c8edb9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,11 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutterrific_opentelemetry/flutterrific_opentelemetry.dart'; import 'package:logging/logging.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'services/auth_manager.dart'; import 'services/connectivity_monitor.dart'; import 'services/credential_store_factory.dart'; import 'services/debug_log.dart'; +import 'services/settings_service.dart'; import 'services/telemetry_config.dart'; import 'services/telemetry_service.dart'; import 'views/app_shell.dart'; @@ -75,6 +77,8 @@ void main() { final credentialStore = createCredentialStore(); final authManager = AuthManager(credentialStore: credentialStore); final connectivityMonitor = ConnectivityMonitorImpl(); + final prefs = await SharedPreferences.getInstance(); + final settingsService = SettingsService(prefs); // Kick off credential loading in the background. authManager.initialize(); @@ -83,6 +87,7 @@ void main() { MultiProvider( providers: [ ChangeNotifierProvider.value(value: authManager), + ChangeNotifierProvider.value(value: settingsService), Provider.value(value: connectivityMonitor), Provider.value(value: telemetryService), ], diff --git a/lib/services/kiro_api.dart b/lib/services/kiro_api.dart index ba11008..a902eb2 100644 --- a/lib/services/kiro_api.dart +++ b/lib/services/kiro_api.dart @@ -594,33 +594,73 @@ class SessionHistory { /// A single message or activity in a session. class SessionMessage { - SessionMessage({required this.role, this.content, this.timestamp, this.agentName}); + SessionMessage({ + required this.role, + this.content, + this.timestamp, + this.agentName, + this.isToolUse = false, + this.isToolResult = false, + this.toolName, + }); final String role; final String? content; final DateTime? timestamp; final String? agentName; + final bool isToolUse; + final bool isToolResult; + final String? toolName; + + bool get isTool => isToolUse || isToolResult; factory SessionMessage.fromJson(Map json) { - // Content can be nested: {text: {content: "..."}} or {toolResult: {...}} String? content; + bool isToolUse = false; + bool isToolResult = false; + String? toolName; final rawContent = json['content']; - if (rawContent is Map) { - final text = rawContent['text']; - if (text is Map) { - content = text['content'] as String?; - } else if (text is String) { - content = text; + if (rawContent is String) { + content = rawContent; + } else if (rawContent is Map) { + // Detect toolUse messages. + final toolUse = rawContent['toolUse']; + if (toolUse is Map) { + isToolUse = true; + toolName = toolUse['name'] as String?; + content = jsonEncode(toolUse['input'] ?? toolUse); } - // For tool results, extract a summary. + + // Detect toolResult messages. final toolResult = rawContent['toolResult']; - if (toolResult != null && content == null) { - final items = (toolResult as Map)['content'] as List?; + if (!isToolUse && toolResult is Map) { + isToolResult = true; + toolName = toolResult['toolUseId'] as String?; + final items = toolResult['content'] as List?; if (items != null && items.isNotEmpty) { - final first = items.first as Map; - content = first['text'] as String?; + final texts = items + .whereType>() + .map((e) => e['text'] as String?) + .where((t) => t != null) + .toList(); + content = texts.isNotEmpty ? texts.join('\n') : jsonEncode(toolResult); + } else { + content = jsonEncode(toolResult); + } + } + + if (!isToolUse && !isToolResult) { + final text = rawContent['text']; + if (text is Map) { + content = text['content'] as String?; + } else if (text is String) { + content = text; } + // Fallback: encode unrecognized map shapes as JSON. + content ??= jsonEncode(rawContent); } + } else if (rawContent is List) { + content = jsonEncode(rawContent); } return SessionMessage( @@ -628,6 +668,9 @@ class SessionMessage { content: content, timestamp: _tryParseDate(json['timestamp']), agentName: json['agentName'] as String?, + isToolUse: isToolUse, + isToolResult: isToolResult, + toolName: toolName, ); } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart new file mode 100644 index 0000000..a94f53f --- /dev/null +++ b/lib/services/settings_service.dart @@ -0,0 +1,20 @@ +import 'package:flutter/foundation.dart' show ChangeNotifier, kIsWeb; +import 'package:shared_preferences/shared_preferences.dart'; + +class SettingsService extends ChangeNotifier { + SettingsService(this._prefs) + : _enterToSend = _prefs.getBool(_kEnterToSend) ?? kIsWeb; + + static const _kEnterToSend = 'enter_to_send'; + final SharedPreferences _prefs; + + bool get enterToSend => _enterToSend; + bool _enterToSend; + + set enterToSend(bool value) { + if (_enterToSend == value) return; + _enterToSend = value; + _prefs.setBool(_kEnterToSend, value); + notifyListeners(); + } +} diff --git a/lib/views/app_shell.dart b/lib/views/app_shell.dart index 6fd0776..035131e 100644 --- a/lib/views/app_shell.dart +++ b/lib/views/app_shell.dart @@ -14,6 +14,7 @@ import '../services/debug_log.dart'; import '../services/telemetry_service.dart'; import 'error_view.dart'; import 'home_view.dart'; +import 'settings_view.dart'; import 'sign_in_view.dart'; import 'sign_in_view_stub.dart' if (dart.library.js_interop) 'sign_in_view_web.dart'; @@ -262,6 +263,15 @@ class _AppShellState extends State { tooltip: 'Debug Log', onPressed: () => _showDebugLogSheet(context), ), + IconButton( + icon: const Icon(Icons.settings_outlined), + tooltip: 'Settings', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SettingsView(), + ), + ), + ), IconButton( icon: const Icon(Icons.logout), tooltip: 'Sign out', diff --git a/lib/views/formatted_content_view.dart b/lib/views/formatted_content_view.dart index 8d6f749..fd48e1c 100644 --- a/lib/views/formatted_content_view.dart +++ b/lib/views/formatted_content_view.dart @@ -24,10 +24,7 @@ class FormattedContentView extends StatelessWidget { @override Widget build(BuildContext context) { if (content.isPlainText) { - return Text( - content.segments.first.text ?? '', - style: Theme.of(context).textTheme.bodyMedium, - ); + return _ExpandableText(text: content.segments.first.text ?? ''); } return Column( @@ -44,10 +41,7 @@ class FormattedContentView extends StatelessWidget { Widget _buildSegment(BuildContext context, ContentSegment segment) { switch (segment.type) { case SegmentType.text: - return Text( - segment.text ?? '', - style: Theme.of(context).textTheme.bodyMedium, - ); + return _ExpandableText(text: segment.text ?? ''); case SegmentType.json: return _JsonBlock( json: segment.jsonText ?? '', @@ -59,6 +53,68 @@ class FormattedContentView extends StatelessWidget { } } +// ─── Expandable text ───────────────────────────────────────────────────────── + +class _ExpandableText extends StatefulWidget { + const _ExpandableText({required this.text}); + final String text; + static const int maxLines = 6; + + @override + State<_ExpandableText> createState() => _ExpandableTextState(); +} + +class _ExpandableTextState extends State<_ExpandableText> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return LayoutBuilder( + builder: (context, constraints) { + final span = TextSpan( + text: widget.text, + style: theme.textTheme.bodyMedium, + ); + final tp = TextPainter( + text: span, + maxLines: _ExpandableText.maxLines, + textDirection: TextDirection.ltr, + )..layout(maxWidth: constraints.maxWidth); + + if (!tp.didExceedMaxLines) { + return Text(widget.text, style: theme.textTheme.bodyMedium); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.text, + style: theme.textTheme.bodyMedium, + maxLines: _expanded ? null : _ExpandableText.maxLines, + overflow: _expanded ? null : TextOverflow.fade, + ), + TextButton.icon( + onPressed: () => setState(() => _expanded = !_expanded), + icon: Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + ), + label: Text(_expanded ? 'Show less' : 'Show more'), + style: TextButton.styleFrom( + textStyle: theme.textTheme.labelSmall, + padding: EdgeInsets.zero, + minimumSize: const Size(0, 32), + ), + ), + ], + ); + }, + ); + } +} + // ─── JSON block with copy + collapse ───────────────────────────────────────── class _JsonBlock extends StatefulWidget { diff --git a/lib/views/home_view.dart b/lib/views/home_view.dart index b2af69c..789cb2d 100644 --- a/lib/views/home_view.dart +++ b/lib/views/home_view.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../services/auth_manager.dart'; import '../services/debug_log.dart'; import '../services/kiro_api.dart'; +import '../services/settings_service.dart'; import 'session_detail_view.dart'; import 'task_detail_view.dart'; @@ -128,6 +129,7 @@ class _CreateTabState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final enterToSend = context.watch().enterToSend; return SingleChildScrollView( padding: const EdgeInsets.all(24), @@ -168,7 +170,9 @@ class _CreateTabState extends State { child: Row( children: [ Text( - 'New line shift+enter', + enterToSend + ? 'New line shift+enter' + : 'New line enter', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -261,7 +265,7 @@ class _CreateTabState extends State { } } -/// Inline dropdown for selecting repos, filtering out already-selected ones. +/// Inline autocomplete for selecting repos, filtering out already-selected ones. class _RepoDropdown extends StatelessWidget { const _RepoDropdown({ required this.repos, @@ -288,27 +292,36 @@ class _RepoDropdown extends StatelessWidget { ); } - return PopupMenuButton( - onSelected: onSelected, - itemBuilder: (_) => available - .map((r) => PopupMenuItem(value: r, child: Text(r.displayName))) - .toList(), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Select repo(s)', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(width: 4), - Icon( - Icons.unfold_more, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ], + return SizedBox( + width: 200, + child: Autocomplete( + displayStringForOption: (r) => r.displayName, + optionsBuilder: (textEditingValue) { + final query = textEditingValue.text.toLowerCase(); + if (query.isEmpty) return available; + return available.where( + (r) => r.displayName.toLowerCase().contains(query), + ); + }, + onSelected: (repo) { + onSelected(repo); + }, + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + style: Theme.of(context).textTheme.bodyMedium, + decoration: InputDecoration( + hintText: 'Search repos…', + hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + isDense: true, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + ); + }, ), ); } @@ -458,7 +471,11 @@ class ChatsTabState extends State { onChanged: (value) => setState(() => _searchQuery = value), ), const SizedBox(height: 8), - Table( + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 600), + child: Table( columnWidths: const { 0: FlexColumnWidth(3), 1: FixedColumnWidth(48), @@ -579,6 +596,7 @@ class ChatsTabState extends State { ), ], ), + )), if (sessions.isEmpty && _searchQuery.isNotEmpty) const Padding( padding: EdgeInsets.all(24), @@ -837,7 +855,11 @@ class TasksTabState extends State { onChanged: (value) => setState(() => _searchQuery = value), ), const SizedBox(height: 8), - Table( + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 700), + child: Table( columnWidths: const { 0: FlexColumnWidth(2.5), 1: FlexColumnWidth(1.5), @@ -959,6 +981,7 @@ class TasksTabState extends State { ), ], ), + )), if (tasks.isEmpty && _searchQuery.isNotEmpty) const Padding( padding: EdgeInsets.all(24), diff --git a/lib/views/message_input_bar.dart b/lib/views/message_input_bar.dart index f242782..46c9b00 100644 --- a/lib/views/message_input_bar.dart +++ b/lib/views/message_input_bar.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/settings_service.dart'; /// A reusable text input bar with a send button for sending messages /// to a session via the API. @@ -43,6 +46,7 @@ class _MessageInputBarState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final enterToSend = context.watch().enterToSend; return Container( padding: EdgeInsets.only( @@ -64,8 +68,10 @@ class _MessageInputBarState extends State { controller: _controller, minLines: 1, maxLines: 4, - textInputAction: TextInputAction.send, - onSubmitted: (_) => _handleSend(), + textInputAction: enterToSend + ? TextInputAction.send + : TextInputAction.newline, + onSubmitted: enterToSend ? (_) => _handleSend() : null, decoration: InputDecoration( hintText: widget.hintText, border: OutlineInputBorder( diff --git a/lib/views/session_detail_view.dart b/lib/views/session_detail_view.dart index e5b7c0d..0b58c7d 100644 --- a/lib/views/session_detail_view.dart +++ b/lib/views/session_detail_view.dart @@ -203,15 +203,28 @@ class _SessionDetailViewState extends State { } } -class _MessageBubble extends StatelessWidget { +class _MessageBubble extends StatefulWidget { const _MessageBubble({required this.message}); final SessionMessage message; + @override + State<_MessageBubble> createState() => _MessageBubbleState(); +} + +class _MessageBubbleState extends State<_MessageBubble> { + bool _toolExpanded = false; + @override Widget build(BuildContext context) { - final isUser = message.role == 'user'; + final msg = widget.message; + final isUser = msg.role == 'user'; final theme = Theme.of(context); - final formatted = ContentFormatter.format(message.content); + + if (msg.isTool) { + return _buildToolBubble(theme, msg); + } + + final formatted = ContentFormatter.format(msg.content); return Padding( padding: const EdgeInsets.only(bottom: 12), @@ -241,22 +254,22 @@ class _MessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!isUser && message.agentName != null) + if (!isUser && msg.agentName != null) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( - message.agentName!, + msg.agentName!, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, ), ), ), FormattedContentView(content: formatted), - if (message.timestamp != null) + if (msg.timestamp != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( - _formatTime(message.timestamp!), + _formatTime(msg.timestamp!), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -272,6 +285,71 @@ class _MessageBubble extends StatelessWidget { ); } + Widget _buildToolBubble(ThemeData theme, SessionMessage msg) { + final label = msg.toolName ?? (msg.isToolUse ? 'Tool call' : 'Tool result'); + final icon = msg.isToolUse ? Icons.build_outlined : Icons.output_outlined; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outlineVariant.withAlpha(120), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => setState(() => _toolExpanded = !_toolExpanded), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, size: 16, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + _toolExpanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + if (_toolExpanded && msg.content != null) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + msg.content!, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 12, + height: 1.5, + ), + ), + ), + ], + ], + ), + ), + ); + } + static String _formatTime(DateTime dt) { final h = dt.toLocal().hour.toString().padLeft(2, '0'); final m = dt.toLocal().minute.toString().padLeft(2, '0'); diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart new file mode 100644 index 0000000..1465992 --- /dev/null +++ b/lib/views/settings_view.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/settings_service.dart'; + +class SettingsView extends StatelessWidget { + const SettingsView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: Consumer( + builder: (context, settings, _) => ListView( + children: [ + SwitchListTile( + title: const Text('Enter sends message'), + subtitle: Text( + settings.enterToSend + ? 'Press Enter to send, Shift+Enter for new line' + : 'Press Enter for new line, use send button to send', + ), + value: settings.enterToSend, + onChanged: (v) => settings.enterToSend = v, + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/task_detail_view.dart b/lib/views/task_detail_view.dart index 84ab9d5..6fb2c47 100644 --- a/lib/views/task_detail_view.dart +++ b/lib/views/task_detail_view.dart @@ -354,15 +354,28 @@ class _InfoChip extends StatelessWidget { } } -class _MessageBubble extends StatelessWidget { +class _MessageBubble extends StatefulWidget { const _MessageBubble({required this.message}); final SessionMessage message; + @override + State<_MessageBubble> createState() => _MessageBubbleState(); +} + +class _MessageBubbleState extends State<_MessageBubble> { + bool _toolExpanded = false; + @override Widget build(BuildContext context) { - final isUser = message.role == 'user'; + final msg = widget.message; + final isUser = msg.role == 'user'; final theme = Theme.of(context); - final formatted = ContentFormatter.format(message.content); + + if (msg.isTool) { + return _buildToolBubble(theme, msg); + } + + final formatted = ContentFormatter.format(msg.content); return Padding( padding: const EdgeInsets.only(bottom: 12), @@ -393,22 +406,22 @@ class _MessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!isUser && message.agentName != null) + if (!isUser && msg.agentName != null) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( - message.agentName!, + msg.agentName!, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, ), ), ), FormattedContentView(content: formatted), - if (message.timestamp != null) + if (msg.timestamp != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( - _formatTime(message.timestamp!), + _formatTime(msg.timestamp!), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -424,6 +437,71 @@ class _MessageBubble extends StatelessWidget { ); } + Widget _buildToolBubble(ThemeData theme, SessionMessage msg) { + final label = msg.toolName ?? (msg.isToolUse ? 'Tool call' : 'Tool result'); + final icon = msg.isToolUse ? Icons.build_outlined : Icons.output_outlined; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outlineVariant.withAlpha(120), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => setState(() => _toolExpanded = !_toolExpanded), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, size: 16, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + _toolExpanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + if (_toolExpanded && msg.content != null) ...[ + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: SelectableText( + msg.content!, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 12, + height: 1.5, + ), + ), + ), + ], + ], + ), + ), + ); + } + static String _formatTime(DateTime dt) { final h = dt.toLocal().hour.toString().padLeft(2, '0'); final m = dt.toLocal().minute.toString().padLeft(2, '0'); diff --git a/pubspec.lock b/pubspec.lock index db58168..45cc448 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -476,10 +476,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -688,6 +688,62 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -793,26 +849,26 @@ packages: dependency: transitive description: name: test - sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.29.0" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.15" + version: "0.6.16" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7f6549c..cb923c2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: path_provider: ^2.1.5 share_plus: ^10.1.4 flutterrific_opentelemetry: ^0.3.4 + shared_preferences: ^2.3.4 dev_dependencies: flutter_test: From c7508475fc8f41ff67c39532c577f0c4e483a752 Mon Sep 17 00:00:00 2001 From: Ryan Cormack Date: Sun, 22 Mar 2026 22:02:23 +0000 Subject: [PATCH 2/2] chore: Fix type safety error handling --- lib/services/kiro_api.dart | 2 +- lib/views/session_detail_view.dart | 4 ++-- lib/views/task_detail_view.dart | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/services/kiro_api.dart b/lib/services/kiro_api.dart index a902eb2..b7aa4bc 100644 --- a/lib/services/kiro_api.dart +++ b/lib/services/kiro_api.dart @@ -612,7 +612,7 @@ class SessionMessage { final bool isToolResult; final String? toolName; - bool get isTool => isToolUse || isToolResult; + bool get isTool => (isToolUse == true) || (isToolResult == true); factory SessionMessage.fromJson(Map json) { String? content; diff --git a/lib/views/session_detail_view.dart b/lib/views/session_detail_view.dart index 0b58c7d..4b5c52d 100644 --- a/lib/views/session_detail_view.dart +++ b/lib/views/session_detail_view.dart @@ -286,8 +286,8 @@ class _MessageBubbleState extends State<_MessageBubble> { } Widget _buildToolBubble(ThemeData theme, SessionMessage msg) { - final label = msg.toolName ?? (msg.isToolUse ? 'Tool call' : 'Tool result'); - final icon = msg.isToolUse ? Icons.build_outlined : Icons.output_outlined; + final label = msg.toolName ?? (msg.isToolUse == true ? 'Tool call' : 'Tool result'); + final icon = msg.isToolUse == true ? Icons.build_outlined : Icons.output_outlined; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Container( diff --git a/lib/views/task_detail_view.dart b/lib/views/task_detail_view.dart index 6fb2c47..3baca7a 100644 --- a/lib/views/task_detail_view.dart +++ b/lib/views/task_detail_view.dart @@ -438,8 +438,8 @@ class _MessageBubbleState extends State<_MessageBubble> { } Widget _buildToolBubble(ThemeData theme, SessionMessage msg) { - final label = msg.toolName ?? (msg.isToolUse ? 'Tool call' : 'Tool result'); - final icon = msg.isToolUse ? Icons.build_outlined : Icons.output_outlined; + final label = msg.toolName ?? (msg.isToolUse == true ? 'Tool call' : 'Tool result'); + final icon = msg.isToolUse == true ? Icons.build_outlined : Icons.output_outlined; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Container(