neoagent 2.4.1-beta.14 → 2.4.1-beta.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/chrome-browser/background.mjs +26 -0
- package/extensions/chrome-browser/manifest.json +3 -3
- package/flutter_app/lib/main_chat.dart +198 -93
- package/flutter_app/lib/main_devices.dart +39 -0
- package/flutter_app/lib/main_unified.dart +29 -1
- package/flutter_app/lib/src/desktop_companion_io.dart +23 -0
- package/flutter_app/lib/src/stream_renderer.dart +42 -3
- package/package.json +1 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +44903 -44730
- package/server/services/browser/extension/registry.js +46 -5
- package/server/services/desktop/registry.js +104 -1
- package/server/services/streaming/android-stream.js +39 -1
- package/server/utils/version.js +29 -19
- package/server/utils/version.test.js +39 -0
|
@@ -8,6 +8,8 @@ let reconnectTimer = null;
|
|
|
8
8
|
let suppressSocketClose = false;
|
|
9
9
|
const DEFAULT_FETCH_TIMEOUT_MS = 10000;
|
|
10
10
|
const DEFAULT_WS_CONNECT_TIMEOUT_MS = 10000;
|
|
11
|
+
const KEEPALIVE_ALARM_NAME = 'neoagent-extension-keepalive';
|
|
12
|
+
const KEEPALIVE_ALARM_MINUTES = 1;
|
|
11
13
|
const EXTENSION_PROTOCOL_VERSION = 1;
|
|
12
14
|
|
|
13
15
|
function getStorage(keys = STORAGE_KEYS) {
|
|
@@ -81,6 +83,12 @@ function clearReconnectTimer() {
|
|
|
81
83
|
reconnectTimer = null;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
function ensureKeepaliveAlarm() {
|
|
87
|
+
chrome.alarms?.create?.(KEEPALIVE_ALARM_NAME, {
|
|
88
|
+
periodInMinutes: KEEPALIVE_ALARM_MINUTES,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
async function handleSocketDisconnected(ws) {
|
|
85
93
|
if (socket !== ws) {
|
|
86
94
|
return;
|
|
@@ -308,4 +316,22 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
|
308
316
|
return true;
|
|
309
317
|
});
|
|
310
318
|
|
|
319
|
+
chrome.alarms?.onAlarm?.addListener((alarm) => {
|
|
320
|
+
if (alarm?.name !== KEEPALIVE_ALARM_NAME) return;
|
|
321
|
+
connect().catch((error) => {
|
|
322
|
+
console.error('NeoAgent keepalive reconnect failed', error);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
chrome.runtime.onStartup?.addListener(() => {
|
|
327
|
+
ensureKeepaliveAlarm();
|
|
328
|
+
connect().catch(() => {});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
chrome.runtime.onInstalled?.addListener(() => {
|
|
332
|
+
ensureKeepaliveAlarm();
|
|
333
|
+
connect().catch(() => {});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
ensureKeepaliveAlarm();
|
|
311
337
|
connect().catch(() => {});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "NeoAgent Browser",
|
|
4
|
-
"version": "2.4.1.
|
|
5
|
-
"version_name": "2.4.1-beta.
|
|
4
|
+
"version": "2.4.1.8",
|
|
5
|
+
"version_name": "2.4.1-beta.8",
|
|
6
6
|
"description": "Connect this Chrome browser to NeoAgent for browser automation.",
|
|
7
7
|
"minimum_chrome_version": "118",
|
|
8
|
-
"permissions": ["debugger", "storage", "tabs"],
|
|
8
|
+
"permissions": ["alarms", "debugger", "storage", "tabs"],
|
|
9
9
|
"host_permissions": ["http://*/*", "https://*/*"],
|
|
10
10
|
"icons": {
|
|
11
11
|
"16": "icons/icon16.png",
|
|
@@ -9,15 +9,19 @@ class ChatPanel extends StatefulWidget {
|
|
|
9
9
|
State<ChatPanel> createState() => _ChatPanelState();
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
class _ChatPanelState extends State<ChatPanel> {
|
|
12
|
+
class _ChatPanelState extends State<ChatPanel> with WidgetsBindingObserver {
|
|
13
|
+
static const double _autoScrollBottomThreshold = 120;
|
|
14
|
+
static const int _autoScrollSettlePasses = 4;
|
|
15
|
+
|
|
13
16
|
late final TextEditingController _composerController;
|
|
14
17
|
final ScrollController _scrollController = ScrollController();
|
|
15
18
|
List<SharedChatAttachment> _pendingSharedAttachments =
|
|
16
19
|
const <SharedChatAttachment>[];
|
|
17
20
|
String? _appliedSharedPayloadSignature;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
String _lastScrollContentSignature = '';
|
|
22
|
+
bool _stickToBottom = true;
|
|
23
|
+
bool _ignoreScrollUpdates = false;
|
|
24
|
+
int _scrollGeneration = 0;
|
|
21
25
|
bool _isSendingChatMessage = false;
|
|
22
26
|
bool _isDictating = false;
|
|
23
27
|
bool _isTranscribing = false;
|
|
@@ -27,9 +31,13 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
27
31
|
@override
|
|
28
32
|
void initState() {
|
|
29
33
|
super.initState();
|
|
34
|
+
WidgetsBinding.instance.addObserver(this);
|
|
30
35
|
_composerController = TextEditingController();
|
|
36
|
+
_composerController.addListener(_handleComposerLayoutChanged);
|
|
37
|
+
_scrollController.addListener(_handleScrollPositionChanged);
|
|
31
38
|
widget.controller.addListener(_consumeQueuedDraft);
|
|
32
39
|
_consumeQueuedDraft();
|
|
40
|
+
_scheduleScrollToBottom(force: true);
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
@override
|
|
@@ -39,19 +47,33 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
39
47
|
oldWidget.controller.removeListener(_consumeQueuedDraft);
|
|
40
48
|
widget.controller.addListener(_consumeQueuedDraft);
|
|
41
49
|
_appliedSharedPayloadSignature = null;
|
|
50
|
+
_lastScrollContentSignature = '';
|
|
51
|
+
_stickToBottom = true;
|
|
42
52
|
_consumeQueuedDraft();
|
|
53
|
+
_scheduleScrollToBottom(force: true);
|
|
43
54
|
}
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
@override
|
|
47
58
|
void dispose() {
|
|
59
|
+
WidgetsBinding.instance.removeObserver(this);
|
|
48
60
|
widget.controller.removeListener(_consumeQueuedDraft);
|
|
61
|
+
_composerController.removeListener(_handleComposerLayoutChanged);
|
|
62
|
+
_scrollController.removeListener(_handleScrollPositionChanged);
|
|
49
63
|
_composerController.dispose();
|
|
50
64
|
_scrollController.dispose();
|
|
51
65
|
_dictationCapture?.dispose();
|
|
52
66
|
super.dispose();
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
@override
|
|
70
|
+
void didChangeMetrics() {
|
|
71
|
+
super.didChangeMetrics();
|
|
72
|
+
if (_stickToBottom) {
|
|
73
|
+
_scheduleScrollToBottom();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
Future<void> _startDictation() async {
|
|
56
78
|
if (_isDictating || _isTranscribing) return;
|
|
57
79
|
final capture = LiveVoiceCapture();
|
|
@@ -68,9 +90,9 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
68
90
|
await capture.dispose();
|
|
69
91
|
_dictationCapture = null;
|
|
70
92
|
if (mounted) {
|
|
71
|
-
ScaffoldMessenger.of(
|
|
72
|
-
|
|
73
|
-
);
|
|
93
|
+
ScaffoldMessenger.of(
|
|
94
|
+
context,
|
|
95
|
+
).showSnackBar(SnackBar(content: Text('Microphone error: $e')));
|
|
74
96
|
}
|
|
75
97
|
}
|
|
76
98
|
}
|
|
@@ -96,7 +118,9 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
96
118
|
);
|
|
97
119
|
if (mounted && transcript.isNotEmpty) {
|
|
98
120
|
final current = _composerController.text;
|
|
99
|
-
final separator = current.isNotEmpty && !current.endsWith(' ')
|
|
121
|
+
final separator = current.isNotEmpty && !current.endsWith(' ')
|
|
122
|
+
? ' '
|
|
123
|
+
: '';
|
|
100
124
|
_composerController.text = '$current$separator$transcript';
|
|
101
125
|
_composerController.selection = TextSelection.collapsed(
|
|
102
126
|
offset: _composerController.text.length,
|
|
@@ -104,9 +128,9 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
104
128
|
}
|
|
105
129
|
} catch (e) {
|
|
106
130
|
if (mounted) {
|
|
107
|
-
ScaffoldMessenger.of(
|
|
108
|
-
|
|
109
|
-
);
|
|
131
|
+
ScaffoldMessenger.of(
|
|
132
|
+
context,
|
|
133
|
+
).showSnackBar(SnackBar(content: Text('Transcription failed: $e')));
|
|
110
134
|
}
|
|
111
135
|
} finally {
|
|
112
136
|
await capture?.dispose();
|
|
@@ -158,6 +182,9 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
158
182
|
setState(() {
|
|
159
183
|
_pendingSharedAttachments = const <SharedChatAttachment>[];
|
|
160
184
|
});
|
|
185
|
+
if (_stickToBottom) {
|
|
186
|
+
_scheduleScrollToBottom();
|
|
187
|
+
}
|
|
161
188
|
}
|
|
162
189
|
|
|
163
190
|
String _mimeTypeForFileName(String fileName) {
|
|
@@ -204,10 +231,9 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
204
231
|
}
|
|
205
232
|
final attachments = result.files
|
|
206
233
|
.where(
|
|
207
|
-
(file) =>
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
: file.path?.trim().isNotEmpty == true,
|
|
234
|
+
(file) => kIsWeb
|
|
235
|
+
? file.bytes != null
|
|
236
|
+
: file.path?.trim().isNotEmpty == true,
|
|
211
237
|
)
|
|
212
238
|
.map(
|
|
213
239
|
(file) => SharedChatAttachment(
|
|
@@ -229,108 +255,178 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
229
255
|
...attachments,
|
|
230
256
|
];
|
|
231
257
|
});
|
|
258
|
+
if (_stickToBottom) {
|
|
259
|
+
_scheduleScrollToBottom();
|
|
260
|
+
}
|
|
232
261
|
}
|
|
233
262
|
|
|
234
263
|
bool get _isNearBottom {
|
|
235
264
|
if (!_scrollController.hasClients) return true;
|
|
236
265
|
final pos = _scrollController.position;
|
|
237
266
|
if (!pos.hasContentDimensions) return true;
|
|
238
|
-
return pos.pixels >= pos.maxScrollExtent -
|
|
267
|
+
return pos.pixels >= pos.maxScrollExtent - _autoScrollBottomThreshold;
|
|
239
268
|
}
|
|
240
269
|
|
|
241
|
-
void
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
270
|
+
void _handleScrollPositionChanged() {
|
|
271
|
+
if (_ignoreScrollUpdates || !_scrollController.hasClients) return;
|
|
272
|
+
final nearBottom = _isNearBottom;
|
|
273
|
+
if (_stickToBottom && !nearBottom) {
|
|
274
|
+
_scrollGeneration++;
|
|
275
|
+
}
|
|
276
|
+
_stickToBottom = nearBottom;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
void _handleComposerLayoutChanged() {
|
|
280
|
+
if (_stickToBottom) {
|
|
281
|
+
_scheduleScrollToBottom();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
String _scrollContentSignature(
|
|
286
|
+
List<ChatEntry> messages,
|
|
287
|
+
NeoAgentController controller,
|
|
288
|
+
) {
|
|
289
|
+
final last = messages.isEmpty ? null : messages.last;
|
|
290
|
+
final activeRun = controller.activeRun;
|
|
291
|
+
return <Object?>[
|
|
292
|
+
messages.length,
|
|
293
|
+
last?.id,
|
|
294
|
+
last?.role,
|
|
295
|
+
last?.content.length,
|
|
296
|
+
last?.typing,
|
|
297
|
+
controller.toolEvents.length,
|
|
298
|
+
activeRun?.runId,
|
|
299
|
+
activeRun?.phase,
|
|
300
|
+
activeRun?.iteration,
|
|
301
|
+
controller.streamingAssistant.length,
|
|
302
|
+
controller.isSendingMessage,
|
|
303
|
+
].join('|');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
void _scheduleScrollToBottom({bool force = false}) {
|
|
307
|
+
if (!force && !_stickToBottom) return;
|
|
308
|
+
final generation = ++_scrollGeneration;
|
|
309
|
+
|
|
310
|
+
void settle(int remainingPasses) {
|
|
311
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
312
|
+
if (!mounted ||
|
|
313
|
+
generation != _scrollGeneration ||
|
|
314
|
+
!_scrollController.hasClients) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
final pos = _scrollController.position;
|
|
318
|
+
if (pos.hasContentDimensions) {
|
|
319
|
+
final target = pos.maxScrollExtent;
|
|
320
|
+
if ((pos.pixels - target).abs() > 0.5) {
|
|
321
|
+
_ignoreScrollUpdates = true;
|
|
322
|
+
_scrollController.jumpTo(target);
|
|
323
|
+
_ignoreScrollUpdates = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
_stickToBottom = true;
|
|
327
|
+
if (remainingPasses > 0) {
|
|
328
|
+
Timer(const Duration(milliseconds: 24), () {
|
|
329
|
+
if (mounted && generation == _scrollGeneration) {
|
|
330
|
+
settle(remainingPasses - 1);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
settle(_autoScrollSettlePasses);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
void _maybeFollowChatContent(
|
|
341
|
+
List<ChatEntry> messages,
|
|
342
|
+
NeoAgentController controller,
|
|
343
|
+
) {
|
|
344
|
+
final signature = _scrollContentSignature(messages, controller);
|
|
345
|
+
if (_lastScrollContentSignature == signature) return;
|
|
346
|
+
final isInitialContent = _lastScrollContentSignature.isEmpty;
|
|
347
|
+
final shouldFollow = _stickToBottom || isInitialContent;
|
|
348
|
+
_lastScrollContentSignature = signature;
|
|
349
|
+
if (shouldFollow) {
|
|
350
|
+
_scheduleScrollToBottom(force: isInitialContent);
|
|
351
|
+
}
|
|
249
352
|
}
|
|
250
353
|
|
|
251
354
|
@override
|
|
252
355
|
Widget build(BuildContext context) {
|
|
253
356
|
final controller = widget.controller;
|
|
254
357
|
final messages = controller.visibleChatMessages;
|
|
255
|
-
|
|
256
|
-
_lastToolCount != controller.toolEvents.length ||
|
|
257
|
-
_lastStream != controller.streamingAssistant) {
|
|
258
|
-
_lastMessageCount = messages.length;
|
|
259
|
-
_lastToolCount = controller.toolEvents.length;
|
|
260
|
-
_lastStream = controller.streamingAssistant;
|
|
261
|
-
if (_isNearBottom) _scrollToBottom();
|
|
262
|
-
}
|
|
358
|
+
_maybeFollowChatContent(messages, controller);
|
|
263
359
|
|
|
264
360
|
return Column(
|
|
265
361
|
children: <Widget>[
|
|
266
362
|
Expanded(
|
|
267
363
|
child: SelectionArea(
|
|
268
364
|
child: ListView(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
365
|
+
controller: _scrollController,
|
|
366
|
+
padding: _pagePadding(context),
|
|
367
|
+
children: <Widget>[
|
|
368
|
+
_PageTitle(
|
|
369
|
+
title: 'Chat',
|
|
370
|
+
subtitle: 'Live agent chat with tool and stream status.',
|
|
371
|
+
trailing: Wrap(
|
|
372
|
+
spacing: 10,
|
|
373
|
+
runSpacing: 10,
|
|
374
|
+
crossAxisAlignment: WrapCrossAlignment.center,
|
|
375
|
+
children: <Widget>[
|
|
376
|
+
FilledButton.icon(
|
|
377
|
+
onPressed: () => controller.setSelectedSection(
|
|
378
|
+
AppSection.voiceAssistant,
|
|
379
|
+
),
|
|
380
|
+
icon: Icon(Icons.call),
|
|
381
|
+
label: Text('Call'),
|
|
283
382
|
),
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
icon: Icons.smart_toy_outlined,
|
|
294
|
-
),
|
|
295
|
-
],
|
|
296
|
-
),
|
|
297
|
-
),
|
|
298
|
-
if (controller.errorMessage != null) ...<Widget>[
|
|
299
|
-
_InlineError(message: controller.errorMessage!),
|
|
300
|
-
const SizedBox(height: 16),
|
|
301
|
-
],
|
|
302
|
-
if (controller.activeRun != null ||
|
|
303
|
-
controller.toolEvents.isNotEmpty)
|
|
304
|
-
Padding(
|
|
305
|
-
padding: const EdgeInsets.only(bottom: 16),
|
|
306
|
-
child: _RunStatusPanel(
|
|
307
|
-
run: controller.activeRun,
|
|
308
|
-
tools: controller.toolEvents,
|
|
383
|
+
_MetaPill(
|
|
384
|
+
label: controller.modelIndicator,
|
|
385
|
+
icon: Icons.memory_outlined,
|
|
386
|
+
),
|
|
387
|
+
_MetaPill(
|
|
388
|
+
label: 'Agent: ${controller.activeAgentLabel}',
|
|
389
|
+
icon: Icons.smart_toy_outlined,
|
|
390
|
+
),
|
|
391
|
+
],
|
|
309
392
|
),
|
|
310
393
|
),
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
394
|
+
if (controller.errorMessage != null) ...<Widget>[
|
|
395
|
+
_InlineError(message: controller.errorMessage!),
|
|
396
|
+
const SizedBox(height: 16),
|
|
397
|
+
],
|
|
398
|
+
if (controller.activeRun != null ||
|
|
399
|
+
controller.toolEvents.isNotEmpty)
|
|
400
|
+
Padding(
|
|
401
|
+
padding: const EdgeInsets.only(bottom: 16),
|
|
402
|
+
child: _RunStatusPanel(
|
|
403
|
+
run: controller.activeRun,
|
|
404
|
+
tools: controller.toolEvents,
|
|
319
405
|
),
|
|
320
406
|
),
|
|
321
|
-
)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
407
|
+
if (messages.isEmpty)
|
|
408
|
+
Padding(
|
|
409
|
+
padding: EdgeInsets.only(top: 64),
|
|
410
|
+
child: Center(
|
|
411
|
+
child: _EmptyState(
|
|
412
|
+
title: 'How can I help?',
|
|
413
|
+
subtitle:
|
|
414
|
+
'Runs, tools, memory, scheduling, skills, and MCP are all available here.',
|
|
415
|
+
),
|
|
416
|
+
),
|
|
417
|
+
)
|
|
418
|
+
else
|
|
419
|
+
...messages.map(
|
|
420
|
+
(entry) => Padding(
|
|
421
|
+
padding: const EdgeInsets.only(bottom: 18),
|
|
422
|
+
child: _ChatBubble(
|
|
423
|
+
entry: entry,
|
|
424
|
+
onLoadRunDetail: controller.fetchRunDetail,
|
|
425
|
+
),
|
|
329
426
|
),
|
|
330
427
|
),
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
),
|
|
428
|
+
],
|
|
429
|
+
),
|
|
334
430
|
),
|
|
335
431
|
),
|
|
336
432
|
Container(
|
|
@@ -355,6 +451,9 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
355
451
|
.map((entry) => entry.value)
|
|
356
452
|
.toList(growable: false);
|
|
357
453
|
});
|
|
454
|
+
if (_stickToBottom) {
|
|
455
|
+
_scheduleScrollToBottom();
|
|
456
|
+
}
|
|
358
457
|
if (_pendingSharedAttachments.isEmpty) {
|
|
359
458
|
_clearSharedPayload();
|
|
360
459
|
}
|
|
@@ -409,14 +508,20 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
409
508
|
),
|
|
410
509
|
)
|
|
411
510
|
: IconButton(
|
|
412
|
-
tooltip: _isDictating
|
|
413
|
-
|
|
511
|
+
tooltip: _isDictating
|
|
512
|
+
? 'Stop & transcribe'
|
|
513
|
+
: 'Dictate',
|
|
514
|
+
onPressed: _isDictating
|
|
515
|
+
? _stopAndTranscribe
|
|
516
|
+
: _startDictation,
|
|
414
517
|
icon: Icon(
|
|
415
518
|
_isDictating
|
|
416
519
|
? Icons.stop_circle_outlined
|
|
417
520
|
: Icons.mic_none_rounded,
|
|
418
521
|
),
|
|
419
|
-
color: _isDictating
|
|
522
|
+
color: _isDictating
|
|
523
|
+
? Theme.of(context).colorScheme.error
|
|
524
|
+
: _textSecondary,
|
|
420
525
|
),
|
|
421
526
|
const SizedBox(width: 2),
|
|
422
527
|
FilledButton(
|
|
@@ -1537,6 +1537,40 @@ class _InteractiveSurfacePreviewState
|
|
|
1537
1537
|
} catch (_) {}
|
|
1538
1538
|
}
|
|
1539
1539
|
|
|
1540
|
+
void _handleStreamFirstFrame(String streamKey) {
|
|
1541
|
+
if (!mounted || _requestedStreamKey != streamKey) {
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (_streamFailedKey == streamKey) {
|
|
1545
|
+
setState(() => _streamFailedKey = null);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
void _handleStreamFrameTimeout(String streamKey) {
|
|
1550
|
+
if (!mounted || _requestedStreamKey != streamKey) {
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
setState(() => _streamFailedKey = streamKey);
|
|
1554
|
+
unawaited(_stopActiveStream());
|
|
1555
|
+
if ((widget.screenshotPath ?? '').isEmpty) {
|
|
1556
|
+
unawaited(_refreshStaticFrame());
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
Future<void> _refreshStaticFrame() async {
|
|
1561
|
+
switch (widget.surface) {
|
|
1562
|
+
case _DeviceSurface.browser:
|
|
1563
|
+
await widget.controller.screenshotBrowserRuntime();
|
|
1564
|
+
break;
|
|
1565
|
+
case _DeviceSurface.android:
|
|
1566
|
+
await widget.controller.screenshotAndroidRuntime();
|
|
1567
|
+
break;
|
|
1568
|
+
case _DeviceSurface.desktop:
|
|
1569
|
+
await widget.controller.screenshotDesktopRuntime();
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1540
1574
|
void _detachImageListener() {
|
|
1541
1575
|
if (_imageStream != null && _imageListener != null) {
|
|
1542
1576
|
_imageStream!.removeListener(_imageListener!);
|
|
@@ -1681,6 +1715,7 @@ class _InteractiveSurfacePreviewState
|
|
|
1681
1715
|
streamDeviceId != null &&
|
|
1682
1716
|
streamDeviceId.isNotEmpty &&
|
|
1683
1717
|
streamKey != _streamFailedKey) {
|
|
1718
|
+
final activeStreamKey = streamKey!;
|
|
1684
1719
|
return Stack(
|
|
1685
1720
|
fit: StackFit.expand,
|
|
1686
1721
|
children: <Widget>[
|
|
@@ -1690,6 +1725,10 @@ class _InteractiveSurfacePreviewState
|
|
|
1690
1725
|
deviceId: streamDeviceId,
|
|
1691
1726
|
platform: streamPlatform,
|
|
1692
1727
|
remoteResolution: _pixelSize,
|
|
1728
|
+
onFirstFrame: () =>
|
|
1729
|
+
_handleStreamFirstFrame(activeStreamKey),
|
|
1730
|
+
onFrameTimeout: () =>
|
|
1731
|
+
_handleStreamFrameTimeout(activeStreamKey),
|
|
1693
1732
|
onTap: widget.busy
|
|
1694
1733
|
? null
|
|
1695
1734
|
: (x, y) => unawaited(
|
|
@@ -129,6 +129,7 @@ class RunsAndLogsPanel extends StatefulWidget {
|
|
|
129
129
|
class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
|
|
130
130
|
with SingleTickerProviderStateMixin {
|
|
131
131
|
late final TabController _tabController;
|
|
132
|
+
bool _syncingFromController = false;
|
|
132
133
|
|
|
133
134
|
@override
|
|
134
135
|
void initState() {
|
|
@@ -137,7 +138,7 @@ class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
|
|
|
137
138
|
length: _RunsPageTab.values.length,
|
|
138
139
|
vsync: this,
|
|
139
140
|
initialIndex: _tabForSection(widget.controller.selectedSection).index,
|
|
140
|
-
);
|
|
141
|
+
)..addListener(_handleTabChanged);
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
@override
|
|
@@ -149,17 +150,34 @@ class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
|
|
|
149
150
|
selectedSection == AppSection.logs)) {
|
|
150
151
|
final targetIndex = _tabForSection(selectedSection).index;
|
|
151
152
|
if (_tabController.index != targetIndex) {
|
|
153
|
+
_syncingFromController = true;
|
|
152
154
|
_tabController.index = targetIndex;
|
|
155
|
+
_syncingFromController = false;
|
|
153
156
|
}
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
@override
|
|
158
161
|
void dispose() {
|
|
162
|
+
_tabController.removeListener(_handleTabChanged);
|
|
159
163
|
_tabController.dispose();
|
|
160
164
|
super.dispose();
|
|
161
165
|
}
|
|
162
166
|
|
|
167
|
+
void _handleTabChanged() {
|
|
168
|
+
if (_syncingFromController || _tabController.indexIsChanging) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
_selectSectionForTabIndex(_tabController.index);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
void _selectSectionForTabIndex(int index) {
|
|
175
|
+
final section = _sectionForTab(_RunsPageTab.values[index]);
|
|
176
|
+
if (widget.controller.selectedSection != section) {
|
|
177
|
+
widget.controller.setSelectedSection(section);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
163
181
|
_RunsPageTab _tabForSection(AppSection section) {
|
|
164
182
|
switch (section) {
|
|
165
183
|
case AppSection.logs:
|
|
@@ -169,6 +187,15 @@ class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
|
|
|
169
187
|
}
|
|
170
188
|
}
|
|
171
189
|
|
|
190
|
+
AppSection _sectionForTab(_RunsPageTab tab) {
|
|
191
|
+
switch (tab) {
|
|
192
|
+
case _RunsPageTab.logs:
|
|
193
|
+
return AppSection.logs;
|
|
194
|
+
case _RunsPageTab.runs:
|
|
195
|
+
return AppSection.runs;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
172
199
|
@override
|
|
173
200
|
Widget build(BuildContext context) {
|
|
174
201
|
final controller = widget.controller;
|
|
@@ -193,6 +220,7 @@ class _RunsAndLogsPanelState extends State<RunsAndLogsPanel>
|
|
|
193
220
|
dividerColor: _border,
|
|
194
221
|
indicatorSize: TabBarIndicatorSize.tab,
|
|
195
222
|
labelStyle: const TextStyle(fontWeight: FontWeight.w700),
|
|
223
|
+
onTap: _selectSectionForTabIndex,
|
|
196
224
|
tabs: <Widget>[
|
|
197
225
|
Tab(text: 'Runs (${controller.recentRuns.length})'),
|
|
198
226
|
Tab(text: 'Logs (${controller.logs.length})'),
|
|
@@ -25,6 +25,7 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
25
25
|
final DesktopCompanionActions _actions;
|
|
26
26
|
WebSocket? _socket;
|
|
27
27
|
Timer? _reconnectTimer;
|
|
28
|
+
Timer? _connectionWatchdogTimer;
|
|
28
29
|
Timer? _streamTimer;
|
|
29
30
|
bool _streamCaptureInFlight = false;
|
|
30
31
|
// Set true while a click / drag / scroll / typeText / pressKey command is
|
|
@@ -94,6 +95,7 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
94
95
|
await disconnect();
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
98
|
+
_ensureConnectionWatchdog();
|
|
97
99
|
await _ensureConnected();
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -138,6 +140,8 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
138
140
|
Future<void> disconnect() async {
|
|
139
141
|
_reconnectTimer?.cancel();
|
|
140
142
|
_reconnectTimer = null;
|
|
143
|
+
_connectionWatchdogTimer?.cancel();
|
|
144
|
+
_connectionWatchdogTimer = null;
|
|
141
145
|
_stopStreaming();
|
|
142
146
|
_connecting = false;
|
|
143
147
|
_connected = false;
|
|
@@ -224,6 +228,7 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
224
228
|
uri.toString(),
|
|
225
229
|
headers: <String, Object>{'Cookie': _sessionCookie},
|
|
226
230
|
);
|
|
231
|
+
socket.pingInterval = const Duration(seconds: 25);
|
|
227
232
|
_socket = socket;
|
|
228
233
|
socket.listen(
|
|
229
234
|
_handleMessage,
|
|
@@ -470,6 +475,8 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
470
475
|
void dispose() {
|
|
471
476
|
_reconnectTimer?.cancel();
|
|
472
477
|
_reconnectTimer = null;
|
|
478
|
+
_connectionWatchdogTimer?.cancel();
|
|
479
|
+
_connectionWatchdogTimer = null;
|
|
473
480
|
_stopStreaming();
|
|
474
481
|
_connecting = false;
|
|
475
482
|
_connected = false;
|
|
@@ -486,12 +493,28 @@ class DesktopCompanionManager extends ChangeNotifier {
|
|
|
486
493
|
|
|
487
494
|
void _scheduleReconnect() {
|
|
488
495
|
if (!_enabled || !_authenticated || _sessionCookie.isEmpty) return;
|
|
496
|
+
_ensureConnectionWatchdog();
|
|
489
497
|
_reconnectTimer?.cancel();
|
|
490
498
|
_reconnectTimer = Timer(const Duration(seconds: 5), () {
|
|
491
499
|
unawaited(_ensureConnected());
|
|
492
500
|
});
|
|
493
501
|
}
|
|
494
502
|
|
|
503
|
+
void _ensureConnectionWatchdog() {
|
|
504
|
+
if (!_enabled || !_authenticated || _sessionCookie.isEmpty) return;
|
|
505
|
+
if (_connectionWatchdogTimer != null) return;
|
|
506
|
+
_connectionWatchdogTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
507
|
+
if (!_enabled || !_authenticated || _sessionCookie.isEmpty) {
|
|
508
|
+
_connectionWatchdogTimer?.cancel();
|
|
509
|
+
_connectionWatchdogTimer = null;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (!_connected && !_connecting) {
|
|
513
|
+
unawaited(_ensureConnected());
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
495
518
|
Future<void> _sendEvent(String event, Map<String, Object?> payload) async {
|
|
496
519
|
final socket = _socket;
|
|
497
520
|
if (socket == null || !_connected) return;
|