@tuturuuu/ui 0.23.0 → 0.25.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 (72) hide show
  1. package/CHANGELOG.md +43 -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-external-context-sidebar.tsx +113 -0
  8. package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +5 -1
  9. package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
  10. package/src/components/ui/chat/chat-sidebar-items.tsx +89 -5
  11. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
  12. package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
  13. package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
  14. package/src/components/ui/chat/chat-sidebar.tsx +3 -0
  15. package/src/components/ui/chat/chat-utils.test.ts +173 -0
  16. package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
  17. package/src/components/ui/chat/chat-workspace.tsx +105 -28
  18. package/src/components/ui/chat/hooks-conversations.ts +9 -1
  19. package/src/components/ui/chat/hooks-messages.ts +1 -0
  20. package/src/components/ui/chat/hooks-realtime.ts +93 -4
  21. package/src/components/ui/chat/message-bubble.tsx +49 -4
  22. package/src/components/ui/chat/message-composer.tsx +29 -23
  23. package/src/components/ui/chat/message-list.tsx +5 -3
  24. package/src/components/ui/chat/message-markdown.tsx +4 -1
  25. package/src/components/ui/chat/query-keys.ts +7 -1
  26. package/src/components/ui/chat/selection.ts +1 -1
  27. package/src/components/ui/chat/utils.ts +126 -3
  28. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
  29. package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
  30. package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
  31. package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
  32. package/src/components/ui/custom/workspace-select.tsx +37 -38
  33. package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
  34. package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
  35. package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
  36. package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
  37. package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
  38. package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
  39. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
  40. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
  41. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
  42. package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
  43. package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
  46. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
  47. package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
  48. package/src/components/ui/legacy/meet/form.tsx +25 -1
  49. package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
  50. package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
  51. package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
  52. package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
  53. package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
  54. package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
  55. package/src/components/ui/legacy/meet/page.tsx +1 -0
  56. package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
  57. package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
  58. package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
  59. package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
  60. package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
  61. package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
  62. package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
  63. package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
  64. package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
  65. package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
  66. package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
  67. package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
  68. package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
  69. package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
  70. package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
  71. package/src/components/ui/nuqs-adapter.tsx +1 -0
  72. 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,
@@ -8,7 +8,7 @@ type RouterLike = {
8
8
  replace: (href: string, options?: { scroll?: boolean }) => void;
9
9
  };
10
10
 
11
- export type ChatDetailsTarget = 'agent' | null;
11
+ export type ChatDetailsTarget = 'agent' | 'external' | null;
12
12
 
13
13
  export function buildChatSelectionHref({
14
14
  conversationId,
@@ -5,9 +5,16 @@ 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
+ export type ChatConversationDeliveryState =
12
+ | 'deleted'
13
+ | 'failed'
14
+ | 'seen'
15
+ | 'sending'
16
+ | 'sent';
17
+
11
18
  export const DEFAULT_CHAT_SCOPE: ChatConversationScope = 'personal';
12
19
  export const CHAT_CONVERSATION_TYPE_FILTERS = [
13
20
  'direct',
@@ -16,15 +23,54 @@ export const CHAT_CONVERSATION_TYPE_FILTERS = [
16
23
  'ai',
17
24
  ] as const satisfies ChatConversationType[];
18
25
 
26
+ export type PendingChatSendRequest = {
27
+ fingerprint: string;
28
+ requestId: string;
29
+ };
30
+
31
+ export function resolvePendingChatSendRequest(
32
+ previous: PendingChatSendRequest | null,
33
+ payload: {
34
+ attachments: Array<{
35
+ filename: string;
36
+ path: string;
37
+ sizeBytes?: number | null;
38
+ }>;
39
+ content: string;
40
+ },
41
+ createRequestId: () => string
42
+ ): PendingChatSendRequest {
43
+ const fingerprint = JSON.stringify({
44
+ attachments: payload.attachments.map((attachment) => ({
45
+ filename: attachment.filename,
46
+ path: attachment.path,
47
+ sizeBytes: attachment.sizeBytes,
48
+ })),
49
+ content: payload.content,
50
+ });
51
+
52
+ return previous?.fingerprint === fingerprint
53
+ ? previous
54
+ : { fingerprint, requestId: createRequestId() };
55
+ }
56
+
19
57
  export function normalizeChatConversationScope(
20
- scope?: string | null
58
+ scope?: string | null,
59
+ fallback: ChatConversationScope = 'personal'
21
60
  ): ChatConversationScope {
22
- return scope === 'workspaces' ? 'workspaces' : 'personal';
61
+ if (scope === 'external' || scope === 'personal' || scope === 'workspaces') {
62
+ return scope;
63
+ }
64
+ return fallback;
23
65
  }
24
66
 
25
67
  export function getChatConversationScope(
26
68
  conversation: Pick<ChatConversation, 'metadata' | 'type'>
27
69
  ): ChatConversationScope {
70
+ if (conversation.metadata?.externalChat === true) {
71
+ return 'external';
72
+ }
73
+
28
74
  if (conversation.metadata?.scope === 'personal') {
29
75
  return 'personal';
30
76
  }
@@ -48,6 +94,40 @@ export function getChatConversationScope(
48
94
  return 'workspaces';
49
95
  }
50
96
 
97
+ export function getChatConversationQueueDetails(
98
+ conversation: Pick<
99
+ ChatConversation,
100
+ 'latestMessage' | 'metadata' | 'updatedAt'
101
+ >
102
+ ) {
103
+ const latestMessage = conversation.latestMessage;
104
+ const phone = readNonEmptyString(conversation.metadata.phone);
105
+
106
+ return {
107
+ deliveryState: latestMessage
108
+ ? getChatConversationDeliveryState(latestMessage)
109
+ : null,
110
+ phone,
111
+ preview: latestMessage?.deletedAt
112
+ ? null
113
+ : (readNonEmptyString(latestMessage?.content) ??
114
+ readNonEmptyString(latestMessage?.attachments[0]?.filename)),
115
+ timestamp: latestMessage?.createdAt ?? conversation.updatedAt,
116
+ };
117
+ }
118
+
119
+ export function getChatConversationDeliveryState(
120
+ message: Pick<ChatMessage, 'deletedAt' | 'metadata'>
121
+ ): ChatConversationDeliveryState {
122
+ if (message.deletedAt) return 'deleted';
123
+
124
+ const status = readNonEmptyString(message.metadata.status)?.toLowerCase();
125
+ if (status && /fail|error|reject/u.test(status)) return 'failed';
126
+ if (status && /seen|read/u.test(status)) return 'seen';
127
+ if (status && /pending|queue|sending/u.test(status)) return 'sending';
128
+ return 'sent';
129
+ }
130
+
51
131
  export function isChatConversation(value: unknown): value is ChatConversation {
52
132
  const conversation = value as Partial<ChatConversation> | null | undefined;
53
133
 
@@ -63,6 +143,8 @@ export function isChatConversation(value: unknown): value is ChatConversation {
63
143
  export function getChatConversationTypesForScope(
64
144
  scope: ChatConversationScope
65
145
  ): ChatConversationType[] {
146
+ if (scope === 'external') return ['channel'];
147
+
66
148
  return scope === 'personal'
67
149
  ? ['direct', 'group', 'channel', 'ai']
68
150
  : ['channel', 'ai'];
@@ -115,6 +197,35 @@ export function getChatInitials(profile?: ChatUserProfile | string | null) {
115
197
  .slice(0, 2);
116
198
  }
117
199
 
200
+ export function getChatMessageSenderLabel(
201
+ message: Pick<ChatMessage, 'kind' | 'metadata' | 'sender'>,
202
+ fallback: { assistant: string; external: string; unknown: string }
203
+ ) {
204
+ const nativeDisplayName = readNonEmptyString(message.sender?.displayName);
205
+ if (nativeDisplayName) return nativeDisplayName;
206
+ if (message.kind === 'assistant') return fallback.assistant;
207
+
208
+ const externalSender = message.metadata?.externalSender;
209
+ if (isRecord(externalSender)) {
210
+ const displayName =
211
+ readNonEmptyString(externalSender.displayName) ??
212
+ readNonEmptyString(externalSender.name);
213
+ if (displayName) return displayName;
214
+ }
215
+
216
+ return message.metadata?.externalChat === true
217
+ ? fallback.external
218
+ : fallback.unknown;
219
+ }
220
+
221
+ function isRecord(value: unknown): value is Record<string, unknown> {
222
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
223
+ }
224
+
225
+ function readNonEmptyString(value: unknown) {
226
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
227
+ }
228
+
118
229
  export function getConversationTitle(
119
230
  conversation: ChatConversation,
120
231
  currentUserId: string,
@@ -237,6 +348,18 @@ export function isReadOnlyChatConversation(
237
348
  );
238
349
  }
239
350
 
351
+ export function canPersistChatReadState({
352
+ externalChat,
353
+ hasMembership,
354
+ readOnly,
355
+ }: {
356
+ externalChat: boolean;
357
+ hasMembership: boolean;
358
+ readOnly: boolean;
359
+ }) {
360
+ return !readOnly && (hasMembership || externalChat);
361
+ }
362
+
240
363
  export function getChatSelectionStorageKey(
241
364
  wsId: string,
242
365
  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