Skip to content

Commit 6d4848d

Browse files
authored
feat(desktop): add session actions menu (rename/model/thinking/quit) … (#46)
* feat(desktop): add session actions menu (rename/model/thinking/quit) to pane header Mirror the mobile SessionScreen top-bar overflow menu on desktop so the right-hand chat pane exposes the same per-session actions. Quit reuses the confirm dialog + killSession, then clears the selection to return the pane to its empty state. * test(desktop): cover session actions menu items and quit confirm * fix(desktop): preserve newer session selection on quit
1 parent 85eddab commit 6d4848d

2 files changed

Lines changed: 266 additions & 2 deletions

File tree

app/lib/desktop/chat/desktop_chat_pane.dart

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ class _DesktopChatPaneState extends ConsumerState<DesktopChatPane> {
184184
}
185185
}
186186

187-
class _PaneHeader extends StatelessWidget {
187+
class _PaneHeader extends ConsumerWidget {
188188
const _PaneHeader({
189189
required this.session,
190190
required this.fallbackId,
@@ -195,7 +195,7 @@ class _PaneHeader extends StatelessWidget {
195195
final String? branch;
196196

197197
@override
198-
Widget build(BuildContext context) {
198+
Widget build(BuildContext context, WidgetRef ref) {
199199
final title = (session?.title.trim().isNotEmpty ?? false)
200200
? session!.title.trim()
201201
: fallbackId;
@@ -243,10 +243,120 @@ class _PaneHeader extends StatelessWidget {
243243
const SizedBox(width: 8),
244244
SessionStatusChip(status: session!.status),
245245
],
246+
const SizedBox(width: 4),
247+
_actionsMenu(context, ref),
246248
],
247249
),
248250
);
249251
}
252+
253+
/// Session-level overflow menu (Rename / Model / Thinking / Quit). Mirrors
254+
/// the mobile [SessionScreen] top-bar menu so both platforms expose the same
255+
/// actions.
256+
Widget _actionsMenu(BuildContext context, WidgetRef ref) {
257+
return PopupMenuButton<String>(
258+
tooltip: 'Session actions',
259+
icon: const Icon(Icons.more_vert, size: 20),
260+
onSelected: (value) {
261+
switch (value) {
262+
case 'rename':
263+
handleClientCommand(
264+
'/name',
265+
context: context,
266+
ref: ref,
267+
sessionId: fallbackId,
268+
);
269+
case 'model':
270+
handleClientCommand(
271+
'/model',
272+
context: context,
273+
ref: ref,
274+
sessionId: fallbackId,
275+
);
276+
case 'thinking':
277+
handleClientCommand(
278+
'/thinking',
279+
context: context,
280+
ref: ref,
281+
sessionId: fallbackId,
282+
);
283+
case 'quit':
284+
_confirmQuit(context, ref);
285+
}
286+
},
287+
itemBuilder: (_) => const [
288+
PopupMenuItem(
289+
value: 'rename',
290+
child: ListTile(
291+
leading: Icon(Icons.drive_file_rename_outline),
292+
title: Text('Rename session'),
293+
contentPadding: EdgeInsets.zero,
294+
),
295+
),
296+
PopupMenuItem(
297+
value: 'model',
298+
child: ListTile(
299+
leading: Icon(Icons.smart_toy_outlined),
300+
title: Text('Model'),
301+
contentPadding: EdgeInsets.zero,
302+
),
303+
),
304+
PopupMenuItem(
305+
value: 'thinking',
306+
child: ListTile(
307+
leading: Icon(Icons.psychology_outlined),
308+
title: Text('Thinking'),
309+
contentPadding: EdgeInsets.zero,
310+
),
311+
),
312+
PopupMenuDivider(),
313+
PopupMenuItem(
314+
value: 'quit',
315+
child: ListTile(
316+
leading: Icon(Icons.power_settings_new),
317+
title: Text('Quit session'),
318+
contentPadding: EdgeInsets.zero,
319+
),
320+
),
321+
],
322+
);
323+
}
324+
325+
Future<void> _confirmQuit(BuildContext context, WidgetRef ref) async {
326+
final ok = await showDialog<bool>(
327+
context: context,
328+
builder: (dctx) => AlertDialog(
329+
title: const Text('Quit session?'),
330+
content: const Text(
331+
'This stops the agent process and removes the session. '
332+
'The transcript stays on disk and can be re-attached later.',
333+
),
334+
actions: [
335+
TextButton(
336+
onPressed: () => Navigator.pop(dctx, false),
337+
child: const Text('Cancel'),
338+
),
339+
FilledButton(
340+
onPressed: () => Navigator.pop(dctx, true),
341+
child: const Text('Quit'),
342+
),
343+
],
344+
),
345+
);
346+
if (ok != true || !context.mounted) return;
347+
final messenger = ScaffoldMessenger.of(context);
348+
try {
349+
await ref.read(storeControllerProvider.notifier).killSession(fallbackId);
350+
if (!context.mounted) return;
351+
// Clear the selection so the pane returns to its empty state (desktop's
352+
// analog of the mobile screen's pop-to-home after quit).
353+
if (ref.read(selectedSessionProvider) == fallbackId) {
354+
ref.read(selectedSessionProvider.notifier).state = null;
355+
}
356+
} catch (e) {
357+
messenger.showSnackBar(SnackBar(content: Text('Could not quit: $e')));
358+
}
359+
}
250360
}
251361

252362
class _ThinkingLine extends StatefulWidget {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import 'dart:async';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_riverpod/flutter_riverpod.dart';
5+
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:makit/desktop/chat/desktop_chat_pane.dart';
8+
import 'package:makit/desktop/chat/selected_session.dart';
9+
import 'package:makit/store/connection.dart';
10+
import 'package:makit/store/models.dart';
11+
import 'package:makit/store/store.dart';
12+
import 'package:makit/transport/protocol.dart';
13+
14+
class _EmptyStorage extends FlutterSecureStorage {
15+
const _EmptyStorage() : super();
16+
17+
@override
18+
Future<String?> read({
19+
required String key,
20+
AppleOptions? iOptions,
21+
AndroidOptions? aOptions,
22+
LinuxOptions? lOptions,
23+
WebOptions? webOptions,
24+
AppleOptions? mOptions,
25+
WindowsOptions? wOptions,
26+
}) async => null;
27+
28+
@override
29+
Future<void> write({
30+
required String key,
31+
required String? value,
32+
AppleOptions? iOptions,
33+
AndroidOptions? aOptions,
34+
LinuxOptions? lOptions,
35+
WebOptions? webOptions,
36+
AppleOptions? mOptions,
37+
WindowsOptions? wOptions,
38+
}) async {}
39+
40+
@override
41+
Future<void> delete({
42+
required String key,
43+
AppleOptions? iOptions,
44+
AndroidOptions? aOptions,
45+
LinuxOptions? lOptions,
46+
WebOptions? webOptions,
47+
AppleOptions? mOptions,
48+
WindowsOptions? wOptions,
49+
}) async {}
50+
}
51+
52+
class _ControllableConnection extends ConnectionController {
53+
_ControllableConnection() : super(const _EmptyStorage());
54+
55+
final killCompleted = Completer<Map<String, dynamic>>();
56+
57+
@override
58+
Future<Map<String, dynamic>> request(
59+
MsgType type,
60+
Map<String, dynamic> body,
61+
) {
62+
if (body['kind'] == 'session.kill') return killCompleted.future;
63+
return Future.value(const {});
64+
}
65+
}
66+
67+
Future<void> _pumpPane(
68+
WidgetTester tester, {
69+
required String sessionId,
70+
ConnectionController? connection,
71+
}) async {
72+
final session = Session(
73+
id: sessionId,
74+
projectId: 'p1',
75+
agent: 'pi',
76+
title: 'Session',
77+
status: SessionStatus.idle,
78+
policy: ApprovalPolicy.askOnRisky,
79+
);
80+
81+
await tester.pumpWidget(
82+
ProviderScope(
83+
overrides: [
84+
connectionControllerProvider.overrideWith(
85+
(ref) => connection ?? ConnectionController(const _EmptyStorage()),
86+
),
87+
selectedSessionProvider.overrideWith((ref) => sessionId),
88+
sessionsProvider.overrideWithValue(SessionsState([session])),
89+
reposProvider.overrideWithValue(ReposState(const [])),
90+
chatItemsProvider(sessionId).overrideWithValue(const []),
91+
sessionActionErrorProvider(sessionId).overrideWithValue(null),
92+
commandsProvider(sessionId).overrideWithValue(const []),
93+
],
94+
child: const MaterialApp(home: Scaffold(body: DesktopChatPane())),
95+
),
96+
);
97+
await tester.pump();
98+
}
99+
100+
void main() {
101+
testWidgets('actions menu exposes the same items as the mobile screen', (
102+
tester,
103+
) async {
104+
await _pumpPane(tester, sessionId: 's1');
105+
106+
await tester.tap(find.byTooltip('Session actions'));
107+
await tester.pumpAndSettle();
108+
109+
expect(find.text('Rename session'), findsOneWidget);
110+
expect(find.text('Model'), findsOneWidget);
111+
expect(find.text('Thinking'), findsOneWidget);
112+
expect(find.text('Quit session'), findsOneWidget);
113+
});
114+
115+
testWidgets('Quit prompts for confirmation before killing the session', (
116+
tester,
117+
) async {
118+
await _pumpPane(tester, sessionId: 's1');
119+
120+
await tester.tap(find.byTooltip('Session actions'));
121+
await tester.pumpAndSettle();
122+
await tester.tap(find.text('Quit session'));
123+
await tester.pumpAndSettle();
124+
125+
expect(find.text('Quit session?'), findsOneWidget);
126+
expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget);
127+
expect(find.widgetWithText(FilledButton, 'Quit'), findsOneWidget);
128+
});
129+
130+
testWidgets('Quit completion preserves a newer session selection', (
131+
tester,
132+
) async {
133+
final connection = _ControllableConnection();
134+
await _pumpPane(tester, sessionId: 's1', connection: connection);
135+
136+
await tester.tap(find.byTooltip('Session actions'));
137+
await tester.pumpAndSettle();
138+
await tester.tap(find.text('Quit session'));
139+
await tester.pumpAndSettle();
140+
await tester.tap(find.widgetWithText(FilledButton, 'Quit'));
141+
await tester.pump();
142+
143+
final container = ProviderScope.containerOf(
144+
tester.element(find.byType(DesktopChatPane)),
145+
);
146+
container.read(selectedSessionProvider.notifier).state = 's2';
147+
await tester.pump();
148+
149+
connection.killCompleted.complete(const {});
150+
await tester.pump();
151+
152+
expect(container.read(selectedSessionProvider), 's2');
153+
});
154+
}

0 commit comments

Comments
 (0)