neoagent 2.4.1-beta.13 → 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",
@@ -124,7 +124,8 @@
124
124
 
125
125
  <service
126
126
  android:name=".auto.NeoAgentCarAppService"
127
- android:exported="true">
127
+ android:exported="true"
128
+ android:permission="android.car.permission.BIND_CAR_APP_SERVICE">
128
129
  <intent-filter>
129
130
  <action android:name="androidx.car.app.CarAppService" />
130
131
  <category android:name="androidx.car.app.category.IOT" />
@@ -9,23 +9,35 @@ 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;
26
+ bool _isDictating = false;
27
+ bool _isTranscribing = false;
28
+ LiveVoiceCapture? _dictationCapture;
29
+ final List<Uint8List> _dictationChunks = [];
22
30
 
23
31
  @override
24
32
  void initState() {
25
33
  super.initState();
34
+ WidgetsBinding.instance.addObserver(this);
26
35
  _composerController = TextEditingController();
36
+ _composerController.addListener(_handleComposerLayoutChanged);
37
+ _scrollController.addListener(_handleScrollPositionChanged);
27
38
  widget.controller.addListener(_consumeQueuedDraft);
28
39
  _consumeQueuedDraft();
40
+ _scheduleScrollToBottom(force: true);
29
41
  }
30
42
 
31
43
  @override
@@ -35,18 +47,98 @@ class _ChatPanelState extends State<ChatPanel> {
35
47
  oldWidget.controller.removeListener(_consumeQueuedDraft);
36
48
  widget.controller.addListener(_consumeQueuedDraft);
37
49
  _appliedSharedPayloadSignature = null;
50
+ _lastScrollContentSignature = '';
51
+ _stickToBottom = true;
38
52
  _consumeQueuedDraft();
53
+ _scheduleScrollToBottom(force: true);
39
54
  }
40
55
  }
41
56
 
42
57
  @override
43
58
  void dispose() {
59
+ WidgetsBinding.instance.removeObserver(this);
44
60
  widget.controller.removeListener(_consumeQueuedDraft);
61
+ _composerController.removeListener(_handleComposerLayoutChanged);
62
+ _scrollController.removeListener(_handleScrollPositionChanged);
45
63
  _composerController.dispose();
46
64
  _scrollController.dispose();
65
+ _dictationCapture?.dispose();
47
66
  super.dispose();
48
67
  }
49
68
 
69
+ @override
70
+ void didChangeMetrics() {
71
+ super.didChangeMetrics();
72
+ if (_stickToBottom) {
73
+ _scheduleScrollToBottom();
74
+ }
75
+ }
76
+
77
+ Future<void> _startDictation() async {
78
+ if (_isDictating || _isTranscribing) return;
79
+ final capture = LiveVoiceCapture();
80
+ _dictationCapture = capture;
81
+ _dictationChunks.clear();
82
+ try {
83
+ await capture.start(
84
+ onChunk: (chunk) => _dictationChunks.add(chunk),
85
+ sampleRate: 16000,
86
+ channels: 1,
87
+ );
88
+ if (mounted) setState(() => _isDictating = true);
89
+ } catch (e) {
90
+ await capture.dispose();
91
+ _dictationCapture = null;
92
+ if (mounted) {
93
+ ScaffoldMessenger.of(
94
+ context,
95
+ ).showSnackBar(SnackBar(content: Text('Microphone error: $e')));
96
+ }
97
+ }
98
+ }
99
+
100
+ Future<void> _stopAndTranscribe() async {
101
+ if (!_isDictating) return;
102
+ final capture = _dictationCapture;
103
+ _dictationCapture = null;
104
+ setState(() {
105
+ _isDictating = false;
106
+ _isTranscribing = true;
107
+ });
108
+ try {
109
+ await capture?.stop();
110
+ if (_dictationChunks.isEmpty) return;
111
+ final allBytes = _dictationChunks.fold<List<int>>(
112
+ <int>[],
113
+ (acc, chunk) => acc..addAll(chunk),
114
+ );
115
+ final audioBase64 = base64Encode(Uint8List.fromList(allBytes));
116
+ final transcript = await widget.controller.transcribeDictationAudio(
117
+ audioBase64: audioBase64,
118
+ );
119
+ if (mounted && transcript.isNotEmpty) {
120
+ final current = _composerController.text;
121
+ final separator = current.isNotEmpty && !current.endsWith(' ')
122
+ ? ' '
123
+ : '';
124
+ _composerController.text = '$current$separator$transcript';
125
+ _composerController.selection = TextSelection.collapsed(
126
+ offset: _composerController.text.length,
127
+ );
128
+ }
129
+ } catch (e) {
130
+ if (mounted) {
131
+ ScaffoldMessenger.of(
132
+ context,
133
+ ).showSnackBar(SnackBar(content: Text('Transcription failed: $e')));
134
+ }
135
+ } finally {
136
+ await capture?.dispose();
137
+ _dictationChunks.clear();
138
+ if (mounted) setState(() => _isTranscribing = false);
139
+ }
140
+ }
141
+
50
142
  void _consumeQueuedDraft() {
51
143
  final draft = widget.controller.peekPendingChatDraft();
52
144
  final attachments = widget.controller.peekPendingSharedChatAttachments();
@@ -90,6 +182,9 @@ class _ChatPanelState extends State<ChatPanel> {
90
182
  setState(() {
91
183
  _pendingSharedAttachments = const <SharedChatAttachment>[];
92
184
  });
185
+ if (_stickToBottom) {
186
+ _scheduleScrollToBottom();
187
+ }
93
188
  }
94
189
 
95
190
  String _mimeTypeForFileName(String fileName) {
@@ -136,10 +231,9 @@ class _ChatPanelState extends State<ChatPanel> {
136
231
  }
137
232
  final attachments = result.files
138
233
  .where(
139
- (file) =>
140
- kIsWeb
141
- ? file.bytes != null
142
- : file.path?.trim().isNotEmpty == true,
234
+ (file) => kIsWeb
235
+ ? file.bytes != null
236
+ : file.path?.trim().isNotEmpty == true,
143
237
  )
144
238
  .map(
145
239
  (file) => SharedChatAttachment(
@@ -161,108 +255,178 @@ class _ChatPanelState extends State<ChatPanel> {
161
255
  ...attachments,
162
256
  ];
163
257
  });
258
+ if (_stickToBottom) {
259
+ _scheduleScrollToBottom();
260
+ }
164
261
  }
165
262
 
166
263
  bool get _isNearBottom {
167
264
  if (!_scrollController.hasClients) return true;
168
265
  final pos = _scrollController.position;
169
266
  if (!pos.hasContentDimensions) return true;
170
- return pos.pixels >= pos.maxScrollExtent - 80;
267
+ return pos.pixels >= pos.maxScrollExtent - _autoScrollBottomThreshold;
171
268
  }
172
269
 
173
- void _scrollToBottom() {
174
- WidgetsBinding.instance.addPostFrameCallback((_) {
175
- if (!mounted || !_scrollController.hasClients) return;
176
- final pos = _scrollController.position;
177
- if (pos.hasContentDimensions) {
178
- _scrollController.jumpTo(pos.maxScrollExtent);
179
- }
180
- });
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
+ }
181
352
  }
182
353
 
183
354
  @override
184
355
  Widget build(BuildContext context) {
185
356
  final controller = widget.controller;
186
357
  final messages = controller.visibleChatMessages;
187
- if (_lastMessageCount != messages.length ||
188
- _lastToolCount != controller.toolEvents.length ||
189
- _lastStream != controller.streamingAssistant) {
190
- _lastMessageCount = messages.length;
191
- _lastToolCount = controller.toolEvents.length;
192
- _lastStream = controller.streamingAssistant;
193
- if (_isNearBottom) _scrollToBottom();
194
- }
358
+ _maybeFollowChatContent(messages, controller);
195
359
 
196
360
  return Column(
197
361
  children: <Widget>[
198
362
  Expanded(
199
363
  child: SelectionArea(
200
364
  child: ListView(
201
- controller: _scrollController,
202
- padding: _pagePadding(context),
203
- children: <Widget>[
204
- _PageTitle(
205
- title: 'Chat',
206
- subtitle: 'Live agent chat with tool and stream status.',
207
- trailing: Wrap(
208
- spacing: 10,
209
- runSpacing: 10,
210
- crossAxisAlignment: WrapCrossAlignment.center,
211
- children: <Widget>[
212
- FilledButton.icon(
213
- onPressed: () => controller.setSelectedSection(
214
- 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'),
215
382
  ),
216
- icon: Icon(Icons.call),
217
- label: Text('Call'),
218
- ),
219
- _MetaPill(
220
- label: controller.modelIndicator,
221
- icon: Icons.memory_outlined,
222
- ),
223
- _MetaPill(
224
- label: 'Agent: ${controller.activeAgentLabel}',
225
- icon: Icons.smart_toy_outlined,
226
- ),
227
- ],
228
- ),
229
- ),
230
- if (controller.errorMessage != null) ...<Widget>[
231
- _InlineError(message: controller.errorMessage!),
232
- const SizedBox(height: 16),
233
- ],
234
- if (controller.activeRun != null ||
235
- controller.toolEvents.isNotEmpty)
236
- Padding(
237
- padding: const EdgeInsets.only(bottom: 16),
238
- child: _RunStatusPanel(
239
- run: controller.activeRun,
240
- 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
+ ],
241
392
  ),
242
393
  ),
243
- if (messages.isEmpty)
244
- Padding(
245
- padding: EdgeInsets.only(top: 64),
246
- child: Center(
247
- child: _EmptyState(
248
- title: 'How can I help?',
249
- subtitle:
250
- '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,
251
405
  ),
252
406
  ),
253
- )
254
- else
255
- ...messages.map(
256
- (entry) => Padding(
257
- padding: const EdgeInsets.only(bottom: 18),
258
- child: _ChatBubble(
259
- entry: entry,
260
- 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
+ ),
261
426
  ),
262
427
  ),
263
- ),
264
- ],
265
- ),
428
+ ],
429
+ ),
266
430
  ),
267
431
  ),
268
432
  Container(
@@ -287,6 +451,9 @@ class _ChatPanelState extends State<ChatPanel> {
287
451
  .map((entry) => entry.value)
288
452
  .toList(growable: false);
289
453
  });
454
+ if (_stickToBottom) {
455
+ _scheduleScrollToBottom();
456
+ }
290
457
  if (_pendingSharedAttachments.isEmpty) {
291
458
  _clearSharedPayload();
292
459
  }
@@ -331,6 +498,32 @@ class _ChatPanelState extends State<ChatPanel> {
331
498
  color: _textSecondary,
332
499
  ),
333
500
  const SizedBox(width: 2),
501
+ _isTranscribing
502
+ ? const SizedBox(
503
+ width: 40,
504
+ height: 40,
505
+ child: Padding(
506
+ padding: EdgeInsets.all(10),
507
+ child: CircularProgressIndicator(strokeWidth: 2),
508
+ ),
509
+ )
510
+ : IconButton(
511
+ tooltip: _isDictating
512
+ ? 'Stop & transcribe'
513
+ : 'Dictate',
514
+ onPressed: _isDictating
515
+ ? _stopAndTranscribe
516
+ : _startDictation,
517
+ icon: Icon(
518
+ _isDictating
519
+ ? Icons.stop_circle_outlined
520
+ : Icons.mic_none_rounded,
521
+ ),
522
+ color: _isDictating
523
+ ? Theme.of(context).colorScheme.error
524
+ : _textSecondary,
525
+ ),
526
+ const SizedBox(width: 2),
334
527
  FilledButton(
335
528
  onPressed: () => controller.setSelectedSection(
336
529
  AppSection.voiceAssistant,
@@ -4602,6 +4602,18 @@ class NeoAgentController extends ChangeNotifier {
4602
4602
  }
4603
4603
  }
4604
4604
 
4605
+ Future<String> transcribeDictationAudio({
4606
+ required String audioBase64,
4607
+ String mimeType = 'audio/pcm;rate=16000;channels=1',
4608
+ }) async {
4609
+ final result = await _backendClient.transcribeAudio(
4610
+ backendUrl,
4611
+ audioBase64: audioBase64,
4612
+ mimeType: mimeType,
4613
+ );
4614
+ return result['transcript']?.toString() ?? '';
4615
+ }
4616
+
4605
4617
  Future<void> sendMessage(
4606
4618
  String task, {
4607
4619
  List<SharedChatAttachment> sharedAttachments =
@@ -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(
@@ -188,7 +188,9 @@ class OfficialIntegrationsTab extends StatelessWidget {
188
188
  !item.env.configured
189
189
  ? item.env.summary
190
190
  : item.hasExpiredAccounts
191
- ? 'One or more accounts expired. Reconnect the affected account to restore tool access.'
191
+ ? item.id == 'google_workspace'
192
+ ? 'One or more accounts expired. Reconnect to restore access. If this keeps happening, your Google Cloud OAuth app may be in Testing mode — publish it to Production in Google Cloud Console to get long-lived tokens.'
193
+ : 'One or more accounts expired. Reconnect the affected account to restore tool access.'
192
194
  : !item.supportsMultipleAccounts && item.isConnected
193
195
  ? 'This integration currently supports one connected account per agent. Re-open setup to replace it.'
194
196
  : item.isConnected