@vertesia/ui 1.6.0-dev.20260912.110338Z → 1.6.0-dev.20260918.061604Z

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 (82) hide show
  1. package/lib/env/index.d.ts +4 -0
  2. package/lib/env/index.d.ts.map +1 -1
  3. package/lib/env/index.js +4 -0
  4. package/lib/env/index.js.map +1 -1
  5. package/lib/features/agent/PayloadBuilder.d.ts.map +1 -1
  6. package/lib/features/agent/PayloadBuilder.js +6 -1
  7. package/lib/features/agent/PayloadBuilder.js.map +1 -1
  8. package/lib/features/agent/chat/AgentRunFeedback.d.ts +7 -0
  9. package/lib/features/agent/chat/AgentRunFeedback.d.ts.map +1 -1
  10. package/lib/features/agent/chat/AgentRunFeedback.js +13 -1
  11. package/lib/features/agent/chat/AgentRunFeedback.js.map +1 -1
  12. package/lib/features/agent/chat/ModernAgentConversation.js +4 -3
  13. package/lib/features/agent/chat/ModernAgentConversation.js.map +1 -1
  14. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +2 -2
  15. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
  16. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js +2 -2
  17. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js.map +1 -1
  18. package/lib/features/agent/chat/hooks/useAgentPlans.d.ts +1 -1
  19. package/lib/features/agent/chat/hooks/useAgentPlans.d.ts.map +1 -1
  20. package/lib/features/agent/chat/hooks/useAgentPlans.js +5 -5
  21. package/lib/features/agent/chat/hooks/useAgentPlans.js.map +1 -1
  22. package/lib/features/store/objects/search/DocumentSearchContext.d.ts.map +1 -1
  23. package/lib/features/store/objects/search/DocumentSearchContext.js +6 -1
  24. package/lib/features/store/objects/search/DocumentSearchContext.js.map +1 -1
  25. package/lib/features/user/UserInfo.d.ts +2 -0
  26. package/lib/features/user/UserInfo.d.ts.map +1 -1
  27. package/lib/session/UserSessionProvider.js +3 -3
  28. package/lib/session/UserSessionProvider.js.map +1 -1
  29. package/lib/session/auth/firebase.d.ts.map +1 -1
  30. package/lib/session/auth/firebase.js +10 -0
  31. package/lib/session/auth/firebase.js.map +1 -1
  32. package/lib/shell/login/SignInAuthPending.js +1 -1
  33. package/lib/shell/login/SignInAuthPending.js.map +1 -1
  34. package/lib/shell/login/SignInEmailStep.d.ts.map +1 -1
  35. package/lib/shell/login/SignInEmailStep.js +2 -2
  36. package/lib/shell/login/SignInEmailStep.js.map +1 -1
  37. package/lib/shell/login/SignInIcons.d.ts +1 -1
  38. package/lib/shell/login/SignInIcons.d.ts.map +1 -1
  39. package/lib/shell/login/signInUtils.d.ts +1 -1
  40. package/lib/shell/login/signInUtils.d.ts.map +1 -1
  41. package/lib/shell/login/signInUtils.js +5 -6
  42. package/lib/shell/login/signInUtils.js.map +1 -1
  43. package/lib/vertesia-ui-core.js +83 -41
  44. package/lib/vertesia-ui-core.js.map +1 -1
  45. package/lib/vertesia-ui-env.js +1 -1
  46. package/lib/vertesia-ui-env.js.map +1 -1
  47. package/lib/vertesia-ui-features.js +336 -317
  48. package/lib/vertesia-ui-features.js.map +1 -1
  49. package/lib/vertesia-ui-layout.js +43 -19
  50. package/lib/vertesia-ui-layout.js.map +1 -1
  51. package/lib/vertesia-ui-rich-text.js +123 -74
  52. package/lib/vertesia-ui-rich-text.js.map +1 -1
  53. package/lib/vertesia-ui-router.js.map +1 -1
  54. package/lib/vertesia-ui-session.js +1 -1
  55. package/lib/vertesia-ui-session.js.map +1 -1
  56. package/lib/vertesia-ui-shell.js +57 -28
  57. package/lib/vertesia-ui-shell.js.map +1 -1
  58. package/lib/vertesia-ui-widgets.js +153 -97
  59. package/lib/vertesia-ui-widgets.js.map +1 -1
  60. package/lib/widgets/xml-viewer/hooks/useCollapsible.d.ts +1 -1
  61. package/package.json +20 -20
  62. package/src/env/index.test.ts +27 -0
  63. package/src/env/index.ts +7 -0
  64. package/src/features/agent/PayloadBuilder.tsx +6 -1
  65. package/src/features/agent/chat/AgentRunFeedback.test.tsx +18 -1
  66. package/src/features/agent/chat/AgentRunFeedback.tsx +13 -0
  67. package/src/features/agent/chat/ModernAgentConversation.test.tsx +1 -1
  68. package/src/features/agent/chat/ModernAgentConversation.tsx +6 -5
  69. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +2 -2
  70. package/src/features/agent/chat/ModernAgentOutput/MessageItem.tsx +2 -2
  71. package/src/features/agent/chat/hooks/useAgentPlans.test.ts +52 -0
  72. package/src/features/agent/chat/hooks/useAgentPlans.ts +5 -5
  73. package/src/features/store/objects/search/DocumentSearchContext.ts +8 -3
  74. package/src/router/path.test.ts +5 -5
  75. package/src/session/UserSessionProvider.tsx +3 -3
  76. package/src/session/auth/firebase.test.ts +25 -0
  77. package/src/session/auth/firebase.ts +10 -0
  78. package/src/shell/login/SignInAuthPending.tsx +3 -3
  79. package/src/shell/login/SignInEmailStep.test.tsx +32 -0
  80. package/src/shell/login/SignInEmailStep.tsx +13 -9
  81. package/src/shell/login/signInUtils.ts +12 -4
  82. package/src/widgets/markdown/CollaborativeMarkdownRenderer.test.tsx +11 -3
@@ -1,8 +1,8 @@
1
1
  export declare function useCollapsible(level: number): {
2
2
  collapsed: boolean;
3
3
  buttonProps: {
4
- role?: undefined;
5
4
  onClick?: undefined;
5
+ role?: undefined;
6
6
  style?: undefined;
7
7
  } | {
8
8
  onClick: () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertesia/ui",
3
- "version": "1.6.0-dev.20260912.110338Z",
3
+ "version": "1.6.0-dev.20260918.061604Z",
4
4
  "description": "Vertesia UI components and and hooks",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -45,11 +45,11 @@
45
45
  "@radix-ui/react-switch": "^1.3.7",
46
46
  "@radix-ui/react-tabs": "^1.1.21",
47
47
  "@radix-ui/react-tooltip": "^1.2.16",
48
- "@vertesia/client": "1.6.0-dev.20260912.110338Z",
49
- "@vertesia/common": "1.6.0-dev.20260912.110338Z",
50
- "@vertesia/fusion-ux": "1.6.0-dev.20260912.110338Z",
51
- "@vertesia/json": "1.6.0-dev.20260912.110338Z",
52
- "@vertesia/rich-text": "1.6.0-dev.20260912.110338Z",
48
+ "@vertesia/client": "1.6.0-dev.20260918.061604Z",
49
+ "@vertesia/common": "1.6.0-dev.20260918.061604Z",
50
+ "@vertesia/fusion-ux": "1.6.0-dev.20260918.061604Z",
51
+ "@vertesia/json": "1.6.0-dev.20260918.061604Z",
52
+ "@vertesia/rich-text": "1.6.0-dev.20260918.061604Z",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
55
55
  "aria-hidden": "^1.2.6",
@@ -58,30 +58,30 @@
58
58
  "cmdk": "^1.1.1",
59
59
  "dayjs": "^1.11.23",
60
60
  "debounce": "^3.0.0",
61
- "dompurify": "^3.4.14",
61
+ "dompurify": "^3.4.15",
62
62
  "fast-xml-parser": "^5.11.1",
63
63
  "firebase": "^12.18.0",
64
- "framer-motion": "^13.1.1",
65
- "i18next": "^26.4.1",
64
+ "framer-motion": "^13.2.0",
65
+ "i18next": "^26.4.2",
66
66
  "jwt-decode": "^4.0.0",
67
- "katex": "^0.18.5",
67
+ "katex": "^0.18.7",
68
68
  "lodash-es": "^4.18.1",
69
- "lucide-react": "^1.38.0",
69
+ "lucide-react": "^1.42.0",
70
70
  "mermaid": "^11.17.2",
71
71
  "monaco-editor": "^0.56.0",
72
- "motion": "^13.1.1",
72
+ "motion": "^13.2.0",
73
73
  "papaparse": "^5.7.0",
74
74
  "react": "19.2.8",
75
75
  "react-calendar": "^6.0.1",
76
76
  "react-date-picker": "^12.1.0",
77
77
  "react-dom": "19.2.8",
78
- "react-error-boundary": "^6.1.4",
79
- "react-i18next": "^17.0.12",
78
+ "react-error-boundary": "^6.1.5",
79
+ "react-i18next": "^17.0.13",
80
80
  "react-markdown": "^10.1.0",
81
81
  "react-pdf": "^10.5.0",
82
82
  "react-remove-scroll": "^2.7.2",
83
83
  "react-remove-scroll-bar": "^2.3.8",
84
- "react-resizable-panels": "^4.12.3",
84
+ "react-resizable-panels": "^4.12.4",
85
85
  "react-style-singleton": "^2.2.3",
86
86
  "react-vega": "^8.0.0",
87
87
  "rehype-katex": "^7.0.1",
@@ -106,16 +106,16 @@
106
106
  "@types/node": "^24.13.3",
107
107
  "@types/papaparse": "^5.5.2",
108
108
  "@types/react": "19.2.18",
109
- "@types/react-dom": "19.2.5",
109
+ "@types/react-dom": "19.2.7",
110
110
  "@vertesia/tsconfig": "0.1.0",
111
111
  "axe-core": "^4.13.0",
112
112
  "culori": "^4.0.2",
113
- "i18next-cli": "^1.72.2",
113
+ "i18next-cli": "^1.73.1",
114
114
  "jsdom": "^30.0.1",
115
- "rolldown": "1.2.6",
115
+ "rolldown": "1.2.8",
116
116
  "tailwindcss": "^4.3.3",
117
117
  "typescript": "7.0.2",
118
- "vitest": "^4.1.11",
118
+ "vitest": "5.0.0",
119
119
  "vitest-axe": "^0.1.0",
120
120
  "wcag-contrast": "^3.0.0"
121
121
  },
@@ -188,7 +188,7 @@
188
188
  "url": "https://github.com/vertesia/composableai.git",
189
189
  "directory": "packages/ui"
190
190
  },
191
- "gitHead": "504e8df031c3950ec324c865385caf460b1c1cd7",
191
+ "gitHead": "ea552337206e77c256478b76e19b4235e70bf769",
192
192
  "scripts": {
193
193
  "clean:lib": "rimraf ./lib ./tsconfig.tsbuildinfo",
194
194
  "clean": "rimraf ./node_modules ./lib ./tsconfig.tsbuildinfo",
@@ -192,3 +192,30 @@ describe('build-time default workspace', () => {
192
192
  expect(env.defaultAuthSelection).toEqual({ accountId: 'explicit' });
193
193
  });
194
194
  });
195
+
196
+ describe('fixed Firebase tenant', () => {
197
+ afterEach(() => vi.unstubAllGlobals());
198
+
199
+ it('combines an app tenant with gateway credentials', () => {
200
+ vi.stubGlobal('window', {
201
+ __VERTESIA_RUNTIME_CONFIG__: {
202
+ authMode: 'firebase',
203
+ firebase: {
204
+ apiKey: 'gateway',
205
+ authDomain: 'app.example.com',
206
+ projectId: 'project',
207
+ appId: 'app',
208
+ },
209
+ },
210
+ });
211
+ const env = new VertesiaEnvironment().init(baseProps, { VITE_FIREBASE_TENANT_ID: ' example-tenant ' });
212
+ expect(env.firebase?.tenantId).toBe('example-tenant');
213
+ expect(env.firebase?.apiKey).toBe('gateway');
214
+ });
215
+
216
+ it('does not enable Firebase just because a tenant is supplied', () => {
217
+ vi.stubGlobal('window', {});
218
+ const env = new VertesiaEnvironment().init(baseProps, { VITE_FIREBASE_TENANT_ID: 'tenant' });
219
+ expect(env.firebase).toBeUndefined();
220
+ });
221
+ });
package/src/env/index.ts CHANGED
@@ -34,6 +34,8 @@ export interface EnvProps {
34
34
  projectId: string;
35
35
  appId?: string;
36
36
  providerType?: string;
37
+ /** Fixed Identity Platform tenant, preserved throughout sign-in. */
38
+ tenantId?: string;
37
39
  };
38
40
  /** Default workspace selection when the URL does not explicitly select an account or project. */
39
41
  defaultAuthSelection?: { accountId?: string; projectId?: string };
@@ -139,6 +141,11 @@ export class VertesiaEnvironment implements Readonly<EnvProps> {
139
141
  const runtimeConfig = injectedRuntimeConfig() ?? buildRuntimeConfig(buildEnv);
140
142
  const runtimeFirebase = runtimeConfig?.authMode === 'firebase' ? runtimeConfig.firebase : undefined;
141
143
  this._props = props && runtimeFirebase && !props.firebase ? { ...props, firebase: runtimeFirebase } : props;
144
+ const tenantId =
145
+ typeof buildEnv?.VITE_FIREBASE_TENANT_ID === 'string' ? buildEnv.VITE_FIREBASE_TENANT_ID.trim() : '';
146
+ if (this._props?.firebase && tenantId && !this._props.firebase.tenantId) {
147
+ this._props = { ...this._props, firebase: { ...this._props.firebase, tenantId } };
148
+ }
142
149
  if (this._props && this._props.defaultAuthSelection === undefined && buildEnv) {
143
150
  const accountId =
144
151
  typeof buildEnv.VITE_VERTESIA_ACCOUNT_ID === 'string'
@@ -332,7 +332,12 @@ export class PayloadBuilder {
332
332
  this._model_options = interaction.model_options as ModelOptions | undefined;
333
333
  if (interaction.runtime?.environment) {
334
334
  const envId = interaction.runtime.environment;
335
- this.vertesia.environments.retrieve(envId).then((environment) => (this.environment = environment));
335
+ this.vertesia.environments
336
+ .retrieve(envId)
337
+ .then((environment) => (this.environment = environment))
338
+ .catch(() => {
339
+ this.environment = undefined;
340
+ });
336
341
  }
337
342
  }
338
343
  this.onStateChanged();
@@ -1,8 +1,14 @@
1
1
  import { fireEvent, screen, waitFor } from '@testing-library/react';
2
2
  import type { AgentRunFeedbackEntry, AgentRunFeedbackPayload, AgentRunFeedbackStatus } from '@vertesia/common';
3
+ import { AgentMessageType } from '@vertesia/common';
3
4
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
5
  import { renderWithProviders } from '../../../__tests__/test-utils.js';
5
- import { AgentRunFeedback, AgentRunFeedbackProvider, agentRunFeedbackReasonCodes } from './AgentRunFeedback';
6
+ import {
7
+ AgentRunFeedback,
8
+ AgentRunFeedbackProvider,
9
+ agentRunFeedbackReasonCodes,
10
+ isAgentMessageRatable,
11
+ } from './AgentRunFeedback';
6
12
 
7
13
  const mocks = vi.hoisted(() => {
8
14
  const recordFeedback = vi.fn();
@@ -38,6 +44,17 @@ function payloadOf(call: number): AgentRunFeedbackPayload {
38
44
  return mocks.recordFeedback.mock.calls[call][1] as AgentRunFeedbackPayload;
39
45
  }
40
46
 
47
+ describe('isAgentMessageRatable', () => {
48
+ it('offers a rating on a final answer only, never on a streaming placeholder', () => {
49
+ expect(isAgentMessageRatable({ type: AgentMessageType.ANSWER, details: { streamed: true } })).toBe(true);
50
+ expect(isAgentMessageRatable({ type: AgentMessageType.ANSWER, details: undefined })).toBe(true);
51
+ expect(isAgentMessageRatable({ type: AgentMessageType.ANSWER, details: { source: 'streaming_summary' } })).toBe(
52
+ false,
53
+ );
54
+ expect(isAgentMessageRatable({ type: AgentMessageType.THOUGHT, details: undefined })).toBe(false);
55
+ });
56
+ });
57
+
41
58
  describe('AgentRunFeedback', () => {
42
59
  beforeEach(() => {
43
60
  mocks.recordFeedback.mockReset();
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  AGENT_RUN_FEEDBACK_COMMENT_MAX_LENGTH,
3
3
  type AgentMessage,
4
+ AgentMessageType,
4
5
  type AgentRunFeedbackEntry,
5
6
  type AgentRunFeedbackPayload,
6
7
  type AgentRunFeedbackRating,
@@ -61,6 +62,18 @@ export function agentMessageFeedbackId(message: Pick<AgentMessage, 'workstream_i
61
62
  return `${message.workstream_id || 'main'}:${message.timestamp}`;
62
63
  }
63
64
 
65
+ /**
66
+ * Whether a transcript message can carry a rating. Only a final answer can: while an answer is
67
+ * still streaming, the transcript shows a placeholder stamped with the browser clock, and a
68
+ * rating keyed to that timestamp would never match the answer the worker eventually posts, so
69
+ * the same answer would end up rated twice.
70
+ */
71
+ export function isAgentMessageRatable(message: Pick<AgentMessage, 'type' | 'details'>): boolean {
72
+ if (message.type !== AgentMessageType.ANSWER) return false;
73
+ const details = message.details as { source?: unknown } | undefined;
74
+ return details?.source !== 'streaming_summary';
75
+ }
76
+
64
77
  /** Idempotency key for one submission; the server counts a retried id once. */
65
78
  function newFeedbackId(): string {
66
79
  if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID();
@@ -1278,7 +1278,7 @@ describe('ModernAgentConversation send handling', () => {
1278
1278
  expect.objectContaining({ workstream_id: 'beta', status: 'running' }),
1279
1279
  expect.objectContaining({ workstream_id: 'gamma', status: 'completed' }),
1280
1280
  ]);
1281
- expect(latestRightPanelProps.activeTab).toBe('plan');
1281
+ expect(latestRightPanelProps.activeTab).toBe('workstreams');
1282
1282
  expect(latestMessageInputProps.activeTaskCount).toBe(2);
1283
1283
  expect(latestMessageInputProps.activeWorkstreams).toEqual([
1284
1284
  expect.objectContaining({ workstream_id: 'alpha', status: 'running' }),
@@ -1554,7 +1554,7 @@ function ModernAgentConversationInner({
1554
1554
  showInput,
1555
1555
  showSlidingPanel,
1556
1556
  setShowSlidingPanel,
1557
- } = useAgentPlans(messages, interactive, isModal);
1557
+ } = useAgentPlans(messages, interactive);
1558
1558
 
1559
1559
  const {
1560
1560
  openDocuments,
@@ -1938,9 +1938,10 @@ function ModernAgentConversationInner({
1938
1938
  // Unified right panel state
1939
1939
  // ────────────────────────────────────────────
1940
1940
  type RightPanelTab = 'plan' | 'workstreams' | 'documents' | 'uploads' | 'artifacts' | 'payload' | 'conversation';
1941
- const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
1942
- conversationContent || conversationTab ? 'conversation' : 'plan',
1941
+ const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab | undefined>(
1942
+ conversationContent || conversationTab ? 'conversation' : undefined,
1943
1943
  );
1944
+ const defaultRightPanelTab = plans.length > 0 || panelWorkstreams.length === 0 ? 'plan' : 'workstreams';
1944
1945
  const [selectedArtifactPath, setSelectedArtifactPath] = useState<string | null>(null);
1945
1946
  const [rightPanelWidth, setRightPanelWidth] = useState(400);
1946
1947
  const [isRightPanelResizing, setIsRightPanelResizing] = useState(false);
@@ -3085,8 +3086,8 @@ function ModernAgentConversationInner({
3085
3086
  conversationContent={conversationTab ? conversationAreaJsx : conversationContent}
3086
3087
  // Panel control
3087
3088
  onClose={handleCloseRightPanel}
3088
- defaultTab={rightPanelTab}
3089
- activeTab={rightPanelTab}
3089
+ defaultTab={rightPanelTab ?? defaultRightPanelTab}
3090
+ activeTab={rightPanelTab ?? defaultRightPanelTab}
3090
3091
  onTabChange={setRightPanelTab}
3091
3092
  />
3092
3093
  </div>
@@ -29,7 +29,7 @@ import {
29
29
  Wrench,
30
30
  } from 'lucide-react';
31
31
  import React, { Component, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
32
- import { AgentRunFeedback, agentMessageFeedbackId } from '../AgentRunFeedback';
32
+ import { AgentRunFeedback, agentMessageFeedbackId, isAgentMessageRatable } from '../AgentRunFeedback';
33
33
  import { AnimatedThinkingDots, PulsatingCircle } from '../AnimatedThinkingDots';
34
34
  import { AskUserWidget, isAskUserOptions } from '../AskUserWidget';
35
35
  import { DocumentEditingActionCard, parseMarkdownEditingAction } from '../DocumentEditingActionCard.js';
@@ -823,7 +823,7 @@ function SummaryMessage({
823
823
  </MarkdownRenderer>
824
824
  </div>
825
825
  )}
826
- {feedbackAgentRunId && message.type === AgentMessageType.ANSWER && (
826
+ {feedbackAgentRunId && isAgentMessageRatable(message) && (
827
827
  <AgentRunFeedback
828
828
  agentRunId={feedbackAgentRunId}
829
829
  messageId={agentMessageFeedbackId(message)}
@@ -27,7 +27,7 @@ import type React from 'react';
27
27
  import { memo, useEffect, useMemo, useRef, useState } from 'react';
28
28
  import dayjs from '../../../../core/utils/dayjs.js';
29
29
  import { useDownloadFile } from '../../../store/objects/components/useDownloadFile.js';
30
- import { AgentRunFeedback, agentMessageFeedbackId } from '../AgentRunFeedback';
30
+ import { AgentRunFeedback, agentMessageFeedbackId, isAgentMessageRatable } from '../AgentRunFeedback';
31
31
  import { PulsatingCircle } from '../AnimatedThinkingDots';
32
32
  import { AskUserWidget, isAskUserOptions } from '../AskUserWidget';
33
33
  import { DocumentEditingActionCard, parseMarkdownEditingAction } from '../DocumentEditingActionCard.js';
@@ -683,7 +683,7 @@ function MessageItemComponent({
683
683
  )
684
684
  )}
685
685
 
686
- {feedbackAgentRunId && message.type === AgentMessageType.ANSWER && (
686
+ {feedbackAgentRunId && isAgentMessageRatable(message) && (
687
687
  <AgentRunFeedback
688
688
  agentRunId={feedbackAgentRunId}
689
689
  messageId={agentMessageFeedbackId(message)}
@@ -0,0 +1,52 @@
1
+ import { act, cleanup, renderHook } from '@testing-library/react';
2
+ import { type AgentMessage, AgentMessageType } from '@vertesia/common';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import { useAgentPlans } from './useAgentPlans.js';
5
+
6
+ const question: AgentMessage = {
7
+ timestamp: 1,
8
+ workflow_run_id: 'run-1',
9
+ workstream_id: 'main',
10
+ type: AgentMessageType.QUESTION,
11
+ message: 'Help with a task',
12
+ };
13
+ const plan: AgentMessage = {
14
+ ...question,
15
+ timestamp: 2,
16
+ type: AgentMessageType.PLAN,
17
+ details: { plan: [{ id: 1, goal: 'Review the task', status: 'pending' }] },
18
+ };
19
+
20
+ describe('useAgentPlans sidebar visibility', () => {
21
+ beforeEach(() => sessionStorage.clear());
22
+ afterEach(cleanup);
23
+
24
+ it('stays closed while history loads and when history has no plan', () => {
25
+ const { result, rerender } = renderHook(({ messages }) => useAgentPlans(messages, true), {
26
+ initialProps: { messages: [] as AgentMessage[] },
27
+ });
28
+ expect(result.current.showSlidingPanel).toBe(false);
29
+ rerender({ messages: [question] });
30
+ expect(result.current.showSlidingPanel).toBe(false);
31
+ });
32
+
33
+ it('opens when a plan arrives and resets for a new empty run', () => {
34
+ const { result, rerender } = renderHook(({ messages }) => useAgentPlans(messages, true), {
35
+ initialProps: { messages: [question] },
36
+ });
37
+ rerender({ messages: [question, plan] });
38
+ expect(result.current.showSlidingPanel).toBe(true);
39
+ expect(result.current.plans).toHaveLength(1);
40
+ rerender({ messages: [] });
41
+ expect(result.current.showSlidingPanel).toBe(false);
42
+ });
43
+
44
+ it('preserves manual opening when subsequent messages have no plan', () => {
45
+ const { result, rerender } = renderHook(({ messages }) => useAgentPlans(messages, true), {
46
+ initialProps: { messages: [question] },
47
+ });
48
+ act(() => result.current.setShowSlidingPanel(true));
49
+ rerender({ messages: [question, { ...question, timestamp: 3, type: AgentMessageType.ANSWER }] });
50
+ expect(result.current.showSlidingPanel).toBe(true);
51
+ });
52
+ });
@@ -18,14 +18,14 @@ export interface UseAgentPlansResult {
18
18
  * Key improvement: incremental processing. Instead of scanning ALL messages
19
19
  * on every change, tracks `lastProcessedIndex` and only scans new messages.
20
20
  */
21
- export function useAgentPlans(messages: AgentMessage[], interactive: boolean, isModal = false): UseAgentPlansResult {
21
+ export function useAgentPlans(messages: AgentMessage[], interactive: boolean): UseAgentPlansResult {
22
22
  const [plans, setPlans] = useState<Array<{ plan: Plan; timestamp: number }>>([]);
23
23
  const [activePlanIndex, setActivePlanIndex] = useState<number>(0);
24
24
  const [workstreamStatusMap, setWorkstreamStatusMap] = useState<
25
25
  Map<number, Map<string, 'pending' | 'in_progress' | 'completed' | 'skipped'>>
26
26
  >(new Map());
27
27
  const [showInput, setShowInput] = useState(interactive);
28
- const [showSlidingPanel, setShowSlidingPanel] = useState<boolean>(!isModal);
28
+ const [showSlidingPanel, setShowSlidingPanel] = useState<boolean>(false);
29
29
 
30
30
  // Incremental processing: track how far we've scanned
31
31
  const lastProcessedIndex = useRef<number>(-1);
@@ -38,12 +38,12 @@ export function useAgentPlans(messages: AgentMessage[], interactive: boolean, is
38
38
  setPlans([]);
39
39
  setActivePlanIndex(0);
40
40
  setWorkstreamStatusMap(new Map());
41
- // Keep right panel open by default on desktop sessions.
42
- setShowSlidingPanel(!isModal);
41
+ // Open automatically only once a plan is available.
42
+ setShowSlidingPanel(false);
43
43
  lastProcessedIndex.current = -1;
44
44
  knownPlanTimestamps.current.clear();
45
45
  }
46
- }, [messages.length, isModal]);
46
+ }, [messages.length]);
47
47
 
48
48
  // Helper to determine showInput from the latest message
49
49
  const updateShowInput = useCallback(
@@ -176,9 +176,14 @@ export class DocumentSearch implements SearchInterface {
176
176
  }
177
177
 
178
178
  computeFacets(_query: ObjectSearchQuery) {
179
- this._facetsRequest().then((facets) => {
180
- this.facets.value = facets;
181
- });
179
+ this._facetsRequest()
180
+ .then((facets) => {
181
+ this.facets.value = facets;
182
+ })
183
+ .catch((err: unknown) => {
184
+ const error = err instanceof Error ? err : new Error(String(err));
185
+ this.result.value = { ...this.result.value, error };
186
+ });
182
187
  }
183
188
 
184
189
  async _search(loadMore = false, noFacets = false): Promise<boolean> {
@@ -1,20 +1,20 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import { getMountBasename, joinPath, stripMountBasename, withMountBasename } from './path';
3
3
 
4
4
  // Simulate a served `<base href>` (or its absence) by stubbing document.querySelector + document.URL.
5
5
  function setBaseHref(href: string | null, docUrl = 'https://gw.example.com/x') {
6
- (globalThis as { document?: unknown }).document = {
6
+ vi.stubGlobal('document', {
7
7
  URL: docUrl,
8
8
  querySelector: (sel: string) =>
9
9
  sel === 'base[href]' && href ? ({ href: new URL(href, docUrl).href } as unknown) : null,
10
- };
10
+ });
11
11
  }
12
12
 
13
13
  const MOUNT = '/tenants/05948c_5ed5f4/apps/furniture-catalog/versions/20260620T064257113Z/app';
14
14
 
15
15
  describe('mount basename helpers', () => {
16
16
  afterEach(() => {
17
- delete (globalThis as { document?: unknown }).document;
17
+ vi.unstubAllGlobals();
18
18
  });
19
19
 
20
20
  describe('getMountBasename', () => {
@@ -31,7 +31,7 @@ describe('mount basename helpers', () => {
31
31
  expect(getMountBasename()).toBe('');
32
32
  });
33
33
  it('returns "" when there is no document (SSR)', () => {
34
- delete (globalThis as { document?: unknown }).document;
34
+ vi.stubGlobal('document', undefined);
35
35
  expect(getMountBasename()).toBe('');
36
36
  });
37
37
  });
@@ -134,7 +134,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
134
134
  session.setSession = setSession;
135
135
  getComposableToken(selectedAccount, selectedProject, Env.devAuthToken)
136
136
  .then((res) => {
137
- session.login(res.rawToken).then(() => setSession(session.clone()));
137
+ return session.login(res.rawToken).then(() => setSession(session.clone()));
138
138
  })
139
139
  .catch((err) => {
140
140
  if (surfaceAuthError(err)) return;
@@ -169,7 +169,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
169
169
  }
170
170
  getComposableToken(selectedAccount, selectedProject, token, false, shouldRedirectToCentralAuth())
171
171
  .then((res) => {
172
- session.login(res.rawToken, { loadOnboardingStatus }).then(() => {
172
+ return session.login(res.rawToken, { loadOnboardingStatus }).then(() => {
173
173
  setSession(session.clone());
174
174
  clearAuthHash();
175
175
  });
@@ -238,7 +238,7 @@ export function UserSessionProvider({ children, loadOnboardingStatus = true }: U
238
238
  shouldRedirectToCentralAuth(),
239
239
  )
240
240
  .then((res) => {
241
- session
241
+ return session
242
242
  .login(res.rawToken, { loadOnboardingStatus })
243
243
  .then(() => setSession(session.clone()));
244
244
  })
@@ -0,0 +1,25 @@
1
+ import { Env } from '@vertesia/ui/env';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { getFirebaseAuth, setFirebaseTenant } from './firebase';
4
+
5
+ vi.mock('firebase/app', () => ({ initializeApp: vi.fn(() => ({})) }));
6
+ vi.mock('firebase/auth', () => ({ getAuth: vi.fn(() => ({ tenantId: null })) }));
7
+
8
+ describe('configured Firebase tenant', () => {
9
+ afterEach(() => vi.restoreAllMocks());
10
+
11
+ it('initializes redirect handling with the tenant and skips email discovery', async () => {
12
+ vi.spyOn(Env, 'firebase', 'get').mockReturnValue({
13
+ apiKey: 'key',
14
+ authDomain: 'app.example.com',
15
+ projectId: 'project',
16
+ tenantId: 'fixed-tenant',
17
+ });
18
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
19
+ expect(getFirebaseAuth().tenantId).toBe('fixed-tenant');
20
+ const tenant = await setFirebaseTenant('someone@another-company.com');
21
+ expect(tenant?.firebaseTenantId).toBe('fixed-tenant');
22
+ expect(tenant?.provider).toBe('oidc');
23
+ expect(fetchSpy).not.toHaveBeenCalled();
24
+ });
25
+ });
@@ -37,11 +37,21 @@ export function getFirebaseAnalytics(): Analytics {
37
37
  export function getFirebaseAuth(): Auth {
38
38
  if (!_firebaseAuth) {
39
39
  _firebaseAuth = getAuth(getFirebaseApp());
40
+ _firebaseAuth.tenantId = Env.firebase?.tenantId ?? null;
40
41
  }
41
42
  return _firebaseAuth;
42
43
  }
43
44
 
44
45
  export async function setFirebaseTenant(tenantEmail?: string) {
46
+ if (Env.firebase?.tenantId) {
47
+ getFirebaseAuth().tenantId = Env.firebase.tenantId;
48
+ return {
49
+ firebaseTenantId: Env.firebase.tenantId,
50
+ provider: Env.firebase.providerType ?? 'oidc',
51
+ name: '',
52
+ label: '',
53
+ };
54
+ }
45
55
  if (!tenantEmail) {
46
56
  console.log('No tenant name or email specified, skipping tenant setup');
47
57
  return;
@@ -28,10 +28,10 @@ export default function SignInAuthPending({ provider, onCancel }: SignInAuthPend
28
28
  </div>
29
29
 
30
30
  <div className="w-full flex flex-col gap-2">
31
- <SignInStepButton variant="loading">
31
+ <div className="flex justify-center">
32
32
  <Spinner />
33
- <span>{t('auth.pending.authenticating')}</span>
34
- </SignInStepButton>
33
+ </div>
34
+ <span>{t('auth.pending.authenticating')}</span>
35
35
  {onCancel && (
36
36
  <SignInStepButton variant="ghost" onClick={onCancel}>
37
37
  {t('auth.pending.cancel')}
@@ -0,0 +1,32 @@
1
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { afterEach, expect, it, vi } from 'vitest';
3
+ import SignInEmailStep from './SignInEmailStep';
4
+
5
+ const resolveTenant = vi.hoisted(() => vi.fn());
6
+ vi.mock('@vertesia/ui/session', () => ({ setFirebaseTenant: resolveTenant }));
7
+ vi.mock('@vertesia/ui/i18n', () => ({ useUITranslation: () => ({ t: (key: string) => key }) }));
8
+ afterEach(() => {
9
+ cleanup();
10
+ vi.clearAllMocks();
11
+ });
12
+
13
+ it('keeps feedback visible and prevents duplicate requests while tenant discovery is pending', async () => {
14
+ let finish!: () => void;
15
+ resolveTenant.mockReturnValue(
16
+ new Promise<void>((resolve) => {
17
+ finish = resolve;
18
+ }),
19
+ );
20
+ const onProceed = vi.fn();
21
+ render(<SignInEmailStep initialEmail="person@example.com" onProceed={onProceed} />);
22
+ const button = screen.getByRole('button', { name: 'auth.continue' });
23
+ fireEvent.click(button);
24
+ expect(screen.queryByRole('button', { name: 'auth.continue' })).toBeNull();
25
+ expect(screen.getByRole('textbox')).toBeTruthy();
26
+ expect(screen.getByRole('status')).toBeTruthy();
27
+ expect(button.contains(screen.getByRole('status'))).toBe(false);
28
+ fireEvent.submit(screen.getByRole('textbox').closest('form')!);
29
+ expect(resolveTenant).toHaveBeenCalledTimes(1);
30
+ finish();
31
+ await waitFor(() => expect(onProceed).toHaveBeenCalledWith('person@example.com', undefined));
32
+ });
@@ -67,16 +67,20 @@ export default function SignInEmailStep({ initialEmail, onProceed }: SignInEmail
67
67
  error={submitError ? t('auth.email.invalidError') : undefined}
68
68
  />
69
69
 
70
- <SignInStepButton type="submit" disabled={loading}>
71
- {loading ? (
70
+ {loading ? (
71
+ <div
72
+ role="status"
73
+ aria-label={t('auth.pending.authenticating')}
74
+ className="h-[42px] flex items-center justify-center"
75
+ >
72
76
  <Spinner />
73
- ) : (
74
- <>
75
- <span>{t('auth.continue')}</span>
76
- <ArrowRight className="!size-3.5" />
77
- </>
78
- )}
79
- </SignInStepButton>
77
+ </div>
78
+ ) : (
79
+ <SignInStepButton type="submit">
80
+ <span>{t('auth.continue')}</span>
81
+ <ArrowRight className="!size-3.5" />
82
+ </SignInStepButton>
83
+ )}
80
84
  </form>
81
85
  </SignInStepLayout>
82
86
  );