@vertesia/ui 1.5.0-dev.20260901.045442Z → 1.5.0-dev.20260905.011013Z

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.
Files changed (139) hide show
  1. package/lib/features/agent/chat/AgentRightPanel.d.ts +2 -3
  2. package/lib/features/agent/chat/AgentRightPanel.d.ts.map +1 -1
  3. package/lib/features/agent/chat/AgentRightPanel.js +3 -3
  4. package/lib/features/agent/chat/AgentRightPanel.js.map +1 -1
  5. package/lib/features/agent/chat/DocumentPanel.d.ts +7 -7
  6. package/lib/features/agent/chat/DocumentPanel.d.ts.map +1 -1
  7. package/lib/features/agent/chat/DocumentPanel.js +6 -12
  8. package/lib/features/agent/chat/DocumentPanel.js.map +1 -1
  9. package/lib/features/agent/chat/DocumentsTab.d.ts +15 -0
  10. package/lib/features/agent/chat/DocumentsTab.d.ts.map +1 -0
  11. package/lib/features/agent/chat/DocumentsTab.js +33 -0
  12. package/lib/features/agent/chat/DocumentsTab.js.map +1 -0
  13. package/lib/features/agent/chat/ModernAgentConversation.js +2 -2
  14. package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
  15. package/lib/features/agent/chat/ModernAgentOutput/Header.d.ts.map +1 -1
  16. package/lib/features/agent/chat/ModernAgentOutput/Header.js +15 -10
  17. package/lib/features/agent/chat/ModernAgentOutput/Header.js.map +1 -1
  18. package/lib/features/agent/chat/hooks/useDocumentPanel.d.ts +2 -2
  19. package/lib/features/agent/chat/hooks/useDocumentPanel.d.ts.map +1 -1
  20. package/lib/features/agent/chat/hooks/useDocumentPanel.js +0 -18
  21. package/lib/features/agent/chat/hooks/useDocumentPanel.js.map +1 -1
  22. package/lib/features/oauth/McpConnectionsButton.d.ts.map +1 -1
  23. package/lib/features/oauth/McpConnectionsButton.js +4 -1
  24. package/lib/features/oauth/McpConnectionsButton.js.map +1 -1
  25. package/lib/features/oauth/McpConnectionsDialog.d.ts.map +1 -1
  26. package/lib/features/oauth/McpConnectionsDialog.js +3 -2
  27. package/lib/features/oauth/McpConnectionsDialog.js.map +1 -1
  28. package/lib/features/oauth/useMcpConnections.d.ts +6 -7
  29. package/lib/features/oauth/useMcpConnections.d.ts.map +1 -1
  30. package/lib/features/oauth/useMcpConnections.js +27 -20
  31. package/lib/features/oauth/useMcpConnections.js.map +1 -1
  32. package/lib/features/store/types/TypeRegistry.d.ts +10 -5
  33. package/lib/features/store/types/TypeRegistry.d.ts.map +1 -1
  34. package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts +10 -5
  35. package/lib/features/store/types/search/ObjectTypeSearchContext.d.ts.map +1 -1
  36. package/lib/i18n/locales/ar.json +10 -0
  37. package/lib/i18n/locales/de.json +6 -0
  38. package/lib/i18n/locales/en.json +6 -0
  39. package/lib/i18n/locales/es.json +6 -0
  40. package/lib/i18n/locales/fr.json +6 -0
  41. package/lib/i18n/locales/it.json +6 -0
  42. package/lib/i18n/locales/ja.json +6 -0
  43. package/lib/i18n/locales/ko.json +6 -0
  44. package/lib/i18n/locales/pt.json +6 -0
  45. package/lib/i18n/locales/ru.json +8 -0
  46. package/lib/i18n/locales/tr.json +6 -0
  47. package/lib/i18n/locales/zh-TW.json +6 -0
  48. package/lib/i18n/locales/zh.json +6 -0
  49. package/lib/router/NestedNavigationContext.d.ts +3 -1
  50. package/lib/router/NestedNavigationContext.d.ts.map +1 -1
  51. package/lib/router/NestedNavigationContext.js +2 -2
  52. package/lib/router/NestedNavigationContext.js.map +1 -1
  53. package/lib/vertesia-ui-features.js +14 -14
  54. package/lib/vertesia-ui-features.js.map +1 -1
  55. package/lib/vertesia-ui-i18n.3yHWompP.chunk.js +5 -0
  56. package/lib/vertesia-ui-i18n.3yHWompP.chunk.js.map +1 -0
  57. package/lib/vertesia-ui-i18n.4XKCcnL0.chunk.js +5 -0
  58. package/lib/vertesia-ui-i18n.4XKCcnL0.chunk.js.map +1 -0
  59. package/lib/vertesia-ui-i18n.78KG4J25.chunk.js +5 -0
  60. package/lib/vertesia-ui-i18n.78KG4J25.chunk.js.map +1 -0
  61. package/lib/vertesia-ui-i18n.BN06mpp1.chunk.js +5 -0
  62. package/lib/vertesia-ui-i18n.BN06mpp1.chunk.js.map +1 -0
  63. package/lib/vertesia-ui-i18n.CSzKLlHJ.chunk.js +5 -0
  64. package/lib/vertesia-ui-i18n.CSzKLlHJ.chunk.js.map +1 -0
  65. package/lib/vertesia-ui-i18n.Ccus7aRh.chunk.js +5 -0
  66. package/lib/vertesia-ui-i18n.Ccus7aRh.chunk.js.map +1 -0
  67. package/lib/vertesia-ui-i18n.D9j3c6gI.chunk.js +5 -0
  68. package/lib/vertesia-ui-i18n.D9j3c6gI.chunk.js.map +1 -0
  69. package/lib/vertesia-ui-i18n.DAUg4rrI.chunk.js +5 -0
  70. package/lib/vertesia-ui-i18n.DAUg4rrI.chunk.js.map +1 -0
  71. package/lib/vertesia-ui-i18n.DSxVZKXy.chunk.js +5 -0
  72. package/lib/vertesia-ui-i18n.DSxVZKXy.chunk.js.map +1 -0
  73. package/lib/vertesia-ui-i18n.aahVhHvR.chunk.js +5 -0
  74. package/lib/vertesia-ui-i18n.aahVhHvR.chunk.js.map +1 -0
  75. package/lib/vertesia-ui-i18n.js +2 -2
  76. package/lib/vertesia-ui-i18n.nZ3xJ26v.chunk.js +5 -0
  77. package/lib/vertesia-ui-i18n.nZ3xJ26v.chunk.js.map +1 -0
  78. package/lib/vertesia-ui-i18n.xPYbuFzT.chunk.js +5 -0
  79. package/lib/vertesia-ui-i18n.xPYbuFzT.chunk.js.map +1 -0
  80. package/lib/vertesia-ui-router.js +1 -1
  81. package/lib/vertesia-ui-router.js.map +1 -1
  82. package/package.json +7 -7
  83. package/src/features/agent/chat/AgentRightPanel.tsx +4 -8
  84. package/src/features/agent/chat/DocumentPanel.tsx +39 -48
  85. package/src/features/agent/chat/DocumentsTab.test.tsx +87 -0
  86. package/src/features/agent/chat/DocumentsTab.tsx +124 -0
  87. package/src/features/agent/chat/ModernAgentConversation.test.tsx +0 -1
  88. package/src/features/agent/chat/ModernAgentConversation.tsx +0 -2
  89. package/src/features/agent/chat/ModernAgentOutput/Header.tsx +27 -14
  90. package/src/features/agent/chat/hooks/useDocumentPanel.test.tsx +33 -0
  91. package/src/features/agent/chat/hooks/useDocumentPanel.ts +3 -20
  92. package/src/features/oauth/McpConnectionsButton.test.tsx +79 -8
  93. package/src/features/oauth/McpConnectionsButton.tsx +6 -3
  94. package/src/features/oauth/McpConnectionsDialog.tsx +9 -3
  95. package/src/features/oauth/useMcpConnections.ts +33 -27
  96. package/src/i18n/locales/ar.json +10 -0
  97. package/src/i18n/locales/de.json +6 -0
  98. package/src/i18n/locales/en.json +6 -0
  99. package/src/i18n/locales/es.json +6 -0
  100. package/src/i18n/locales/fr.json +6 -0
  101. package/src/i18n/locales/it.json +6 -0
  102. package/src/i18n/locales/ja.json +6 -0
  103. package/src/i18n/locales/ko.json +6 -0
  104. package/src/i18n/locales/pt.json +6 -0
  105. package/src/i18n/locales/ru.json +8 -0
  106. package/src/i18n/locales/tr.json +6 -0
  107. package/src/i18n/locales/zh-TW.json +6 -0
  108. package/src/i18n/locales/zh.json +6 -0
  109. package/src/router/NestedNavigationContext.test.tsx +54 -0
  110. package/src/router/NestedNavigationContext.tsx +9 -2
  111. package/lib/features/agent/chat/DocumentTabBar.d.ts +0 -9
  112. package/lib/features/agent/chat/DocumentTabBar.d.ts.map +0 -1
  113. package/lib/features/agent/chat/DocumentTabBar.js +0 -9
  114. package/lib/features/agent/chat/DocumentTabBar.js.map +0 -1
  115. package/lib/vertesia-ui-i18n.BJBGsq-8.chunk.js +0 -5
  116. package/lib/vertesia-ui-i18n.BJBGsq-8.chunk.js.map +0 -1
  117. package/lib/vertesia-ui-i18n.BY5YG5cX.chunk.js +0 -5
  118. package/lib/vertesia-ui-i18n.BY5YG5cX.chunk.js.map +0 -1
  119. package/lib/vertesia-ui-i18n.Bd5_1BXO.chunk.js +0 -5
  120. package/lib/vertesia-ui-i18n.Bd5_1BXO.chunk.js.map +0 -1
  121. package/lib/vertesia-ui-i18n.Bvwej2HH.chunk.js +0 -5
  122. package/lib/vertesia-ui-i18n.Bvwej2HH.chunk.js.map +0 -1
  123. package/lib/vertesia-ui-i18n.CMT3f4gf.chunk.js +0 -5
  124. package/lib/vertesia-ui-i18n.CMT3f4gf.chunk.js.map +0 -1
  125. package/lib/vertesia-ui-i18n.D56DNPJW.chunk.js +0 -5
  126. package/lib/vertesia-ui-i18n.D56DNPJW.chunk.js.map +0 -1
  127. package/lib/vertesia-ui-i18n.DDzKLNx6.chunk.js +0 -5
  128. package/lib/vertesia-ui-i18n.DDzKLNx6.chunk.js.map +0 -1
  129. package/lib/vertesia-ui-i18n.D_Am1LpF.chunk.js +0 -5
  130. package/lib/vertesia-ui-i18n.D_Am1LpF.chunk.js.map +0 -1
  131. package/lib/vertesia-ui-i18n.DrZizgGV.chunk.js +0 -5
  132. package/lib/vertesia-ui-i18n.DrZizgGV.chunk.js.map +0 -1
  133. package/lib/vertesia-ui-i18n.DwHsq86z.chunk.js +0 -5
  134. package/lib/vertesia-ui-i18n.DwHsq86z.chunk.js.map +0 -1
  135. package/lib/vertesia-ui-i18n.PLpo0Bbz.chunk.js +0 -5
  136. package/lib/vertesia-ui-i18n.PLpo0Bbz.chunk.js.map +0 -1
  137. package/lib/vertesia-ui-i18n.xVbXALqz.chunk.js +0 -5
  138. package/lib/vertesia-ui-i18n.xVbXALqz.chunk.js.map +0 -1
  139. package/src/features/agent/chat/DocumentTabBar.tsx +0 -41
@@ -0,0 +1,124 @@
1
+ import { Button, Input, VTooltip } from '@vertesia/ui/core';
2
+ import { useUITranslation } from '@vertesia/ui/i18n';
3
+ import { NavLink } from '@vertesia/ui/router';
4
+ import { ExternalLinkIcon, FileTextIcon } from 'lucide-react';
5
+ import React, { useCallback, useId, useMemo, useState } from 'react';
6
+ import { DocumentPanel } from './DocumentPanel.js';
7
+ import type { OpenDocument } from './types/document.js';
8
+
9
+ /** Below this many documents the list is short enough that a filter box is just noise. */
10
+ const FILTER_VISIBILITY_THRESHOLD = 5;
11
+
12
+ interface DocumentsTabProps {
13
+ documents: OpenDocument[];
14
+ /** Document shown in the detail view; `null` shows the list. */
15
+ activeDocumentId: string | null;
16
+ onSelectDocument: (id: string | null) => void;
17
+ onUpdateDocumentTitle?: (id: string, title: string) => void;
18
+ refreshKey: number;
19
+ runId?: string;
20
+ }
21
+
22
+ function DocumentsTabComponent({
23
+ documents,
24
+ activeDocumentId,
25
+ onSelectDocument,
26
+ onUpdateDocumentTitle,
27
+ refreshKey,
28
+ runId,
29
+ }: DocumentsTabProps) {
30
+ const { t } = useUITranslation();
31
+ const [filterValue, setFilterValue] = useState('');
32
+ const filterInputId = useId();
33
+
34
+ const normalizedFilterValue = filterValue.trim().toLocaleLowerCase();
35
+ const filteredDocuments = useMemo(() => {
36
+ if (!normalizedFilterValue) return documents;
37
+ return documents.filter((doc) => doc.title.toLocaleLowerCase().includes(normalizedFilterValue));
38
+ }, [documents, normalizedFilterValue]);
39
+
40
+ const handleBack = useCallback(() => onSelectDocument(null), [onSelectDocument]);
41
+
42
+ const activeDocument = activeDocumentId ? documents.find((doc) => doc.id === activeDocumentId) : undefined;
43
+
44
+ if (activeDocumentId) {
45
+ return (
46
+ <DocumentPanel
47
+ documentId={activeDocumentId}
48
+ title={activeDocument?.title}
49
+ onBack={handleBack}
50
+ onUpdateDocumentTitle={onUpdateDocumentTitle}
51
+ refreshKey={refreshKey}
52
+ runId={runId}
53
+ />
54
+ );
55
+ }
56
+
57
+ return (
58
+ <div className="flex flex-col h-full">
59
+ {/* Top bar */}
60
+ <div className="flex shrink-0 flex-col gap-2 border-b px-3 py-2">
61
+ <span className="text-xs text-muted">
62
+ {normalizedFilterValue
63
+ ? t('agent.documentsFiltered', {
64
+ visible: filteredDocuments.length,
65
+ total: documents.length,
66
+ })
67
+ : t('agent.documentCount', { count: documents.length })}
68
+ </span>
69
+ {documents.length > FILTER_VISIBILITY_THRESHOLD && (
70
+ <>
71
+ <label htmlFor={filterInputId} className="sr-only">
72
+ {t('form.filter')}
73
+ </label>
74
+ <Input
75
+ id={filterInputId}
76
+ type="text"
77
+ role="searchbox"
78
+ autoComplete="off"
79
+ placeholder={t('store.searchPlaceholder')}
80
+ value={filterValue}
81
+ onChange={setFilterValue}
82
+ clearable={true}
83
+ />
84
+ </>
85
+ )}
86
+ </div>
87
+
88
+ {/* List */}
89
+ <div className="flex-1 overflow-y-auto px-3 py-2">
90
+ {filteredDocuments.length > 0 ? (
91
+ <div className="min-w-0">
92
+ {filteredDocuments.map((doc) => (
93
+ <div key={doc.id} className="flex min-w-0 items-center gap-1">
94
+ <Button
95
+ variant="unstyled"
96
+ className="flex min-w-0 flex-1 items-center justify-start gap-1.5 rounded px-1 py-1 text-start text-sm hover:bg-muted/30"
97
+ onClick={() => onSelectDocument(doc.id)}
98
+ title={doc.title}
99
+ >
100
+ <FileTextIcon className="size-4 shrink-0 text-muted" />
101
+ <span className="min-w-0 truncate">{doc.title}</span>
102
+ </Button>
103
+ <NavLink
104
+ href={`/store/objects/${doc.id}#overview`}
105
+ topLevelNav
106
+ className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted hover:bg-muted/20 hover:text-foreground"
107
+ >
108
+ <VTooltip description={t('agent.openDocument')} placement="top" size="xs" asChild>
109
+ <ExternalLinkIcon className="size-3.5" aria-hidden="true" />
110
+ </VTooltip>
111
+ <span className="sr-only">{t('agent.openDocument')}</span>
112
+ </NavLink>
113
+ </div>
114
+ ))}
115
+ </div>
116
+ ) : (
117
+ <div className="px-1 py-6 text-sm text-muted">{t('agent.noMatchingDocuments')}</div>
118
+ )}
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ export const DocumentsTab = React.memo(DocumentsTabComponent);
@@ -282,7 +282,6 @@ describe('ModernAgentConversation send handling', () => {
282
282
  isDocPanelOpen: false,
283
283
  docRefreshKey: 0,
284
284
  closeDocPanel: vi.fn(),
285
- closeDocument: vi.fn(),
286
285
  selectDocument: vi.fn(),
287
286
  openDocInPanel: vi.fn(),
288
287
  updateDocumentTitle: vi.fn(),
@@ -1561,7 +1561,6 @@ function ModernAgentConversationInner({
1561
1561
  isDocPanelOpen,
1562
1562
  docRefreshKey,
1563
1563
  closeDocPanel: handleCloseDocPanel,
1564
- closeDocument: handleCloseDocument,
1565
1564
  selectDocument,
1566
1565
  openDocInPanel,
1567
1566
  updateDocumentTitle,
@@ -3062,7 +3061,6 @@ function ModernAgentConversationInner({
3062
3061
  openDocuments={openDocuments}
3063
3062
  activeDocumentId={activeDocumentId}
3064
3063
  onSelectDocument={selectDocument}
3065
- onCloseDocument={handleCloseDocument}
3066
3064
  onUpdateDocumentTitle={updateDocumentTitle}
3067
3065
  docRefreshKey={docRefreshKey}
3068
3066
  runId={agentRunId}
@@ -65,6 +65,19 @@ export interface HeaderProps {
65
65
  className?: string;
66
66
  }
67
67
 
68
+ /**
69
+ * Opens an internal route in a new browser tab, carrying the active tenant sticky params (a/p) so
70
+ * the new tab keeps the current account/project; absolute/external URLs are left untouched.
71
+ */
72
+ function useOpenInNewTab() {
73
+ const { router } = useRouterContext();
74
+ return (url: string) => {
75
+ const href = url.startsWith('/') ? router.getTopRouter().navigator.addStickyParams(url) : url;
76
+ window.open(href, '_blank', 'noopener,noreferrer');
77
+ return href;
78
+ };
79
+ }
80
+
68
81
  export default function Header({
69
82
  title,
70
83
  variant = 'full',
@@ -93,6 +106,7 @@ export default function Header({
93
106
  className,
94
107
  }: HeaderProps) {
95
108
  const { t } = useUITranslation();
109
+ const openInNewTab = useOpenInNewTab();
96
110
  const isCompact = variant === 'compact';
97
111
  const nextViewMode: AgentConversationViewMode = viewMode === 'sliding' ? 'stacked' : 'sliding';
98
112
  const verboseLabel = t('agent.verbose');
@@ -195,6 +209,19 @@ export default function Header({
195
209
  </Button>
196
210
  )}
197
211
 
212
+ {agentRunId && (
213
+ <Button
214
+ type="button"
215
+ size={isCompact ? 'icon' : 'xs'}
216
+ variant="ghost"
217
+ onClick={() => openInNewTab(`/store/agent-runner/${agentRunId}`)}
218
+ title={t('agent.openInNewTab')}
219
+ className={cn('transition-all duration-200 rounded-md', isCompact && 'size-8 rounded-lg')}
220
+ >
221
+ <ExternalLink className="size-4" />
222
+ </Button>
223
+ )}
224
+
198
225
  {resetWorkflow && (
199
226
  <Button
200
227
  type="button"
@@ -316,7 +343,6 @@ function MoreDropdown({
316
343
  const toast = useToast();
317
344
  const { client } = useUserSession();
318
345
  const builder = usePayloadBuilder();
319
- const { router } = useRouterContext();
320
346
 
321
347
  const cancelWorkflow = async () => {
322
348
  try {
@@ -360,14 +386,6 @@ function MoreDropdown({
360
386
  }
361
387
  };
362
388
 
363
- const openUrl = (url: string) => {
364
- // Carry the active tenant sticky params (a/p) on internal routes so the new tab keeps the
365
- // current account/project; leave absolute/external URLs untouched.
366
- const href = url.startsWith('/') ? router.getTopRouter().navigator.addStickyParams(url) : url;
367
- window.open(href, '_blank');
368
- return href;
369
- };
370
-
371
389
  const copyAgentRunId = () => {
372
390
  navigator.clipboard.writeText(agentRunId);
373
391
  toast({
@@ -401,11 +419,6 @@ function MoreDropdown({
401
419
  }
402
420
  >
403
421
  <MenuGroup label="Actions">
404
- {isModal && (
405
- <MenuItem onClick={() => openUrl(`/store/agent-runner/${agentRunId}`)}>
406
- <ExternalLink className="size-3.5 text-muted" /> {t('agent.openInNewTab')}
407
- </MenuItem>
408
- )}
409
422
  <MenuItem onClick={copyAgentRunId}>
410
423
  <CopyIcon className="size-3.5 text-muted" /> {t('agent.copyAgentRunId')}
411
424
  </MenuItem>
@@ -0,0 +1,33 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { type AgentMessage, AgentMessageType } from '@vertesia/common';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { useDocumentPanel } from './useDocumentPanel';
5
+
6
+ function documentCreated(documentId: string, title: string): AgentMessage {
7
+ return {
8
+ type: AgentMessageType.UPDATE,
9
+ message: '',
10
+ timestamp: Date.now(),
11
+ details: { event_class: 'document_created', document_id: documentId, title },
12
+ } as unknown as AgentMessage;
13
+ }
14
+
15
+ describe('useDocumentPanel', () => {
16
+ it('opens the newest document reported by the agent', () => {
17
+ const messages = [documentCreated('doc-1', 'Brief'), documentCreated('doc-2', 'Summary')];
18
+ const { result } = renderHook(() => useDocumentPanel(messages));
19
+
20
+ expect(result.current.openDocuments.map((doc) => doc.id)).toEqual(['doc-1', 'doc-2']);
21
+ expect(result.current.activeDocumentId).toBe('doc-2');
22
+ expect(result.current.isDocPanelOpen).toBe(true);
23
+ });
24
+
25
+ it('selects null to go back to the list while keeping the documents open', () => {
26
+ const { result } = renderHook(() => useDocumentPanel([documentCreated('doc-1', 'Brief')]));
27
+
28
+ act(() => result.current.selectDocument(null));
29
+
30
+ expect(result.current.activeDocumentId).toBeNull();
31
+ expect(result.current.openDocuments).toHaveLength(1);
32
+ });
33
+ });
@@ -8,8 +8,8 @@ export interface UseDocumentPanelResult {
8
8
  isDocPanelOpen: boolean;
9
9
  docRefreshKey: number;
10
10
  closeDocPanel: () => void;
11
- closeDocument: (docId: string) => void;
12
- selectDocument: (docId: string) => void;
11
+ /** Select a document to display, or `null` to go back to the document list. */
12
+ selectDocument: (docId: string | null) => void;
13
13
  openDocInPanel: (docId: string) => void;
14
14
  updateDocumentTitle: (docId: string, title: string) => void;
15
15
  }
@@ -137,23 +137,7 @@ export function useDocumentPanel(messages: AgentMessage[]): UseDocumentPanelResu
137
137
  setIsDocPanelOpen(false);
138
138
  }, []);
139
139
 
140
- const closeDocument = useCallback((docId: string) => {
141
- setOpenDocuments((prev) => {
142
- const next = prev.filter((d) => d.id !== docId);
143
- if (next.length === 0) {
144
- setIsDocPanelOpen(false);
145
- setActiveDocumentId(null);
146
- } else {
147
- setActiveDocumentId((current) => {
148
- if (current === docId) return next[0].id;
149
- return current;
150
- });
151
- }
152
- return next;
153
- });
154
- }, []);
155
-
156
- const selectDocument = useCallback((docId: string) => {
140
+ const selectDocument = useCallback((docId: string | null) => {
157
141
  setActiveDocumentId(docId);
158
142
  }, []);
159
143
 
@@ -176,7 +160,6 @@ export function useDocumentPanel(messages: AgentMessage[]): UseDocumentPanelResu
176
160
  isDocPanelOpen,
177
161
  docRefreshKey,
178
162
  closeDocPanel,
179
- closeDocument,
180
163
  selectDocument,
181
164
  openDocInPanel,
182
165
  updateDocumentTitle,
@@ -47,8 +47,23 @@ describe('MCP connection controls', () => {
47
47
  manifest: {
48
48
  name: 'mcp-app',
49
49
  title: 'MCP App',
50
- oauth_providers: {},
51
50
  tool_collections: [
51
+ {
52
+ type: 'mcp',
53
+ id: 'service-account',
54
+ name: 'Service Account',
55
+ namespace: 'service_account',
56
+ url: 'https://service-account-mcp.example.com',
57
+ oauth_provider: 'service-account',
58
+ },
59
+ {
60
+ type: 'mcp',
61
+ id: 'service-calendar',
62
+ name: 'Service Calendar',
63
+ namespace: 'service_calendar',
64
+ url: 'https://service-calendar-mcp.example.com',
65
+ oauth_provider: 'service-account',
66
+ },
52
67
  {
53
68
  type: 'mcp',
54
69
  id: 'jira',
@@ -86,11 +101,23 @@ describe('MCP connection controls', () => {
86
101
  auth: 'api_key',
87
102
  },
88
103
  ],
104
+ oauth_providers: {
105
+ 'service-account': {
106
+ display_name: 'Service Account OAuth',
107
+ grant_type: 'client_credentials',
108
+ },
109
+ },
89
110
  },
90
- oauth_collection_ids: ['jira', 'miro', 'linear', 'github'],
111
+ oauth_collection_ids: ['jira', 'miro', 'linear', 'github', 'service-account', 'service-calendar'],
91
112
  },
92
113
  ]);
93
114
  mocks.getStatus.mockResolvedValue([
115
+ {
116
+ authenticated: true,
117
+ collection_id: 'service-account',
118
+ collection_name: 'Service Account',
119
+ mcp_server_url: 'https://service-account-mcp.example.com',
120
+ },
94
121
  {
95
122
  authenticated: true,
96
123
  collection_id: 'jira',
@@ -124,7 +151,9 @@ describe('MCP connection controls', () => {
124
151
  ]);
125
152
  const onChange = vi.fn();
126
153
 
127
- renderWithProviders(<McpConnectionsInlineList disabledCollections={['linear']} onChange={onChange} />);
154
+ renderWithProviders(
155
+ <McpConnectionsInlineList disabledCollections={['linear', 'service-calendar']} onChange={onChange} />,
156
+ );
128
157
 
129
158
  await screen.findByText('Jira');
130
159
  await waitFor(() => expect(mocks.getStatus).toHaveBeenCalledTimes(1));
@@ -133,16 +162,25 @@ describe('MCP connection controls', () => {
133
162
  expect(screen.getByText('Linear')).not.toBeNull();
134
163
  expect(screen.getByText('GitHub')).not.toBeNull();
135
164
  expect(screen.getByText('API Key Tools')).not.toBeNull();
165
+ expect(screen.getByText('Service Account')).not.toBeNull();
166
+ expect(screen.getByText('Service Calendar')).not.toBeNull();
167
+ expect(screen.queryByText('Service Account OAuth')).toBeNull();
136
168
  const apiKeyBadge = screen.getByText('API key');
137
169
  expect(apiKeyBadge.className).toContain('w-32');
170
+ const managedByAppBadges = screen.getAllByText('Managed by app');
171
+ expect(managedByAppBadges).toHaveLength(2);
172
+ expect(managedByAppBadges[0].className).toContain('w-32');
138
173
  expect(screen.queryByText('Connected')).toBeNull();
139
174
  expect(screen.getAllByRole('button', { name: 'Disconnect' })).toHaveLength(3);
140
175
  const connectButton = screen.getByRole('button', { name: 'Connect' });
141
176
  const disconnectButton = screen.getAllByRole('button', { name: 'Disconnect' })[0];
142
177
  expect(connectButton.className).toContain('w-32');
143
178
  expect(disconnectButton.className).toContain('w-32');
144
- expect(screen.getAllByText('Enabled')).toHaveLength(4);
145
- expect(screen.getByText('Disabled')).not.toBeNull();
179
+ expect(screen.getAllByText('Enabled')).toHaveLength(5);
180
+ expect(screen.getAllByText('Disabled')).toHaveLength(2);
181
+
182
+ fireEvent.click(screen.getByRole('switch', { name: 'Activate Service Calendar for this conversation' }));
183
+ expect(onChange).toHaveBeenCalledWith(['linear']);
146
184
  });
147
185
 
148
186
  it('renders MCP rows before connection statuses finish loading', async () => {
@@ -207,8 +245,29 @@ describe('MCP connection controls', () => {
207
245
  manifest: {
208
246
  name: 'jira-app',
209
247
  title: 'Jira',
210
- oauth_providers: {},
248
+ oauth_providers: {
249
+ 'service-account': {
250
+ display_name: 'Service Account OAuth',
251
+ grant_type: 'client_credentials',
252
+ },
253
+ },
211
254
  tool_collections: [
255
+ {
256
+ type: 'mcp',
257
+ id: 'service-account',
258
+ name: 'Service Account',
259
+ namespace: 'service_account',
260
+ url: 'https://service-account-mcp.example.com',
261
+ oauth_provider: 'service-account',
262
+ },
263
+ {
264
+ type: 'mcp',
265
+ id: 'service-calendar',
266
+ name: 'Service Calendar',
267
+ namespace: 'service_calendar',
268
+ url: 'https://service-calendar-mcp.example.com',
269
+ oauth_provider: 'service-account',
270
+ },
212
271
  {
213
272
  type: 'mcp',
214
273
  id: 'jira',
@@ -225,10 +284,16 @@ describe('MCP connection controls', () => {
225
284
  },
226
285
  ],
227
286
  },
228
- oauth_collection_ids: ['jira', 'github'],
287
+ oauth_collection_ids: ['jira', 'github', 'service-account', 'service-calendar'],
229
288
  },
230
289
  ]);
231
290
  mocks.getStatus.mockResolvedValue([
291
+ {
292
+ authenticated: true,
293
+ collection_id: 'service-account',
294
+ collection_name: 'Service Account',
295
+ mcp_server_url: 'https://service-account-mcp.example.com',
296
+ },
232
297
  {
233
298
  authenticated: true,
234
299
  collection_id: 'jira',
@@ -254,12 +319,18 @@ describe('MCP connection controls', () => {
254
319
 
255
320
  const menuItem = await screen.findByText('MCP');
256
321
  expect(menuItem).not.toBeNull();
257
- expect(screen.getByText('1')).not.toBeNull();
322
+ expect(screen.getByText('3')).not.toBeNull();
258
323
 
259
324
  fireEvent.click(menuItem);
260
325
 
261
326
  await waitFor(() => {
262
327
  expect(screen.getByText('Manage the MCP servers available to this conversation.')).not.toBeNull();
263
328
  });
329
+ expect(screen.getByText('Service Account')).not.toBeNull();
330
+ expect(screen.getByText('Service Calendar')).not.toBeNull();
331
+ expect(screen.queryByText('Service Account OAuth')).toBeNull();
332
+ expect(screen.getAllByText('Managed by app')).toHaveLength(2);
333
+ expect(screen.getAllByRole('button', { name: 'Disconnect' })).toHaveLength(1);
334
+ expect(screen.getAllByRole('button', { name: 'Connect' })).toHaveLength(1);
264
335
  });
265
336
  });
@@ -128,6 +128,7 @@ export function McpConnectionsInlineList({
128
128
  {groups.map((group) => {
129
129
  const active = !isGroupDisabled(group, disabledCollections);
130
130
  const connected = group.authStatus?.authenticated === true;
131
+ const interactiveOAuth = group.authType === 'oauth' && group.oauthGrantType !== 'client_credentials';
131
132
  const StatusIcon = connected ? Link2 : Link2Off;
132
133
  return (
133
134
  <div key={group.key} className="flex flex-wrap items-center gap-x-3 gap-y-2 py-2 text-sm">
@@ -155,7 +156,7 @@ export function McpConnectionsInlineList({
155
156
  </span>
156
157
  </VTooltip>
157
158
  </div>
158
- {statusLoading && !group.authStatus ? (
159
+ {interactiveOAuth && statusLoading && !group.authStatus ? (
159
160
  <Button
160
161
  variant="outline"
161
162
  size="sm"
@@ -165,7 +166,7 @@ export function McpConnectionsInlineList({
165
166
  >
166
167
  <Spinner className="size-3" />
167
168
  </Button>
168
- ) : group.authType === 'oauth' ? (
169
+ ) : interactiveOAuth ? (
169
170
  <RemoteMcpConnectionButton
170
171
  appId={group.appId}
171
172
  collectionId={group.representativeId}
@@ -178,7 +179,9 @@ export function McpConnectionsInlineList({
178
179
  />
179
180
  ) : (
180
181
  <Badge variant="outline" className="h-6 w-32 justify-center px-2 text-xs">
181
- {t('mcpConnections.apiKey')}
182
+ {group.authType === 'api_key'
183
+ ? t('mcpConnections.apiKey')
184
+ : t('mcpConnections.managedByApp')}
182
185
  </Badge>
183
186
  )}
184
187
  {onChange && (
@@ -1,6 +1,6 @@
1
1
  import { useUITranslation } from '@vertesia/ui/i18n';
2
2
  import { Link2, Link2Off } from 'lucide-react';
3
- import { Button, Modal, ModalBody, ModalTitle, Spinner, Switch, VTooltip } from '../../core/index.js';
3
+ import { Badge, Button, Modal, ModalBody, ModalTitle, Spinner, Switch, VTooltip } from '../../core/index.js';
4
4
  import { RemoteMcpConnectionButton } from './RemoteMcpConnectionButton.js';
5
5
  import { isGroupDisabled, type McpConnectionGroup, toggleGroupDisabled } from './useMcpConnections.js';
6
6
 
@@ -62,6 +62,8 @@ export function McpConnectionsDialog({
62
62
  {groups.map((group) => {
63
63
  const active = !isGroupDisabled(group, disabledCollections);
64
64
  const connected = group.authStatus?.authenticated === true;
65
+ const interactiveOAuth =
66
+ group.authType === 'oauth' && group.oauthGrantType !== 'client_credentials';
65
67
  const StatusIcon = connected ? Link2 : Link2Off;
66
68
  return (
67
69
  <div key={group.key} className="flex items-center justify-between gap-3 py-3">
@@ -92,7 +94,7 @@ export function McpConnectionsDialog({
92
94
  </VTooltip>
93
95
  </div>
94
96
  <div className="flex shrink-0 items-center gap-3">
95
- {statusLoading && !group.authStatus ? (
97
+ {interactiveOAuth && statusLoading && !group.authStatus ? (
96
98
  <Button
97
99
  variant="outline"
98
100
  size="sm"
@@ -102,7 +104,7 @@ export function McpConnectionsDialog({
102
104
  >
103
105
  <Spinner className="size-3" />
104
106
  </Button>
105
- ) : group.authType === 'oauth' ? (
107
+ ) : interactiveOAuth ? (
106
108
  <RemoteMcpConnectionButton
107
109
  appId={group.appId}
108
110
  collectionId={group.representativeId}
@@ -113,6 +115,10 @@ export function McpConnectionsDialog({
113
115
  showDisconnect
114
116
  readOnly={readOnly}
115
117
  />
118
+ ) : group.authType === 'oauth' ? (
119
+ <Badge variant="outline" className="h-6 w-32 justify-center px-2 text-xs">
120
+ {t('mcpConnections.managedByApp')}
121
+ </Badge>
116
122
  ) : null}
117
123
  {onChange && (
118
124
  <Switch
@@ -7,22 +7,21 @@ import {
7
7
  import { useUserSession } from '@vertesia/ui/session';
8
8
  import { useCallback, useEffect, useRef, useState } from 'react';
9
9
 
10
- /**
11
- * A group of MCP tool collections that share authentication and activation state.
12
- */
10
+ /** A visible MCP collection row. OAuth authentication may be shared with sibling rows. */
13
11
  export interface McpConnectionGroup {
14
12
  key: string;
15
13
  appId: string;
16
14
  appName: string;
17
- /** Display label: provider display_name, oauth_app name, or individual collection name */
15
+ /** Display label for the MCP collection. */
18
16
  label: string;
19
- /** Representative collection ID used for status and, for OAuth groups, connection operations. */
17
+ /** Collection ID used for status and OAuth operations; shared when collections use the same provider. */
20
18
  representativeId: string;
21
- /** All collection IDs in this group (used for per-conversation activation toggles) */
19
+ /** Collection IDs controlled by this row's per-conversation activation toggle. */
22
20
  memberIds: string[];
23
- /** Names of all collections in this group (for tooltip when > 1) */
21
+ /** Collection names represented by this row. */
24
22
  memberNames: string[];
25
23
  authType: 'oauth' | 'api_key';
24
+ oauthGrantType?: 'authorization_code' | 'client_credentials';
26
25
  authStatus?: OAuthAuthStatus;
27
26
  }
28
27
 
@@ -80,29 +79,36 @@ export function useMcpConnections() {
80
79
  }
81
80
 
82
81
  for (const [providerKey, cols] of providerMap) {
83
- allGroups.push({
84
- key: `${inst.id}:provider:${providerKey}`,
85
- appId: inst.id,
86
- appName: inst.manifest.title || inst.manifest.name,
87
- label: manifestProviders[providerKey]?.display_name || providerKey,
88
- representativeId: cols[0].id,
89
- memberIds: cols.map((c) => c.id),
90
- memberNames: cols.map((c) => c.name),
91
- authType: 'oauth',
92
- });
82
+ const representativeId = cols[0].id;
83
+ for (const col of cols) {
84
+ allGroups.push({
85
+ key: `${inst.id}:provider:${providerKey}:${col.id}`,
86
+ appId: inst.id,
87
+ appName: inst.manifest.title || inst.manifest.name,
88
+ label: col.name,
89
+ representativeId,
90
+ memberIds: [col.id],
91
+ memberNames: [col.name],
92
+ authType: 'oauth',
93
+ oauthGrantType: manifestProviders[providerKey]?.grant_type,
94
+ });
95
+ }
93
96
  }
94
97
 
95
98
  for (const [oauthApp, cols] of oauthAppMap) {
96
- allGroups.push({
97
- key: `${inst.id}:oauthapp:${oauthApp}`,
98
- appId: inst.id,
99
- appName: inst.manifest.title || inst.manifest.name,
100
- label: oauthApp,
101
- representativeId: cols[0].id,
102
- memberIds: cols.map((c) => c.id),
103
- memberNames: cols.map((c) => c.name),
104
- authType: 'oauth',
105
- });
99
+ const representativeId = cols[0].id;
100
+ for (const col of cols) {
101
+ allGroups.push({
102
+ key: `${inst.id}:oauthapp:${oauthApp}:${col.id}`,
103
+ appId: inst.id,
104
+ appName: inst.manifest.title || inst.manifest.name,
105
+ label: col.name,
106
+ representativeId,
107
+ memberIds: [col.id],
108
+ memberNames: [col.name],
109
+ authType: 'oauth',
110
+ });
111
+ }
106
112
  }
107
113
 
108
114
  for (const col of individualCols) {