-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsession_tile.dart
More file actions
258 lines (250 loc) · 10.6 KB
/
Copy pathsession_tile.dart
File metadata and controls
258 lines (250 loc) · 10.6 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
248
249
250
251
252
253
254
255
256
257
258
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';
import '../../app/theme.dart';
import '../../store/models.dart';
import '../../store/store.dart';
import '../widgets/session_status_dot.dart';
import '../ports/session_ports_glyph.dart';
import '../../status/status_event.dart';
import '../../status/status_providers.dart';
import 'repo_chips.dart';
import '../../app/routes.dart';
/// A session inside a repo card, as an inset block (SPEC-19, direction C).
///
/// The block sits on a raised surface so a session reads as living *in* its
/// worktree rather than as a sibling of it — the old flat list left the two
/// ambiguous. Swipe to quit, tap to open.
class SessionTile extends ConsumerWidget {
const SessionTile({super.key, required this.session});
final Session session;
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = Theme.of(context).colorScheme;
final wantsUser = _needsUser(session.status);
return Semantics(
// Screen readers can neither swipe nor long-press, so quit is published
// as a custom action instead of being gesture-only.
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'Quit session'): () =>
_confirmAndQuit(context, ref),
},
child: _body(context, ref, cs, wantsUser),
);
}
Widget _body(
BuildContext context,
WidgetRef ref,
ColorScheme cs,
bool wantsUser,
) {
return ClipRRect(
borderRadius: BorderRadius.circular(kRadius8),
child: Dismissible(
key: ValueKey('sess-${session.id}'),
direction: DismissDirection.endToStart,
background: ColoredBox(
color: cs.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: kSpace16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Icon(PhosphorIconsLight.power, color: cs.onErrorContainer),
const SizedBox(width: kSpace8),
Text(
'Quit',
style: TextStyle(
color: cs.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
confirmDismiss: (_) => _confirmAndQuit(context, ref),
child: Material(
// A session waiting on the user tints its own block, so the card's
// accent bar has a visible cause once the row is expanded.
color: wantsUser
? kStatusCaution.withValues(alpha: 0.10)
: cs.surfaceContainer,
child: InkWell(
onTap: () => context.go(routeForSession(session.id)),
// Swipe is not an accessible gesture: it is invisible to assistive
// tech and awkward one-handed. Long-press reaches the same confirm
// dialog, matching WorktreeRow, which already long-presses for its
// actions.
onLongPress: () => _confirmAndQuit(context, ref),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: kTouchRow),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: kSpace10,
vertical: kSpace8,
),
child: Row(
children: [
// Same dot as the desktop sidebar and pane header, so a
// status reads identically everywhere (DESIGN.md). It also
// pulses for the active states, which a pill cannot.
if (session.pending)
Icon(
PhosphorIconsLight.pencilSimple,
size: 10,
color: cs.outline,
)
else
SessionStatusDot(status: session.status),
const SizedBox(width: kSpace10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
session.pending &&
session.title.trim().isEmpty
? 'new session'
: session.title,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
),
// The dot already carries running/idle/exited. A
// word is spent only where the session is actually
// waiting on the user.
if (session.pending)
TagChip(label: 'draft', color: cs.outline)
else if (wantsUser)
SessionStatusChip(status: session.status),
],
),
const SizedBox(height: kSpace2),
if (_handoffCaption(session) case final caption?) ...[
Row(
children: [
Icon(
PhosphorIconsLight.arrowBendUpRight,
size: 12,
color: cs.outline,
),
const SizedBox(width: kSpace4),
Expanded(
child: Text(
caption,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: cs.outline),
),
),
],
),
const SizedBox(height: kSpace2),
],
Text(
session.pending
? 'Send a message to create a branch'
: session.lastPreview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: cs.outline),
),
],
),
),
const SizedBox(width: kSpace8),
// The session's own ports glyph (D14): quiet, renders only
// when a listener is attributed to this session.
SessionPortsGlyph(sessionId: session.id),
// The agent is the least urgent fact here, so it sits last
// and small — the status leads.
AgentAvatar(agent: session.agent, size: 22),
],
),
),
),
),
),
),
);
}
/// Statuses that are a request for the user, not just progress — these keep
/// their spelled-out chip next to the dot.
static bool _needsUser(SessionStatus s) =>
s == SessionStatus.awaitingInput ||
s == SessionStatus.awaitingApproval ||
s == SessionStatus.error;
/// SPEC-46 D10: a session with lineage was handed off from another session,
/// so the row explains itself rather than appearing as a mystery title. The
/// caption renders whenever [Session.parentId] is set and never depends on
/// resolving the parent — which may be closed or simply not cached — and
/// carries the outgoing agent's reason when one was written.
static String? _handoffCaption(Session session) {
if (session.parentId == null) return null;
final reason = session.handoffReason?.trim();
// Lineage with no reason is not necessarily a handoff: `makit fork` (U4)
// branches a conversation natively and deliberately writes no reason, because
// a fork is not a handoff (D6). "Continued from" is true of both; claiming a
// handoff would mislabel every forked session in the one place the user meets
// it.
return reason == null || reason.isEmpty
? 'Continued from another session'
: 'Handed off — $reason';
}
/// Confirms the close, then requests it and only reports the row as
/// dismissed once the server acknowledges. Returning false on failure keeps
/// the row in place (the session is still in [sessionsProvider]), so a failed
/// close never desyncs the list from server state.
Future<bool> _confirmAndQuit(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 confirmed = await _confirmQuit(context);
if (!confirmed) return false;
try {
await ref.read(storeControllerProvider.notifier).closeSession(session.id);
return true;
} catch (e) {
status.failure(
'Could not quit session',
error: e,
source: StatusSources.session,
sessionId: session.id,
);
return false;
}
}
Future<bool> _confirmQuit(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (dctx) => AlertDialog(
title: const Text('Quit session?'),
content: Text(
'Stop “${session.title}” and remove it? '
'The transcript stays on disk and can be re-attached.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dctx, true),
child: const Text('Quit'),
),
],
),
);
return ok == true;
}
}