Skip to content
47 changes: 13 additions & 34 deletions app/lib/desktop/chat/desktop_chat_pane.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import '../../ui/session/tool_renderers.dart' show kReadableContentMaxWidth;
import 'composer_focus.dart';
import 'selected_session.dart';
import 'sidebar_layout.dart';
import '../../ui/composer/composer_selectors.dart';

/// The right-hand pane of the desktop two-pane chat: transcript + docked
/// composer for [selectedSessionProvider].
Expand Down Expand Up @@ -174,6 +175,11 @@ class _DesktopChatPaneState extends ConsumerState<DesktopChatPane> {
onSend: (text) => _handleSend(sessionId, text),
onCancel: () => _cancelTurn(sessionId),
running: running,
alwaysExpanded: true,
footerActions: [
ComposerModelSelector(sessionId: sessionId),
ComposerThinkingSelector(sessionId: sessionId),
],
focusNode: ref.watch(desktopComposerFocusProvider),
sendChord: ref
.watch(keymapProvider)
Expand Down Expand Up @@ -282,9 +288,8 @@ class _PaneHeader extends ConsumerWidget {
);
}

/// Session-level overflow menu (Rename / Model / Thinking / Quit). Mirrors
/// the mobile [SessionScreen] top-bar menu so both platforms expose the same
/// actions.
/// Session-level overflow menu (Rename / Quit). Model and thinking-effort
/// live inline in the composer footer now, so they are not repeated here.
Widget _actionsMenu(BuildContext context, WidgetRef ref) {
return PopupMenuButton<String>(
tooltip: 'Session actions',
Expand All @@ -305,20 +310,6 @@ class _PaneHeader extends ConsumerWidget {
ref: ref,
sessionId: fallbackId,
);
case 'model':
handleClientCommand(
'/model',
context: context,
ref: ref,
sessionId: fallbackId,
);
case 'thinking':
handleClientCommand(
'/thinking',
context: context,
ref: ref,
sessionId: fallbackId,
);
case 'quit':
_confirmQuit(context, ref);
}
Expand All @@ -332,22 +323,6 @@ class _PaneHeader extends ConsumerWidget {
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'model',
child: ListTile(
leading: Icon(Symbols.smart_toy, weight: 200),
title: Text('Model'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuItem(
value: 'thinking',
child: ListTile(
leading: Icon(Symbols.psychology, weight: 200),
title: Text('Thinking'),
contentPadding: EdgeInsets.zero,
),
),
PopupMenuDivider(),
PopupMenuItem(
value: 'quit',
Expand Down Expand Up @@ -785,7 +760,11 @@ class _WorktreeStartViewState extends ConsumerState<_WorktreeStartView> {
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
child: Composer(onSend: _start, running: _starting),
child: Composer(
onSend: _start,
running: _starting,
alwaysExpanded: true,
),
),
),
),
Expand Down
59 changes: 48 additions & 11 deletions app/lib/ui/composer/composer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import 'slash_palette.dart';
///
/// Two visual states:
/// - **Compact** (unfocused): `[+] [1-line field] [send?]`.
/// - **Expanded** (focused): full-width 3-line field on top, with
/// `[+] … [send?]` in an action row beneath it.
/// - **Expanded** (focused, or always on desktop): an auto-growing multiline
/// field on top (grows with the caret up to 10 lines, then scrolls), with a
/// footer row beneath it: `[footerActions…] … [+] [send?]`.
///
/// Tapping into the field expands it; losing focus collapses it back to the
/// 1-line compact form (text is preserved). The send button fades in only
/// while the field is non-empty.
/// On mobile the field is compact until focused, then expands to the full
/// form; losing focus collapses it back to the 1-line compact form (text is
/// preserved). On desktop [alwaysExpanded] keeps the full form up permanently.
/// The send button fades in only while the field is non-empty.
class Composer extends StatefulWidget {
const Composer({
super.key,
Expand All @@ -26,9 +28,21 @@ class Composer extends StatefulWidget {
this.sendChord,
this.newlineChord,
this.focusNode,
this.footerActions = const <Widget>[],
this.alwaysExpanded = false,
});
final void Function(String text) onSend;

/// Leading controls placed on the left of the footer action row when the
/// composer is in its full (expanded) form — e.g. the model + thinking
/// selectors. Each may render nothing (a shrunk box) when it has no data.
final List<Widget> footerActions;

/// When true the composer is permanently in its full form (multiline field +
/// footer), regardless of focus. Desktop sets this; mobile leaves it false so
/// the field collapses to a one-liner when unfocused.
final bool alwaysExpanded;

/// The chord that sends the message. Null uses the built-in default
/// (⌘/Ctrl+Enter), which keeps mobile behavior unchanged.
final KeyChord? sendChord;
Expand Down Expand Up @@ -113,6 +127,10 @@ class _ComposerState extends State<Composer> {
setState(() => _showSlash = false);
}

/// Whether to show the full (multiline + footer) form: always on desktop,
/// otherwise only while the field is focused.
bool get _expanded => widget.alwaysExpanded || _isFocused;

@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
Expand All @@ -139,7 +157,7 @@ class _ComposerState extends State<Composer> {
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: _isFocused ? _buildExpanded(cs) : _buildCompact(cs),
child: _expanded ? _buildExpanded(cs) : _buildCompact(cs),
),
),
],
Expand All @@ -159,7 +177,9 @@ class _ComposerState extends State<Composer> {
);
}

/// Expanded: full-width 3-line field, then `[+] … [send?]` beneath.
/// Expanded: auto-growing multiline field, then a footer row with the
/// caller's [Composer.footerActions] on the left (model/thinking selectors)
/// and `[+] [send?]` on the right.
Widget _buildExpanded(ColorScheme cs) {
return Column(
mainAxisSize: MainAxisSize.min,
Expand All @@ -170,8 +190,24 @@ class _ComposerState extends State<Composer> {
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
// The selectors share the row's free space and shrink (their
// labels ellipsize) rather than overflowing on narrow widths.
// Expanded here also stands in for the trailing Spacer, pushing
// [+]/send to the right when the actions are short or absent.
Expanded(
child: Row(
children: [
for (final action in widget.footerActions)
Flexible(
child: Padding(
padding: const EdgeInsets.only(right: 6),
child: action,
),
),
],
),
),
_buildPlus(),
const Spacer(),
// Reserve the send button's footprint so the layout doesn't
// jump when the send button fades in/out.
SizedBox(width: 48, child: _buildSendSlot()),
Expand Down Expand Up @@ -271,9 +307,10 @@ class _ComposerState extends State<Composer> {
key: _fieldKey, // stable element across compact↔expanded reparenting
controller: _ctrl,
focusNode: _focus,
// Compact = exactly 1 line; expanded = fixed 3 lines (scrolls past).
minLines: _isFocused ? 3 : 1,
maxLines: _isFocused ? 3 : 1,
// Compact = exactly 1 line; expanded auto-grows with the caret up to
// 10 lines, then scrolls internally.
minLines: 1,
maxLines: _expanded ? 10 : 1,
textCapitalization: TextCapitalization.sentences,
// Return behavior is driven by _shortcuts(): unconfigured (mobile)
// keeps the native Return-inserts-newline action, with sending via
Expand Down
120 changes: 120 additions & 0 deletions app/lib/ui/composer/composer_selectors.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart';

import '../../store/store.dart';
import '../home/repo_chips.dart' show AgentAvatar;
import 'client_commands.dart';

/// Small rounded pill used in the composer footer: a leading widget (agent
/// avatar or icon) + a short label, tappable to open a picker. Kept visually
/// low-key so it reads as an inline control, not a primary button.
class _ComposerPill extends StatelessWidget {
const _ComposerPill({
required this.leading,
required this.label,
required this.onTap,
this.tooltip,
});

final Widget leading;
final String label;
final VoidCallback onTap;
final String? tooltip;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final cs = theme.colorScheme;
final pill = InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
leading,
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: cs.onSurfaceVariant,
),
),
),
],
),
),
);
return tooltip == null ? pill : Tooltip(message: tooltip!, child: pill);
}
}

/// Composer-footer control that shows the session's current model and, on tap,
/// opens the model picker (reusing the `/model` client command). Renders
/// nothing until `session.meta` has arrived with a non-empty models list — the
/// client fetches the selectable models asynchronously, so there is simply no
/// selector to show in the meantime.
class ComposerModelSelector extends ConsumerWidget {
/// Creates the model selector for [sessionId].
const ComposerModelSelector({super.key, required this.sessionId});

/// The session whose model this selector reads and switches.
final String sessionId;

@override
Widget build(BuildContext context, WidgetRef ref) {
final meta = ref.watch(sessionMetaProvider(sessionId));
if (meta == null || meta.models.isEmpty) return const SizedBox.shrink();
final agent = ref.watch(sessionsProvider).byId(sessionId)?.agent ?? '';
return _ComposerPill(
leading: AgentAvatar(agent: agent, size: 16),
label: meta.model?.name ?? 'model',
tooltip: 'Model',
onTap: () => handleClientCommand(
'/model',
context: context,
ref: ref,
sessionId: sessionId,
),
);
}
}

/// Composer-footer control that shows the session's current thinking
/// (reasoning) effort and, on tap, opens the thinking-level picker (reusing the
/// `/thinking` client command). Hidden entirely when the agent reports no
/// thinking support (an empty thinking level).
class ComposerThinkingSelector extends ConsumerWidget {
/// Creates the thinking-effort selector for [sessionId].
const ComposerThinkingSelector({super.key, required this.sessionId});

/// The session whose thinking level this selector reads and changes.
final String sessionId;

@override
Widget build(BuildContext context, WidgetRef ref) {
final meta = ref.watch(sessionMetaProvider(sessionId));
if (meta == null || meta.thinking.isEmpty) return const SizedBox.shrink();
return _ComposerPill(
leading: Icon(
Symbols.signal_cellular_alt,
size: 16,
weight: 400,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
label: meta.thinking,
tooltip: 'Thinking effort',
onTap: () => handleClientCommand(
'/thinking',
context: context,
ref: ref,
sessionId: sessionId,
),
);
}
}
19 changes: 5 additions & 14 deletions app/lib/ui/session/session_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import '../../store/models.dart';
import '../../store/store.dart';
import '../composer/client_commands.dart';
import '../composer/composer.dart';
import '../composer/composer_selectors.dart';
import 'chat_message.dart';
import 'tool_call_card.dart';
import '../widgets/connection_chip.dart';
Expand Down Expand Up @@ -41,7 +42,6 @@ class _SessionScreenState extends ConsumerState<SessionScreen> {
Widget build(BuildContext context) {
final session = ref.watch(sessionsProvider).byId(widget.sessionId);
final items = ref.watch(chatItemsProvider(widget.sessionId));
final meta = ref.watch(sessionMetaProvider(widget.sessionId));

ref.listen<ActionError?>(sessionActionErrorProvider(widget.sessionId), (
prev,
Expand Down Expand Up @@ -234,19 +234,6 @@ class _SessionScreenState extends ConsumerState<SessionScreen> {
],
),
),
if (meta?.model != null)
Text(
'${meta!.model!.name} · ${meta.thinking}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: cs.onSurface.withValues(alpha: 0.55),
shadows: [
Shadow(color: cs.surface, blurRadius: 6),
],
),
),
],
),
),
Expand Down Expand Up @@ -278,6 +265,10 @@ class _SessionScreenState extends ConsumerState<SessionScreen> {
onSend: (text) => _handleSend(text),
running: session?.status == SessionStatus.running,
onCancel: _cancelTurn,
footerActions: [
ComposerModelSelector(sessionId: widget.sessionId),
ComposerThinkingSelector(sessionId: widget.sessionId),
],
),
),
),
Expand Down
Loading
Loading