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.
@@ -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.7",
5
- "version_name": "2.4.1-beta.7",
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
- int _lastMessageCount = 0;
19
- int _lastToolCount = 0;
20
- String _lastStream = '';
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(context).showSnackBar(
72
- SnackBar(content: Text('Microphone error: $e')),
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(context).showSnackBar(
108
- SnackBar(content: Text('Transcription failed: $e')),
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
- kIsWeb
209
- ? file.bytes != null
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 - 80;
267
+ return pos.pixels >= pos.maxScrollExtent - _autoScrollBottomThreshold;
239
268
  }
240
269
 
241
- void _scrollToBottom() {
242
- WidgetsBinding.instance.addPostFrameCallback((_) {
243
- if (!mounted || !_scrollController.hasClients) return;
244
- final pos = _scrollController.position;
245
- if (pos.hasContentDimensions) {
246
- _scrollController.jumpTo(pos.maxScrollExtent);
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
- if (_lastMessageCount != messages.length ||
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
- controller: _scrollController,
270
- padding: _pagePadding(context),
271
- children: <Widget>[
272
- _PageTitle(
273
- title: 'Chat',
274
- subtitle: 'Live agent chat with tool and stream status.',
275
- trailing: Wrap(
276
- spacing: 10,
277
- runSpacing: 10,
278
- crossAxisAlignment: WrapCrossAlignment.center,
279
- children: <Widget>[
280
- FilledButton.icon(
281
- onPressed: () => controller.setSelectedSection(
282
- AppSection.voiceAssistant,
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
- icon: Icon(Icons.call),
285
- label: Text('Call'),
286
- ),
287
- _MetaPill(
288
- label: controller.modelIndicator,
289
- icon: Icons.memory_outlined,
290
- ),
291
- _MetaPill(
292
- label: 'Agent: ${controller.activeAgentLabel}',
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
- if (messages.isEmpty)
312
- Padding(
313
- padding: EdgeInsets.only(top: 64),
314
- child: Center(
315
- child: _EmptyState(
316
- title: 'How can I help?',
317
- subtitle:
318
- 'Runs, tools, memory, scheduling, skills, and MCP are all available here.',
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
- else
323
- ...messages.map(
324
- (entry) => Padding(
325
- padding: const EdgeInsets.only(bottom: 18),
326
- child: _ChatBubble(
327
- entry: entry,
328
- onLoadRunDetail: controller.fetchRunDetail,
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 ? 'Stop & transcribe' : 'Dictate',
413
- onPressed: _isDictating ? _stopAndTranscribe : _startDictation,
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 ? Theme.of(context).colorScheme.error : _textSecondary,
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;