@tuturuuu/ui 0.22.0 → 0.24.0

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 (66) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/biome.json +1 -1
  3. package/package.json +78 -78
  4. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
  5. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
  6. package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
  7. package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
  8. package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
  9. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
  10. package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
  11. package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
  12. package/src/components/ui/chat/chat-utils.test.ts +107 -0
  13. package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
  14. package/src/components/ui/chat/chat-workspace.tsx +66 -24
  15. package/src/components/ui/chat/hooks-conversations.ts +9 -1
  16. package/src/components/ui/chat/hooks-messages.ts +1 -0
  17. package/src/components/ui/chat/message-bubble.tsx +49 -4
  18. package/src/components/ui/chat/message-composer.tsx +29 -23
  19. package/src/components/ui/chat/message-markdown.tsx +4 -1
  20. package/src/components/ui/chat/query-keys.ts +7 -1
  21. package/src/components/ui/chat/utils.ts +85 -3
  22. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
  24. package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
  25. package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
  26. package/src/components/ui/custom/workspace-select.tsx +37 -38
  27. package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
  28. package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
  29. package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
  30. package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
  31. package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
  32. package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
  33. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
  34. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
  35. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
  36. package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
  37. package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
  40. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
  41. package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
  42. package/src/components/ui/legacy/meet/form.tsx +25 -1
  43. package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
  44. package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
  45. package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
  46. package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
  47. package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
  48. package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
  49. package/src/components/ui/legacy/meet/page.tsx +1 -0
  50. package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
  51. package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
  52. package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
  53. package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
  54. package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
  55. package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
  56. package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
  57. package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
  58. package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
  59. package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
  60. package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
  61. package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
  62. package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
  63. package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
  64. package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
  65. package/src/components/ui/nuqs-adapter.tsx +1 -0
  66. package/src/hooks/time-blocking-provider.tsx +127 -211
@@ -13,13 +13,19 @@ export const chatQueryKeys = {
13
13
  ] as const,
14
14
  conversations: (wsId: string, archived = 'active') =>
15
15
  [...chatQueryKeys.all(wsId), 'conversations', archived] as const,
16
- conversationsInfinite: (wsId: string, archived = 'active', limit = 40) =>
16
+ conversationsInfinite: (
17
+ wsId: string,
18
+ archived = 'active',
19
+ limit = 40,
20
+ scope: 'all' | 'external' = 'all'
21
+ ) =>
17
22
  [
18
23
  ...chatQueryKeys.all(wsId),
19
24
  'conversations',
20
25
  archived,
21
26
  'infinite',
22
27
  limit,
28
+ scope,
23
29
  ] as const,
24
30
  directory: (wsId: string, query: string) =>
25
31
  [...chatQueryKeys.all(wsId), 'directory', query] as const,
@@ -5,7 +5,7 @@ import type {
5
5
  ChatUserProfile,
6
6
  } from '@tuturuuu/internal-api';
7
7
 
8
- export type ChatConversationScope = 'personal' | 'workspaces';
8
+ export type ChatConversationScope = 'external' | 'personal' | 'workspaces';
9
9
  export type ChatConversationArchiveFilter = 'active' | 'all' | 'archived';
10
10
 
11
11
  export const DEFAULT_CHAT_SCOPE: ChatConversationScope = 'personal';
@@ -16,15 +16,54 @@ export const CHAT_CONVERSATION_TYPE_FILTERS = [
16
16
  'ai',
17
17
  ] as const satisfies ChatConversationType[];
18
18
 
19
+ export type PendingChatSendRequest = {
20
+ fingerprint: string;
21
+ requestId: string;
22
+ };
23
+
24
+ export function resolvePendingChatSendRequest(
25
+ previous: PendingChatSendRequest | null,
26
+ payload: {
27
+ attachments: Array<{
28
+ filename: string;
29
+ path: string;
30
+ sizeBytes?: number | null;
31
+ }>;
32
+ content: string;
33
+ },
34
+ createRequestId: () => string
35
+ ): PendingChatSendRequest {
36
+ const fingerprint = JSON.stringify({
37
+ attachments: payload.attachments.map((attachment) => ({
38
+ filename: attachment.filename,
39
+ path: attachment.path,
40
+ sizeBytes: attachment.sizeBytes,
41
+ })),
42
+ content: payload.content,
43
+ });
44
+
45
+ return previous?.fingerprint === fingerprint
46
+ ? previous
47
+ : { fingerprint, requestId: createRequestId() };
48
+ }
49
+
19
50
  export function normalizeChatConversationScope(
20
- scope?: string | null
51
+ scope?: string | null,
52
+ fallback: ChatConversationScope = 'personal'
21
53
  ): ChatConversationScope {
22
- return scope === 'workspaces' ? 'workspaces' : 'personal';
54
+ if (scope === 'external' || scope === 'personal' || scope === 'workspaces') {
55
+ return scope;
56
+ }
57
+ return fallback;
23
58
  }
24
59
 
25
60
  export function getChatConversationScope(
26
61
  conversation: Pick<ChatConversation, 'metadata' | 'type'>
27
62
  ): ChatConversationScope {
63
+ if (conversation.metadata?.externalChat === true) {
64
+ return 'external';
65
+ }
66
+
28
67
  if (conversation.metadata?.scope === 'personal') {
29
68
  return 'personal';
30
69
  }
@@ -63,6 +102,8 @@ export function isChatConversation(value: unknown): value is ChatConversation {
63
102
  export function getChatConversationTypesForScope(
64
103
  scope: ChatConversationScope
65
104
  ): ChatConversationType[] {
105
+ if (scope === 'external') return ['channel'];
106
+
66
107
  return scope === 'personal'
67
108
  ? ['direct', 'group', 'channel', 'ai']
68
109
  : ['channel', 'ai'];
@@ -115,6 +156,35 @@ export function getChatInitials(profile?: ChatUserProfile | string | null) {
115
156
  .slice(0, 2);
116
157
  }
117
158
 
159
+ export function getChatMessageSenderLabel(
160
+ message: Pick<ChatMessage, 'kind' | 'metadata' | 'sender'>,
161
+ fallback: { assistant: string; external: string; unknown: string }
162
+ ) {
163
+ const nativeDisplayName = readNonEmptyString(message.sender?.displayName);
164
+ if (nativeDisplayName) return nativeDisplayName;
165
+ if (message.kind === 'assistant') return fallback.assistant;
166
+
167
+ const externalSender = message.metadata?.externalSender;
168
+ if (isRecord(externalSender)) {
169
+ const displayName =
170
+ readNonEmptyString(externalSender.displayName) ??
171
+ readNonEmptyString(externalSender.name);
172
+ if (displayName) return displayName;
173
+ }
174
+
175
+ return message.metadata?.externalChat === true
176
+ ? fallback.external
177
+ : fallback.unknown;
178
+ }
179
+
180
+ function isRecord(value: unknown): value is Record<string, unknown> {
181
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
182
+ }
183
+
184
+ function readNonEmptyString(value: unknown) {
185
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
186
+ }
187
+
118
188
  export function getConversationTitle(
119
189
  conversation: ChatConversation,
120
190
  currentUserId: string,
@@ -237,6 +307,18 @@ export function isReadOnlyChatConversation(
237
307
  );
238
308
  }
239
309
 
310
+ export function canPersistChatReadState({
311
+ externalChat,
312
+ hasMembership,
313
+ readOnly,
314
+ }: {
315
+ externalChat: boolean;
316
+ hasMembership: boolean;
317
+ readOnly: boolean;
318
+ }) {
319
+ return !readOnly && (hasMembership || externalChat);
320
+ }
321
+
240
322
  export function getChatSelectionStorageKey(
241
323
  wsId: string,
242
324
  scope: ChatConversationScope
@@ -3,10 +3,82 @@ import { join } from 'node:path';
3
3
  import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
4
4
  import { describe, expect, it } from 'vitest';
5
5
  import {
6
+ buildWorkspaceSetupHandoffUrl,
6
7
  mergeWorkspaceSelectWorkspaces,
7
8
  normalizeWorkspaceSwitchPath,
9
+ resolveWorkspaceAvatarUrl,
8
10
  } from '../workspace-select-helpers';
9
11
 
12
+ describe('buildWorkspaceSetupHandoffUrl', () => {
13
+ it('builds a localized Platform setup URL with an encoded satellite return', () => {
14
+ const handoffUrl = new URL(
15
+ buildWorkspaceSetupHandoffUrl({
16
+ locale: 'vi',
17
+ platformUrl: 'https://tuturuuu.com',
18
+ returnOrigin: 'https://tasks.tuturuuu.com',
19
+ returnPath: '/workspace-1/tasks?view=mine',
20
+ workspaceId: 'workspace-1',
21
+ })
22
+ );
23
+
24
+ expect(handoffUrl.pathname).toBe('/vi/workspace-1/workspace-setup');
25
+ expect(handoffUrl.searchParams.get('returnUrl')).toBe(
26
+ 'https://tasks.tuturuuu.com/vi/workspace-1/tasks?view=mine'
27
+ );
28
+ });
29
+
30
+ it('does not duplicate an existing locale prefix', () => {
31
+ const handoffUrl = new URL(
32
+ buildWorkspaceSetupHandoffUrl({
33
+ locale: 'en',
34
+ platformUrl: 'http://platform.tuturuuu.localhost',
35
+ returnOrigin: 'http://meet.tuturuuu.localhost',
36
+ returnPath: '/en/workspace/workspace-1',
37
+ workspaceId: 'workspace-1',
38
+ })
39
+ );
40
+
41
+ expect(handoffUrl.searchParams.get('returnUrl')).toBe(
42
+ 'http://meet.tuturuuu.localhost/en/workspace/workspace-1'
43
+ );
44
+ });
45
+
46
+ it('rejects a return path that changes the satellite origin', () => {
47
+ expect(() =>
48
+ buildWorkspaceSetupHandoffUrl({
49
+ locale: 'en',
50
+ platformUrl: 'https://tuturuuu.com',
51
+ returnOrigin: 'https://tasks.tuturuuu.com',
52
+ returnPath: 'https://evil.example/workspace-1',
53
+ workspaceId: 'workspace-1',
54
+ })
55
+ ).toThrow('Workspace return path must stay on the current app');
56
+ });
57
+ });
58
+
59
+ describe('resolveWorkspaceAvatarUrl', () => {
60
+ it('preserves a valid hosted avatar when an app provides a local fallback', () => {
61
+ expect(
62
+ resolveWorkspaceAvatarUrl(
63
+ 'https://tuturuuu.com/media/logos/transparent.png',
64
+ {
65
+ rootWorkspaceLogoUrl: '/media/logos/transparent.png',
66
+ }
67
+ )
68
+ ).toBe('https://tuturuuu.com/media/logos/transparent.png');
69
+ });
70
+
71
+ it('uses the canonical root logo only when the root workspace has no avatar', () => {
72
+ expect(
73
+ resolveWorkspaceAvatarUrl(null, {
74
+ rootWorkspaceLogoUrl:
75
+ 'https://tuturuuu.com/media/logos/transparent.png',
76
+ })
77
+ ).toBe('https://tuturuuu.com/media/logos/transparent.png');
78
+ expect(resolveWorkspaceAvatarUrl(null)).toBeNull();
79
+ });
80
+ });
81
+
10
82
  describe('mergeWorkspaceSelectWorkspaces', () => {
11
83
  it('uses the current workspace fallback when the workspace list is unavailable', () => {
12
84
  const fallback: InternalApiWorkspaceSummary = {
@@ -0,0 +1,131 @@
1
+ 'use client';
2
+
3
+ import { MessageSquareText, Play, RotateCcw } from '@tuturuuu/icons';
4
+ import { updateConnectedOnboardingProgress } from '@tuturuuu/internal-api/onboarding';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import {
7
+ getLaunchableApp,
8
+ getLaunchableAppByHostname,
9
+ resolveLaunchableAppUrl,
10
+ } from '@tuturuuu/utils/launchable-apps';
11
+ import { useTranslations } from 'next-intl';
12
+ import { useState } from 'react';
13
+
14
+ function currentAppSlug() {
15
+ if (typeof window === 'undefined') return 'platform';
16
+ return (
17
+ getLaunchableAppByHostname(window.location.hostname)?.slug ?? 'platform'
18
+ );
19
+ }
20
+
21
+ function closeSettingsDialog() {
22
+ window.dispatchEvent(new Event('tuturuuu:settings-dialog-close-intent'));
23
+ }
24
+
25
+ export function OnboardingSettingsPanel() {
26
+ const t = useTranslations('onboarding_guide');
27
+ const [pending, setPending] = useState<'replay' | 'restart' | null>(null);
28
+
29
+ const replayApp = async () => {
30
+ const appSlug = currentAppSlug();
31
+ setPending('replay');
32
+ await updateConnectedOnboardingProgress({
33
+ dismissed_at: null,
34
+ replay_app: appSlug,
35
+ }).catch(() => null);
36
+ setPending(null);
37
+ closeSettingsDialog();
38
+ window.dispatchEvent(
39
+ new CustomEvent('tuturuuu:onboarding-replay', { detail: { appSlug } })
40
+ );
41
+ };
42
+
43
+ const restartJourney = async () => {
44
+ setPending('restart');
45
+ await updateConnectedOnboardingProgress({
46
+ completed_missions: [],
47
+ dismissed_at: null,
48
+ goals: [],
49
+ guidance_mode: 'standard',
50
+ journey_revision: 2,
51
+ persona: null,
52
+ replay_app: 'platform',
53
+ }).catch(() => null);
54
+
55
+ const platform = getLaunchableApp('platform');
56
+ if (!platform) return;
57
+ window.location.assign(
58
+ resolveLaunchableAppUrl({
59
+ app: platform,
60
+ currentOrigin: window.location.origin,
61
+ path: '/personal',
62
+ searchParams: { guide: 'platform' },
63
+ })
64
+ );
65
+ };
66
+
67
+ const shareFeedback = () => {
68
+ closeSettingsDialog();
69
+ window.dispatchEvent(
70
+ new CustomEvent('tuturuuu:report-problem-open-intent', {
71
+ detail: { context: `onboarding:${currentAppSlug()}` },
72
+ })
73
+ );
74
+ };
75
+
76
+ const actions = [
77
+ {
78
+ description: t('replay_app_description'),
79
+ icon: Play,
80
+ label: t('replay_app'),
81
+ onClick: replayApp,
82
+ pending: pending === 'replay',
83
+ },
84
+ {
85
+ description: t('restart_journey_description'),
86
+ icon: RotateCcw,
87
+ label: t('restart_journey'),
88
+ onClick: restartJourney,
89
+ pending: pending === 'restart',
90
+ },
91
+ {
92
+ description: t('feedback_description'),
93
+ icon: MessageSquareText,
94
+ label: t('feedback'),
95
+ onClick: shareFeedback,
96
+ pending: false,
97
+ },
98
+ ];
99
+
100
+ return (
101
+ <div className="space-y-3">
102
+ {actions.map((action) => (
103
+ <div
104
+ key={action.label}
105
+ className="flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between"
106
+ >
107
+ <div className="flex gap-3">
108
+ <div className="grid size-9 shrink-0 place-items-center rounded-lg border bg-muted/40">
109
+ <action.icon className="size-4" />
110
+ </div>
111
+ <div>
112
+ <p className="font-medium text-sm">{action.label}</p>
113
+ <p className="mt-1 max-w-xl text-muted-foreground text-sm">
114
+ {action.description}
115
+ </p>
116
+ </div>
117
+ </div>
118
+ <Button
119
+ type="button"
120
+ variant="outline"
121
+ disabled={action.pending}
122
+ onClick={action.onClick}
123
+ >
124
+ <action.icon className="size-4" />
125
+ {action.label}
126
+ </Button>
127
+ </div>
128
+ ))}
129
+ </div>
130
+ );
131
+ }
@@ -4,6 +4,7 @@ import {
4
4
  ArrowLeft,
5
5
  ChevronDown,
6
6
  ChevronRight,
7
+ Compass,
7
8
  Search,
8
9
  X,
9
10
  } from '@tuturuuu/icons';
@@ -63,6 +64,7 @@ import { useTranslations } from 'next-intl';
63
64
  import { parseAsString, useQueryState } from 'nuqs';
64
65
  import type { ComponentType, KeyboardEvent, ReactNode } from 'react';
65
66
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
67
+ import { OnboardingSettingsPanel } from './onboarding-settings-panel';
66
68
  import type { createSettingsSearchEngine } from './settings-dialog-search';
67
69
  import { loadSettingsSearchEngine } from './settings-dialog-search-loader';
68
70
 
@@ -158,17 +160,41 @@ export function SettingsDialogShell({
158
160
  })
159
161
  );
160
162
 
163
+ const extendedNavItems = useMemo<SettingsNavGroup[]>(
164
+ () => [
165
+ ...navItems,
166
+ {
167
+ label: t('onboarding_guide.title'),
168
+ items: [
169
+ {
170
+ name: 'onboarding_guide',
171
+ label: t('onboarding_guide.title'),
172
+ description: t('onboarding_guide.description'),
173
+ icon: Compass,
174
+ keywords: [
175
+ t('onboarding_guide.replay_app'),
176
+ t('onboarding_guide.restart_journey'),
177
+ ],
178
+ },
179
+ ],
180
+ },
181
+ ],
182
+ [navItems, t]
183
+ );
184
+
161
185
  const searchEngine = useMemo<SettingsSearchEngine | null>(
162
- () => searchEngineFactory?.(navItems) ?? null,
163
- [navItems, searchEngineFactory]
186
+ () => searchEngineFactory?.(extendedNavItems) ?? null,
187
+ [extendedNavItems, searchEngineFactory]
164
188
  );
165
189
 
166
190
  const allNavItems = useMemo(
167
- () => searchEngine?.allItems ?? navItems.flatMap((group) => group.items),
168
- [navItems, searchEngine]
191
+ () =>
192
+ searchEngine?.allItems ??
193
+ extendedNavItems.flatMap((group) => group.items),
194
+ [extendedNavItems, searchEngine]
169
195
  );
170
196
 
171
- const activeGroup = navItems.find((group) =>
197
+ const activeGroup = extendedNavItems.find((group) =>
172
198
  group.items.some((item) => item.name === activeTab)
173
199
  );
174
200
 
@@ -209,8 +235,10 @@ export function SettingsDialogShell({
209
235
 
210
236
  const filteredNavItems = useMemo(
211
237
  () =>
212
- searchQuery ? (searchEngine?.search(searchQuery) ?? navItems) : navItems,
213
- [navItems, searchEngine, searchQuery]
238
+ searchQuery
239
+ ? (searchEngine?.search(searchQuery) ?? extendedNavItems)
240
+ : extendedNavItems,
241
+ [extendedNavItems, searchEngine, searchQuery]
214
242
  );
215
243
 
216
244
  const filteredEnabledItems = useMemo(
@@ -221,6 +249,11 @@ export function SettingsDialogShell({
221
249
  ),
222
250
  [filteredNavItems, searchEngine, searchQuery]
223
251
  );
252
+ const keyboardEnabledItems = useMemo(
253
+ () =>
254
+ filteredEnabledItems.filter((item) => item.name !== 'onboarding_guide'),
255
+ [filteredEnabledItems]
256
+ );
224
257
 
225
258
  const updateSearchQuery = useCallback(
226
259
  (value: string) => {
@@ -252,30 +285,30 @@ export function SettingsDialogShell({
252
285
 
253
286
  const changeActiveItem = useCallback(
254
287
  (targetIndex: number) => {
255
- const targetItem = filteredEnabledItems[targetIndex];
288
+ const targetItem = keyboardEnabledItems[targetIndex];
256
289
  if (targetItem) selectActiveTab(targetItem.name);
257
290
  },
258
- [filteredEnabledItems, selectActiveTab]
291
+ [keyboardEnabledItems, selectActiveTab]
259
292
  );
260
293
 
261
294
  const moveActiveItem = useCallback(
262
295
  (direction: 1 | -1) => {
263
- if (filteredEnabledItems.length === 0) return;
296
+ if (keyboardEnabledItems.length === 0) return;
264
297
 
265
- const currentIndex = filteredEnabledItems.findIndex(
298
+ const currentIndex = keyboardEnabledItems.findIndex(
266
299
  (item) => item.name === activeTab
267
300
  );
268
301
  const nextIndex =
269
302
  currentIndex === -1
270
303
  ? direction === 1
271
304
  ? 0
272
- : filteredEnabledItems.length - 1
273
- : (currentIndex + direction + filteredEnabledItems.length) %
274
- filteredEnabledItems.length;
305
+ : keyboardEnabledItems.length - 1
306
+ : (currentIndex + direction + keyboardEnabledItems.length) %
307
+ keyboardEnabledItems.length;
275
308
 
276
309
  changeActiveItem(nextIndex);
277
310
  },
278
- [activeTab, changeActiveItem, filteredEnabledItems]
311
+ [activeTab, changeActiveItem, keyboardEnabledItems]
279
312
  );
280
313
 
281
314
  const handleKeyboardNavigation = useCallback(
@@ -325,12 +358,12 @@ export function SettingsDialogShell({
325
358
 
326
359
  if (event.key === 'End') {
327
360
  event.preventDefault();
328
- changeActiveItem(filteredEnabledItems.length - 1);
361
+ changeActiveItem(keyboardEnabledItems.length - 1);
329
362
  }
330
363
  },
331
364
  [
332
365
  changeActiveItem,
333
- filteredEnabledItems.length,
366
+ keyboardEnabledItems.length,
334
367
  focusSearch,
335
368
  keyboardNavigation,
336
369
  moveActiveItem,
@@ -569,7 +602,11 @@ export function SettingsDialogShell({
569
602
  <Separator />
570
603
  </>
571
604
  )}
572
- {children}
605
+ {activeTab === 'onboarding_guide' ? (
606
+ <OnboardingSettingsPanel />
607
+ ) : (
608
+ children
609
+ )}
573
610
  </div>
574
611
  </div>
575
612
  </main>
@@ -1,5 +1,16 @@
1
1
  import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
2
2
 
3
+ export function resolveWorkspaceAvatarUrl(
4
+ avatarUrl: string | null | undefined,
5
+ {
6
+ rootWorkspaceLogoUrl,
7
+ }: {
8
+ rootWorkspaceLogoUrl?: string;
9
+ } = {}
10
+ ) {
11
+ return avatarUrl || rootWorkspaceLogoUrl || null;
12
+ }
13
+
3
14
  export function mergeWorkspaceSelectWorkspaces(
4
15
  workspaces: InternalApiWorkspaceSummary[] | undefined,
5
16
  currentWorkspaceFallback: InternalApiWorkspaceSummary | null | undefined
@@ -19,6 +30,45 @@ export function mergeWorkspaceSelectWorkspaces(
19
30
  return [...workspaceList, currentWorkspaceFallback];
20
31
  }
21
32
 
33
+ export function buildWorkspaceSetupHandoffUrl({
34
+ locale,
35
+ platformUrl,
36
+ returnOrigin,
37
+ returnPath,
38
+ workspaceId,
39
+ }: {
40
+ locale: string;
41
+ platformUrl: string;
42
+ returnOrigin: string;
43
+ returnPath: string;
44
+ workspaceId: string;
45
+ }) {
46
+ const origin = new URL(returnOrigin).origin;
47
+ const returnUrl = new URL(returnPath, origin);
48
+
49
+ if (returnUrl.origin !== origin) {
50
+ throw new Error('Workspace return path must stay on the current app');
51
+ }
52
+
53
+ const localePrefix = `/${locale}`;
54
+ if (
55
+ returnUrl.pathname !== localePrefix &&
56
+ !returnUrl.pathname.startsWith(`${localePrefix}/`)
57
+ ) {
58
+ returnUrl.pathname = `${localePrefix}${
59
+ returnUrl.pathname.startsWith('/') ? '' : '/'
60
+ }${returnUrl.pathname}`;
61
+ }
62
+
63
+ const setupUrl = new URL(
64
+ `/${locale}/${encodeURIComponent(workspaceId)}/workspace-setup`,
65
+ platformUrl
66
+ );
67
+ setupUrl.searchParams.set('returnUrl', returnUrl.toString());
68
+
69
+ return setupUrl.toString();
70
+ }
71
+
22
72
  export function normalizeWorkspaceSwitchPath(
23
73
  pathname: string,
24
74
  nextSlug: string