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
131 changes: 131 additions & 0 deletions .kiro/plans/ux-fixes-2026-03-22.md
Original file line number Diff line number Diff line change
@@ -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 |
1 change: 1 addition & 0 deletions ios/Flutter/Debug.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
1 change: 1 addition & 0 deletions ios/Flutter/Release.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
43 changes: 43 additions & 0 deletions ios/Podfile
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -83,6 +87,7 @@ void main() {
MultiProvider(
providers: [
ChangeNotifierProvider<AuthManager>.value(value: authManager),
ChangeNotifierProvider<SettingsService>.value(value: settingsService),
Provider<ConnectivityMonitor>.value(value: connectivityMonitor),
Provider<TelemetryService>.value(value: telemetryService),
],
Expand Down
69 changes: 56 additions & 13 deletions lib/services/kiro_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -594,40 +594,83 @@ 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 == true) || (isToolResult == true);

factory SessionMessage.fromJson(Map<String, dynamic> 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<String, dynamic>) {
final text = rawContent['text'];
if (text is Map<String, dynamic>) {
content = text['content'] as String?;
} else if (text is String) {
content = text;
if (rawContent is String) {
content = rawContent;
} else if (rawContent is Map<String, dynamic>) {
// Detect toolUse messages.
final toolUse = rawContent['toolUse'];
if (toolUse is Map<String, dynamic>) {
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<String, dynamic>)['content'] as List?;
if (!isToolUse && toolResult is Map<String, dynamic>) {
isToolResult = true;
toolName = toolResult['toolUseId'] as String?;
final items = toolResult['content'] as List?;
if (items != null && items.isNotEmpty) {
final first = items.first as Map<String, dynamic>;
content = first['text'] as String?;
final texts = items
.whereType<Map<String, dynamic>>()
.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<String, dynamic>) {
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(
role: json['role'] as String? ?? 'unknown',
content: content,
timestamp: _tryParseDate(json['timestamp']),
agentName: json['agentName'] as String?,
isToolUse: isToolUse,
isToolResult: isToolResult,
toolName: toolName,
);
}
}
Expand Down
20 changes: 20 additions & 0 deletions lib/services/settings_service.dart
Original file line number Diff line number Diff line change
@@ -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();
}
}
10 changes: 10 additions & 0 deletions lib/views/app_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -262,6 +263,15 @@ class _AppShellState extends State<AppShell> {
tooltip: 'Debug Log',
onPressed: () => _showDebugLogSheet(context),
),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Settings',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const SettingsView(),
),
),
),
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Sign out',
Expand Down
Loading