@tuturuuu/ui 0.2.0 → 0.3.2

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 (129) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +79 -67
  3. package/src/components/ui/__tests__/avatar.test.tsx +8 -5
  4. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
  8. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
  9. package/src/components/ui/chart.test.tsx +29 -0
  10. package/src/components/ui/chart.tsx +12 -3
  11. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +396 -2
  12. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +36 -8
  13. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +14 -0
  14. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +5 -0
  15. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +21 -7
  16. package/src/components/ui/chat/chat-agent-details-utils.test.ts +73 -0
  17. package/src/components/ui/chat/chat-agent-details-utils.tsx +100 -26
  18. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +517 -0
  19. package/src/components/ui/chat/chat-workspace.tsx +31 -1
  20. package/src/components/ui/chat/hooks-messages.test.tsx +45 -1
  21. package/src/components/ui/chat/hooks-messages.ts +1 -1
  22. package/src/components/ui/chat/hooks-realtime.ts +13 -16
  23. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  24. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  25. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  26. package/src/components/ui/custom/common-footer.tsx +16 -1
  27. package/src/components/ui/custom/production-indicator.tsx +1 -1
  28. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  29. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  30. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  31. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  32. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  33. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  34. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  35. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  36. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  37. package/src/components/ui/custom/workspace-select.tsx +33 -12
  38. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  39. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  40. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  41. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  42. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  43. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  44. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  45. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  46. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  47. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  48. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  49. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  50. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  51. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  52. package/src/components/ui/finance/invoices/utils.ts +75 -17
  53. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  54. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  55. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  56. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  57. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  58. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  59. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  60. package/src/components/ui/finance/transactions/form.tsx +60 -0
  61. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  62. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  63. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  64. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  65. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  66. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  67. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  68. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  69. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  70. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  71. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  72. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  73. package/src/components/ui/legacy/meet/page.tsx +87 -39
  74. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  75. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  77. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  78. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  79. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  80. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  81. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  82. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  83. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  84. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  85. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  86. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  87. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  88. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  89. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  90. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  91. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  92. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  93. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  94. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  95. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  96. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  97. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  98. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  99. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  100. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  101. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  102. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  103. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  104. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  105. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  106. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  107. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  108. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  109. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  110. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  111. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  112. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  113. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  114. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  115. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  116. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  117. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  118. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  119. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  120. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  121. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  122. package/src/hooks/use-calendar-sync.tsx +22 -277
  123. package/src/hooks/use-calendar.tsx +95 -525
  124. package/src/hooks/use-semantic-task-search.ts +10 -33
  125. package/src/hooks/use-task-actions.ts +43 -117
  126. package/src/hooks/use-user-config.ts +1 -1
  127. package/src/hooks/use-workspace-config.ts +6 -2
  128. package/src/hooks/use-workspace-presence.ts +1 -1
  129. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
@@ -0,0 +1,517 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import {
5
+ Check,
6
+ LoaderCircle,
7
+ Play,
8
+ QrCode,
9
+ RefreshCw,
10
+ ScanLine,
11
+ Square,
12
+ X,
13
+ } from '@tuturuuu/icons';
14
+ import type {
15
+ AiAgentChannelConfig,
16
+ AiAgentZaloPersonalAction,
17
+ AiAgentZaloPersonalHistorySyncResult,
18
+ AiAgentZaloPersonalPhoneSyncJobSnapshot,
19
+ AiAgentZaloPersonalPhoneSyncResult,
20
+ AiAgentZaloPersonalQrLoginSession,
21
+ AiAgentZaloPersonalQrLoginStatus,
22
+ } from '@tuturuuu/internal-api/infrastructure';
23
+ import {
24
+ abortAiAgentZaloPersonalQrLogin,
25
+ getAiAgentZaloPersonalQrLoginStatus,
26
+ getAiAgentZaloPersonalStatus,
27
+ runAiAgentZaloPersonalAction,
28
+ startAiAgentZaloPersonalQrLogin,
29
+ } from '@tuturuuu/internal-api/infrastructure';
30
+ import Image from 'next/image';
31
+ import { useTranslations } from 'next-intl';
32
+ import type { ReactNode } from 'react';
33
+ import { useEffect, useRef, useState } from 'react';
34
+ import { Badge } from '../badge';
35
+ import { Button } from '../button';
36
+ import { toast } from '../sonner';
37
+ import {
38
+ formatZaloPersonalError,
39
+ KeyValue,
40
+ PanelSection,
41
+ } from './chat-agent-details-utils';
42
+
43
+ const QR_QUERY_KEY = 'chat-zalo-personal-qr';
44
+ const STATUS_QUERY_KEY = 'chat-zalo-personal-status';
45
+
46
+ const ACTIVE_QR_STATUSES = new Set<AiAgentZaloPersonalQrLoginStatus>([
47
+ 'pending',
48
+ 'qr_generated',
49
+ 'scanned',
50
+ 'credentials_ready',
51
+ 'authenticated',
52
+ ]);
53
+
54
+ const QR_STATUS_KEYS = {
55
+ aborted: 'agent_zalo_personal_qr_aborted',
56
+ authenticated: 'agent_zalo_personal_qr_authenticated',
57
+ credentials_ready: 'agent_zalo_personal_qr_credentials_ready',
58
+ declined: 'agent_zalo_personal_qr_declined',
59
+ expired: 'agent_zalo_personal_qr_expired',
60
+ failed: 'agent_zalo_personal_qr_failed',
61
+ pending: 'agent_zalo_personal_qr_pending',
62
+ persisted: 'agent_zalo_personal_qr_persisted',
63
+ qr_generated: 'agent_zalo_personal_qr_generated',
64
+ scanned: 'agent_zalo_personal_qr_scanned',
65
+ } as const satisfies Record<AiAgentZaloPersonalQrLoginStatus, string>;
66
+
67
+ const ACTION_LABEL_KEYS = {
68
+ start: 'agent_zalo_personal_start',
69
+ stop: 'agent_zalo_personal_stop',
70
+ 'sync-history': 'agent_zalo_personal_sync_history',
71
+ 'sync-phone': 'agent_zalo_personal_sync_phone',
72
+ validate: 'agent_zalo_personal_validate',
73
+ } as const satisfies Record<AiAgentZaloPersonalAction, string>;
74
+
75
+ const ACTION_SUCCESS_KEYS = {
76
+ start: 'agent_zalo_personal_start_success',
77
+ stop: 'agent_zalo_personal_stop_success',
78
+ validate: 'agent_zalo_personal_validate_success',
79
+ } as const satisfies Record<
80
+ Exclude<AiAgentZaloPersonalAction, 'sync-history' | 'sync-phone'>,
81
+ string
82
+ >;
83
+
84
+ export function AgentZaloPersonalPanel({
85
+ agentId,
86
+ channel,
87
+ isPending,
88
+ onRefresh,
89
+ }: {
90
+ agentId: string;
91
+ channel: AiAgentChannelConfig;
92
+ isPending: boolean;
93
+ onRefresh: () => void;
94
+ }) {
95
+ const t = useTranslations('chat');
96
+ const queryClient = useQueryClient();
97
+ const [startedSession, setStartedSession] =
98
+ useState<AiAgentZaloPersonalQrLoginSession | null>(null);
99
+ const handledTerminalRef = useRef<string | null>(null);
100
+ const sessionId = startedSession?.sessionId ?? null;
101
+ const statusQuery = useQuery({
102
+ queryFn: () => getAiAgentZaloPersonalStatus(agentId, channel.id),
103
+ queryKey: [STATUS_QUERY_KEY, agentId, channel.id],
104
+ refetchInterval: (query) =>
105
+ query.state.data?.phoneSyncJob?.status === 'running' ? 3000 : 10_000,
106
+ });
107
+ const qrQuery = useQuery({
108
+ enabled: Boolean(sessionId),
109
+ queryFn: () =>
110
+ getAiAgentZaloPersonalQrLoginStatus(agentId, channel.id, sessionId || ''),
111
+ queryKey: [QR_QUERY_KEY, agentId, channel.id, sessionId],
112
+ refetchInterval: (query) =>
113
+ isActiveQrStatus(query.state.data?.session.status) ? 2000 : false,
114
+ retry: false,
115
+ });
116
+ const session = qrQuery.data?.session ?? startedSession;
117
+ const startMutation = useMutation({
118
+ mutationFn: () => startAiAgentZaloPersonalQrLogin(agentId, channel.id),
119
+ onError: (error) =>
120
+ toast.error(error.message || t('agent_zalo_personal_qr_start_failed')),
121
+ onSuccess: (result) => {
122
+ handledTerminalRef.current = null;
123
+ setStartedSession(result.session);
124
+ },
125
+ });
126
+ const abortMutation = useMutation({
127
+ mutationFn: (targetSessionId: string) =>
128
+ abortAiAgentZaloPersonalQrLogin(agentId, channel.id, targetSessionId),
129
+ onError: (error) =>
130
+ toast.error(error.message || t('agent_zalo_personal_qr_abort_failed')),
131
+ onSuccess: (result) => {
132
+ setStartedSession(result.session);
133
+ toast.success(t('agent_zalo_personal_qr_abort_success'));
134
+ },
135
+ });
136
+ const actionMutation = useMutation({
137
+ mutationFn: (action: AiAgentZaloPersonalAction) =>
138
+ runAiAgentZaloPersonalAction(agentId, channel.id, action),
139
+ onError: (error) =>
140
+ toast.error(error.message || t('agent_zalo_personal_action_failed')),
141
+ onSuccess: (result, action) => {
142
+ queryClient.setQueryData([STATUS_QUERY_KEY, agentId, channel.id], {
143
+ phoneSyncJob: result.phoneSyncJob ?? null,
144
+ status: result.status,
145
+ });
146
+
147
+ if (action === 'sync-history') {
148
+ const sync = (result.sync ?? {
149
+ synced: 0,
150
+ threads: 0,
151
+ timedOut: false,
152
+ }) as AiAgentZaloPersonalHistorySyncResult;
153
+
154
+ toast.success(
155
+ t('agent_zalo_personal_sync_history_success', {
156
+ count: sync.synced,
157
+ threads: sync.threads,
158
+ })
159
+ );
160
+
161
+ if (sync.timedOut) {
162
+ toast.warning(t('agent_zalo_personal_sync_history_timed_out'));
163
+ }
164
+ } else if (action === 'sync-phone') {
165
+ const sync = (result.sync ?? {
166
+ error: null,
167
+ status: 'waiting_for_phone',
168
+ synced: 0,
169
+ threads: 0,
170
+ }) as AiAgentZaloPersonalPhoneSyncResult;
171
+
172
+ if (sync.status === 'failed') {
173
+ toast.error(sync.error || t('agent_zalo_personal_sync_phone_failed'));
174
+ } else if (sync.status === 'waiting_for_phone') {
175
+ toast.warning(t('agent_zalo_personal_sync_phone_waiting'));
176
+ } else if (sync.status === 'completed_no_payload') {
177
+ toast.warning(t('agent_zalo_personal_sync_phone_no_payload'));
178
+ } else {
179
+ toast.success(
180
+ t('agent_zalo_personal_sync_phone_success', {
181
+ count: sync.synced,
182
+ threads: sync.threads,
183
+ })
184
+ );
185
+
186
+ if (sync.status === 'partial') {
187
+ toast.warning(t('agent_zalo_personal_sync_phone_partial'));
188
+ }
189
+ }
190
+ } else {
191
+ toast.success(t(ACTION_SUCCESS_KEYS[action]));
192
+ }
193
+
194
+ void queryClient.invalidateQueries({
195
+ queryKey: [STATUS_QUERY_KEY, agentId, channel.id],
196
+ });
197
+ onRefresh();
198
+ },
199
+ });
200
+
201
+ useEffect(() => {
202
+ if (!session || isActiveQrStatus(session.status)) return;
203
+ const terminalKey = `${session.sessionId}:${session.status}`;
204
+ if (handledTerminalRef.current === terminalKey) return;
205
+ handledTerminalRef.current = terminalKey;
206
+
207
+ if (session.status === 'persisted') {
208
+ toast.success(t('agent_zalo_personal_qr_persisted_success'));
209
+ onRefresh();
210
+ } else if (session.status === 'failed') {
211
+ toast.error(session.error || t('agent_zalo_personal_qr_failed'));
212
+ }
213
+ }, [onRefresh, session, t]);
214
+
215
+ const listenerStatus = statusQuery.data?.status;
216
+ const phoneSyncJob = statusQuery.data?.phoneSyncJob ?? null;
217
+ const phoneSyncRunning = phoneSyncJob?.status === 'running';
218
+ const qrBusy = startMutation.isPending || abortMutation.isPending;
219
+ const actionBusy = isPending || actionMutation.isPending || phoneSyncRunning;
220
+ const listenerError =
221
+ listenerStatus?.lastError ===
222
+ 'zalo_personal_phone_sync_waiting_for_phone' && phoneSyncJob
223
+ ? null
224
+ : formatZaloPersonalError(listenerStatus?.lastError, t);
225
+ const phoneSyncJobMessage = getPhoneSyncJobMessage(phoneSyncJob, t);
226
+
227
+ return (
228
+ <>
229
+ <PanelSection
230
+ icon={<QrCode className="size-4" />}
231
+ title={t('agent_zalo_personal_qr_title')}
232
+ >
233
+ <div className="min-w-0 space-y-3 overflow-hidden">
234
+ <div className="flex items-center justify-between gap-2">
235
+ <Badge
236
+ variant={
237
+ session?.status === 'persisted' ? 'success' : 'secondary'
238
+ }
239
+ >
240
+ {session
241
+ ? t(QR_STATUS_KEYS[session.status])
242
+ : t('agent_zalo_personal_qr_not_started')}
243
+ </Badge>
244
+ {session?.expiresAt ? (
245
+ <span className="text-muted-foreground text-xs">
246
+ {new Date(session.expiresAt).toLocaleTimeString()}
247
+ </span>
248
+ ) : null}
249
+ </div>
250
+ <div className="grid min-w-0 place-items-center overflow-hidden rounded-md border bg-background p-3">
251
+ {session?.qrImageDataUrl ? (
252
+ <Image
253
+ alt={t('agent_zalo_personal_qr_alt')}
254
+ className="aspect-square w-full max-w-56 rounded-sm"
255
+ height={224}
256
+ src={session.qrImageDataUrl}
257
+ unoptimized
258
+ width={224}
259
+ />
260
+ ) : (
261
+ <div className="grid aspect-square w-full max-w-56 place-items-center rounded-sm bg-muted/30 text-muted-foreground">
262
+ {qrBusy || isActiveQrStatus(session?.status) ? (
263
+ <LoaderCircle className="size-8 animate-spin" />
264
+ ) : (
265
+ <QrCode className="size-8" />
266
+ )}
267
+ </div>
268
+ )}
269
+ </div>
270
+ {session?.scannedProfile ? (
271
+ <div className="flex items-center gap-2 rounded-md border bg-muted/20 p-2 text-sm">
272
+ <ScanLine className="size-4 text-dynamic-green" />
273
+ <span className="min-w-0 truncate">
274
+ {session.scannedProfile.displayName ??
275
+ t('agent_zalo_personal_qr_scanned')}
276
+ </span>
277
+ </div>
278
+ ) : null}
279
+ {getQrErrorMessage(session, t) ? (
280
+ <p className="break-words rounded-md border border-dynamic-red/20 bg-dynamic-red/5 p-2 text-dynamic-red text-xs">
281
+ {getQrErrorMessage(session, t)}
282
+ </p>
283
+ ) : null}
284
+ <div className="grid min-w-0 grid-cols-2 gap-2">
285
+ <Button
286
+ disabled={qrBusy}
287
+ onClick={() => startMutation.mutate()}
288
+ size="sm"
289
+ type="button"
290
+ >
291
+ {startMutation.isPending ? (
292
+ <LoaderCircle className="size-4 animate-spin" />
293
+ ) : (
294
+ <QrCode className="size-4" />
295
+ )}
296
+ {t(
297
+ session
298
+ ? 'agent_zalo_personal_qr_retry'
299
+ : 'agent_zalo_personal_qr_start'
300
+ )}
301
+ </Button>
302
+ <Button
303
+ disabled={
304
+ !sessionId || qrBusy || !isActiveQrStatus(session?.status)
305
+ }
306
+ onClick={() => sessionId && abortMutation.mutate(sessionId)}
307
+ size="sm"
308
+ type="button"
309
+ variant="secondary"
310
+ >
311
+ <X className="size-4" />
312
+ {t('agent_zalo_personal_qr_abort')}
313
+ </Button>
314
+ </div>
315
+ </div>
316
+ </PanelSection>
317
+
318
+ <PanelSection
319
+ icon={<Play className="size-4" />}
320
+ title={t('agent_zalo_personal_listener_title')}
321
+ >
322
+ <div className="min-w-0 space-y-3 overflow-hidden">
323
+ <KeyValue
324
+ label={t('agent_zalo_personal_feature')}
325
+ value={
326
+ listenerStatus
327
+ ? listenerStatus.enabled
328
+ ? t('agent_zalo_personal_enabled')
329
+ : t('agent_zalo_personal_disabled')
330
+ : t('agent_zalo_personal_not_available')
331
+ }
332
+ />
333
+ <KeyValue
334
+ label={t('agent_zalo_personal_running')}
335
+ value={
336
+ listenerStatus
337
+ ? listenerStatus.running
338
+ ? t('agent_zalo_personal_running_value')
339
+ : t('agent_zalo_personal_stopped')
340
+ : t('agent_zalo_personal_not_available')
341
+ }
342
+ />
343
+ <KeyValue
344
+ label={t('agent_zalo_personal_own_id')}
345
+ value={
346
+ listenerStatus?.ownId ??
347
+ channel.zaloPersonalOwnId ??
348
+ t('agent_zalo_personal_not_available')
349
+ }
350
+ />
351
+ {listenerError ? (
352
+ <p className="break-words rounded-md border border-dynamic-red/20 bg-dynamic-red/5 p-2 text-dynamic-red text-xs">
353
+ {listenerError}
354
+ </p>
355
+ ) : null}
356
+ {phoneSyncJobMessage ? (
357
+ <p
358
+ className={`break-words rounded-md border p-2 text-xs ${getPhoneSyncJobClassName(
359
+ phoneSyncJob
360
+ )}`}
361
+ >
362
+ {phoneSyncJobMessage}
363
+ </p>
364
+ ) : null}
365
+ <div className="grid min-w-0 grid-cols-2 gap-2">
366
+ <ActionButton
367
+ action="validate"
368
+ busy={actionBusy}
369
+ icon={<Check className="size-4" />}
370
+ onRun={actionMutation.mutate}
371
+ />
372
+ <ActionButton
373
+ action="start"
374
+ busy={actionBusy}
375
+ icon={<Play className="size-4" />}
376
+ onRun={actionMutation.mutate}
377
+ />
378
+ <ActionButton
379
+ action="sync-history"
380
+ busy={actionBusy}
381
+ icon={<RefreshCw className="size-4" />}
382
+ onRun={actionMutation.mutate}
383
+ />
384
+ <ActionButton
385
+ action="sync-phone"
386
+ busy={actionBusy}
387
+ icon={<RefreshCw className="size-4" />}
388
+ onRun={actionMutation.mutate}
389
+ />
390
+ <ActionButton
391
+ action="stop"
392
+ busy={actionBusy}
393
+ icon={<Square className="size-4" />}
394
+ onRun={actionMutation.mutate}
395
+ />
396
+ </div>
397
+ </div>
398
+ </PanelSection>
399
+ </>
400
+ );
401
+ }
402
+
403
+ function getPhoneSyncJobMessage(
404
+ job: AiAgentZaloPersonalPhoneSyncJobSnapshot | null,
405
+ t: ReturnType<typeof useTranslations>
406
+ ) {
407
+ if (!job) return null;
408
+
409
+ if (job.status === 'running') {
410
+ return t('agent_zalo_personal_sync_phone_waiting');
411
+ }
412
+
413
+ if (job.status === 'failed') {
414
+ return (
415
+ formatZaloPersonalError(job.error, t) ||
416
+ t('agent_zalo_personal_sync_phone_failed')
417
+ );
418
+ }
419
+
420
+ const sync = job.sync;
421
+
422
+ if (!sync) return null;
423
+
424
+ if (sync.status === 'failed') {
425
+ return (
426
+ formatZaloPersonalError(sync.error, t) ||
427
+ t('agent_zalo_personal_sync_phone_failed')
428
+ );
429
+ }
430
+
431
+ if (sync.status === 'waiting_for_phone') {
432
+ if (sync.pullAttempts > 0) {
433
+ return sync.requestHttpError
434
+ ? t('agent_zalo_personal_sync_phone_no_approval_http_unavailable')
435
+ : t('agent_zalo_personal_sync_phone_no_approval');
436
+ }
437
+
438
+ return t('agent_zalo_personal_sync_phone_waiting');
439
+ }
440
+
441
+ if (sync.status === 'completed_no_payload') {
442
+ return sync.requestHttpError
443
+ ? t('agent_zalo_personal_sync_phone_no_payload_http_unavailable')
444
+ : t('agent_zalo_personal_sync_phone_no_payload');
445
+ }
446
+
447
+ if (sync.status === 'partial') {
448
+ return t('agent_zalo_personal_sync_phone_partial');
449
+ }
450
+
451
+ return t('agent_zalo_personal_sync_phone_success', {
452
+ count: sync.synced,
453
+ threads: sync.threads,
454
+ });
455
+ }
456
+
457
+ function getPhoneSyncJobClassName(
458
+ job: AiAgentZaloPersonalPhoneSyncJobSnapshot | null
459
+ ) {
460
+ if (job?.status === 'failed' || job?.sync?.status === 'failed') {
461
+ return 'border-dynamic-red/20 bg-dynamic-red/5 text-dynamic-red';
462
+ }
463
+
464
+ if (job?.sync?.status === 'completed') {
465
+ return 'border-dynamic-green/20 bg-dynamic-green/5 text-dynamic-green';
466
+ }
467
+
468
+ return 'border-dynamic-yellow/20 bg-dynamic-yellow/5 text-dynamic-yellow';
469
+ }
470
+
471
+ function getQrErrorMessage(
472
+ session: AiAgentZaloPersonalQrLoginSession | null | undefined,
473
+ t: ReturnType<typeof useTranslations>
474
+ ) {
475
+ if (!session?.error) return null;
476
+
477
+ if (
478
+ session.status === 'aborted' ||
479
+ session.status === 'declined' ||
480
+ session.status === 'expired'
481
+ ) {
482
+ return t(QR_STATUS_KEYS[session.status]);
483
+ }
484
+
485
+ return session.error;
486
+ }
487
+
488
+ function ActionButton({
489
+ action,
490
+ busy,
491
+ icon,
492
+ onRun,
493
+ }: {
494
+ action: AiAgentZaloPersonalAction;
495
+ busy: boolean;
496
+ icon: ReactNode;
497
+ onRun: (action: AiAgentZaloPersonalAction) => void;
498
+ }) {
499
+ const t = useTranslations('chat');
500
+
501
+ return (
502
+ <Button
503
+ disabled={busy}
504
+ onClick={() => onRun(action)}
505
+ size="sm"
506
+ type="button"
507
+ variant="outline"
508
+ >
509
+ {busy ? <LoaderCircle className="size-4 animate-spin" /> : icon}
510
+ {t(ACTION_LABEL_KEYS[action])}
511
+ </Button>
512
+ );
513
+ }
514
+
515
+ function isActiveQrStatus(status?: AiAgentZaloPersonalQrLoginStatus) {
516
+ return status ? ACTIVE_QR_STATUSES.has(status) : false;
517
+ }
@@ -5,6 +5,7 @@ import type {
5
5
  ChatAttachment,
6
6
  ChatAttachmentDraft,
7
7
  ChatConversation,
8
+ ChatMessage,
8
9
  } from '@tuturuuu/internal-api';
9
10
  import { cn } from '@tuturuuu/utils/format';
10
11
  import { usePathname, useSearchParams } from 'next/navigation';
@@ -152,11 +153,17 @@ export function ChatWorkspace({
152
153
  conversationId: selectedVirtualReadOnly ? null : activeConversationId,
153
154
  wsId,
154
155
  });
156
+ const fetchedMessages = selectedVirtualReadOnly
157
+ ? []
158
+ : flattenChatMessagePages(messagesQuery.data);
155
159
  const messages = selectedVirtualReadOnly
156
160
  ? selectedConversation?.latestMessage
157
161
  ? [selectedConversation.latestMessage]
158
162
  : []
159
- : flattenChatMessagePages(messagesQuery.data);
163
+ : mergeConversationLatestMessage(
164
+ fetchedMessages,
165
+ selectedConversation?.latestMessage
166
+ );
160
167
  const sendMessage = useSendChatMessage({
161
168
  conversationId: activeConversationId,
162
169
  currentUserId,
@@ -567,6 +574,29 @@ export function ChatWorkspace({
567
574
  );
568
575
  }
569
576
 
577
+ function mergeConversationLatestMessage(
578
+ messages: ChatMessage[],
579
+ latestMessage?: ChatMessage | null
580
+ ) {
581
+ if (
582
+ !latestMessage ||
583
+ messages.some((message) => message.id === latestMessage.id)
584
+ ) {
585
+ return messages;
586
+ }
587
+
588
+ return [...messages, latestMessage].sort(
589
+ (left, right) =>
590
+ readChatMessageTimestamp(left.createdAt) -
591
+ readChatMessageTimestamp(right.createdAt)
592
+ );
593
+ }
594
+
595
+ function readChatMessageTimestamp(value: string) {
596
+ const timestamp = Date.parse(value);
597
+ return Number.isFinite(timestamp) ? timestamp : 0;
598
+ }
599
+
570
600
  const POSTGRES_UUID_PATTERN =
571
601
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
572
602
 
@@ -1,6 +1,12 @@
1
+ import { QueryClient } from '@tanstack/react-query';
1
2
  import type { ChatMessage } from '@tuturuuu/internal-api';
2
3
  import { describe, expect, it } from 'vitest';
3
- import { mergeCachedMessages } from './hooks-messages';
4
+ import {
5
+ type ChatMessagesPage,
6
+ mergeCachedMessages,
7
+ patchCachedMessages,
8
+ } from './hooks-messages';
9
+ import { chatQueryKeys } from './query-keys';
4
10
 
5
11
  function createMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
6
12
  return {
@@ -65,3 +71,41 @@ describe('mergeCachedMessages', () => {
65
71
  ).toEqual(['message-user', 'message-assistant']);
66
72
  });
67
73
  });
74
+
75
+ describe('patchCachedMessages', () => {
76
+ it('patches infinite message pages used by the chat workspace', () => {
77
+ const queryClient = new QueryClient();
78
+ const wsId = 'workspace-1';
79
+ const conversationId = 'conversation-1';
80
+ const message = createMessage();
81
+ const queryKey = chatQueryKeys.messagesInfinite(wsId, conversationId, 80);
82
+
83
+ queryClient.setQueryData<{
84
+ pageParams: (string | null)[];
85
+ pages: ChatMessagesPage[];
86
+ }>(queryKey, {
87
+ pageParams: [null],
88
+ pages: [
89
+ {
90
+ hasMore: false,
91
+ limit: 80,
92
+ messages: [],
93
+ nextBefore: null,
94
+ },
95
+ ],
96
+ });
97
+
98
+ patchCachedMessages(queryClient, wsId, conversationId, (current) =>
99
+ mergeCachedMessages(current, [message])
100
+ );
101
+
102
+ expect(
103
+ queryClient
104
+ .getQueryData<{
105
+ pageParams: (string | null)[];
106
+ pages: ChatMessagesPage[];
107
+ }>(queryKey)
108
+ ?.pages[0]?.messages.map(({ id }) => id)
109
+ ).toEqual(['message-1']);
110
+ });
111
+ });
@@ -353,7 +353,7 @@ function patchCachedMessage(
353
353
  );
354
354
  }
355
355
 
356
- function patchCachedMessages(
356
+ export function patchCachedMessages(
357
357
  queryClient: QueryClient,
358
358
  wsId: string,
359
359
  conversationId: string,
@@ -3,6 +3,7 @@
3
3
  import { type QueryClient, useQueryClient } from '@tanstack/react-query';
4
4
  import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
5
5
  import { useEffect } from 'react';
6
+ import { mergeCachedMessages, patchCachedMessages } from './hooks-messages';
6
7
  import { chatQueryKeys } from './query-keys';
7
8
 
8
9
  type ChatRealtimeEvent =
@@ -137,21 +138,17 @@ function patchMessage(
137
138
  wsId: string,
138
139
  message: ChatMessage
139
140
  ) {
140
- queryClient.setQueriesData<ChatMessage[]>(
141
- {
142
- queryKey: [
143
- ...chatQueryKeys.all(wsId),
144
- 'messages',
145
- message.conversationId,
146
- ],
147
- },
148
- (current = []) => {
149
- const existingIndex = current.findIndex((item) => item.id === message.id);
150
- if (existingIndex < 0) return [...current, message];
151
-
152
- return current.map((item, index) =>
153
- index === existingIndex ? message : item
154
- );
155
- }
141
+ patchCachedMessages(queryClient, wsId, message.conversationId, (current) =>
142
+ mergeCachedMessages(current, [message])
156
143
  );
144
+ queryClient.invalidateQueries({
145
+ queryKey: [...chatQueryKeys.all(wsId), 'messages', message.conversationId],
146
+ });
147
+ queryClient.invalidateQueries({
148
+ queryKey: [
149
+ ...chatQueryKeys.all(wsId),
150
+ 'messages-infinite',
151
+ message.conversationId,
152
+ ],
153
+ });
157
154
  }