-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpane_header.dart
More file actions
247 lines (234 loc) · 8.78 KB
/
Copy pathpane_header.dart
File metadata and controls
247 lines (234 loc) · 8.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';
import 'package:window_manager/window_manager.dart';
import '../../../app/theme.dart';
import '../../../store/models.dart';
import '../../../store/store.dart';
import '../../../status/status_event.dart';
import '../../../status/status_providers.dart';
import '../../../ui/composer/client_commands.dart';
import '../../../ui/session/session_identity.dart';
import '../../../ui/widgets/menu_item.dart';
import '../sidebar_layout.dart';
import '../title_bar_strip.dart';
import 'workspace_controller.dart';
/// The docked pane header: the session title, the sidebar-unfold control when
/// collapsed, and the session actions menu (SPEC-19, moved from
/// desktop_chat_pane). Sits on the traffic-light row and keeps the window
/// draggable where the sidebar's drag strip used to be.
class PaneHeader extends ConsumerWidget {
const PaneHeader({
super.key,
required this.session,
required this.fallbackId,
});
final Session? session;
final String fallbackId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final collapsed = ref.watch(sidebarCollapsedProvider);
// Same fallback order as the sidebar tiles (SPEC-12 decision 8):
// title → agent name → raw session id.
final title = sessionPaneTitle(session, fallbackId);
final theme = Theme.of(context);
final content = Padding(
// When collapsed the pane starts at window x=0, so inset the leading
// control past the traffic lights and align it to the traffic-light row;
// otherwise use the normal gutter.
padding: EdgeInsets.fromLTRB(
collapsed ? kTrafficLightInset : 16,
7,
16,
12,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (collapsed) ...[
const SidebarToggleControls(collapse: false),
const SizedBox(width: kSpace8),
],
if (session != null) ...[
// Match the sidebar-fold icon scale so the fold icon,
// title, and actions menu all sit on the traffic-light row.
const SizedBox(width: kSpace10),
],
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w400,
),
),
),
const SizedBox(width: kSpace4),
SessionActionsMenu(sessionId: fallbackId),
],
),
);
// Keep the window draggable where the sidebar's drag strip used to be.
// DragToMoveArea sits UNDER the content (Stack sibling, like the sidebar's
// _Header) rather than wrapping it: its double-tap recognizer would
// otherwise delay every button tap by the gesture-disambiguation window.
if (!collapsed) return content;
return Stack(
children: [
const Positioned.fill(child: DragToMoveArea(child: SizedBox.expand())),
content,
],
);
}
}
/// The session name shown in a pane header: title, else agent name, else id.
String sessionPaneTitle(Session? session, String fallbackId) {
if (session != null && session.title.trim().isNotEmpty) {
return session.title.trim();
}
if (session != null && session.agent.trim().isNotEmpty) return session.agent;
return fallbackId;
}
/// The session-level overflow menu (Rename / Quit) for a tab strip. Model and
/// thinking-effort live inline in the composer footer, so they are not repeated
/// here. Shared by the docked [PaneHeader] and the workspace tab strip.
class SessionActionsMenu extends ConsumerWidget {
/// Creates the actions menu acting on [sessionId]. [splitId]/[tabId] identify
/// the tab hosting the menu; when both are provided, "Quit session" also
/// closes that tab (Quit = kill the session **and** close its tab). Null
/// keeps the plain unbind behaviour (the standalone [PaneHeader], not used in
/// the workspace tree).
const SessionActionsMenu({
super.key,
required this.sessionId,
this.splitId,
this.tabId,
});
/// The session the menu's actions target.
final String sessionId;
/// The split hosting this menu's tab, closed on Quit when non-null.
final String? splitId;
/// The tab hosting this menu, closed on Quit when non-null.
final String? tabId;
@override
Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton<String>(
tooltip: 'Session actions',
popUpAnimationStyle: AnimationStyle.noAnimation,
padding: EdgeInsets.zero,
// Use `child` (not `icon`): `icon` builds an internal IconButton that
// enforces a 48px min tap target, which inflates the header row and
// pushes the centered row content below the traffic-light line.
child: Padding(
padding: const EdgeInsets.all(3),
child: Icon(
PhosphorIconsRegular.dotsThree,
size: 20,
color: Theme.of(context).colorScheme.onSurface,
),
),
onSelected: (value) {
switch (value) {
case 'rename':
handleClientCommand(
'/name',
context: context,
ref: ref,
sessionId: sessionId,
);
case 'details':
// Reads state the client already holds (D13) — not capability
// gated. `desktop: true` for the anchored popover, `sessionId` so
// the open panel watches and fills in live (D19).
showSessionIdentity(
context: context,
desktop: true,
sessionId: sessionId,
);
case 'quit':
_confirmClose(context, ref);
}
},
itemBuilder: (context) => [
themedMenuItem(
value: 'rename',
icon: PhosphorIconsLight.pencilSimple,
label: 'Rename session',
),
themedMenuItem(
value: 'details',
icon: PhosphorIconsLight.fingerprint,
label: 'Session details',
),
const PopupMenuDivider(),
themedMenuItem(
value: 'quit',
icon: PhosphorIconsLight.moon,
label: 'Close session',
),
],
);
}
Future<void> _confirmClose(BuildContext context, WidgetRef ref) async {
// Resolved before the first await: `ref` throws once its widget is
// unmounted, and the record must survive the thing that reported to it.
final status = ref.status;
final ok = await showDialog<bool>(
context: context,
builder: (dctx) => AlertDialog(
title: const Text('Close session?'),
content: const Text(
'This releases the agent and frees its memory, and removes the session '
'from the active list. The transcript is kept — reopen it any time to '
'resume where you left off.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dctx, true),
child: const Text('Close'),
),
],
),
);
if (ok != true || !context.mounted) return;
// Capture notifiers before the async gap: the optimistic close disposes
// this menu's widget, so nothing below may touch `ref`/`context`.
final store = ref.read(storeControllerProvider.notifier);
final workspace = ref.read(workspaceControllerProvider.notifier);
// Close first, then drop the session's tabs — only mutate the workspace
// once the close is acknowledged, so a failed close leaves the active
// session and its tabs intact (no orphaned layout).
try {
await store.closeSession(sessionId);
if (splitId != null && tabId != null) {
workspace.closeTab(splitId!, tabId!);
}
// Drop any *other* tab still hosting the session so it never lingers.
workspace.unbindSession(sessionId);
} catch (e) {
status.failure(
'Could not close the session',
error: e,
source: StatusSources.session,
sessionId: sessionId,
);
}
}
}
/// A slim top strip shown above the pane's empty state when the sidebar is
/// hidden, so the unfold control stays reachable with no session selected.
/// Renders nothing while the sidebar is visible. Mirrors the sidebar's
/// `_Header` drag-strip + overlaid fold button.
class UnfoldStrip extends ConsumerWidget {
const UnfoldStrip({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!ref.watch(sidebarCollapsedProvider)) return const SizedBox.shrink();
return const TitleBarStrip(leading: SidebarToggleControls(collapse: false));
}
}