@tuturuuu/ui 0.18.0 → 0.19.1

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 (68) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/package.json +76 -71
  3. package/src/components/ui/calendar-app/calendar-client-page.tsx +19 -1
  4. package/src/components/ui/calendar-app/calendar-page-shell.tsx +37 -16
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager-helpers.ts +70 -0
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +516 -578
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +1 -0
  8. package/src/components/ui/calendar-app/components/calendar-sync-settings-panel.tsx +155 -0
  9. package/src/components/ui/calendar-app/components/merge-provider-calendars.test.ts +46 -0
  10. package/src/components/ui/calendar-app/components/merge-provider-calendars.ts +81 -0
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +22 -129
  12. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +17 -3
  13. package/src/components/ui/chat/chat-agent-details-utils.tsx +2 -0
  14. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +17 -6
  15. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +35 -0
  16. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +17 -0
  17. package/src/components/ui/custom/nav-link.test.tsx +1 -0
  18. package/src/components/ui/custom/nav-link.tsx +4 -1
  19. package/src/components/ui/custom/settings-dialog-shell.tsx +23 -14
  20. package/src/components/ui/custom/statistic-card.tsx +3 -5
  21. package/src/components/ui/custom/structure.test.tsx +4 -0
  22. package/src/components/ui/custom/user-filters.tsx +4 -7
  23. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +1 -1
  24. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +1 -1
  25. package/src/components/ui/custom/workspace-select.tsx +38 -53
  26. package/src/components/ui/finance/analytics/category-spending-chart.test.tsx +3 -3
  27. package/src/components/ui/finance/budgets/budget-card.test.tsx +3 -3
  28. package/src/components/ui/finance/debts/debt-loan-card.test.tsx +3 -3
  29. package/src/components/ui/finance/debts/debt-loan-detail-cards.test.tsx +3 -3
  30. package/src/components/ui/finance/invoices/charts/invoice-totals-chart.test.tsx +3 -3
  31. package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
  32. package/src/components/ui/finance/invoices/components/invoice-customer-select-card.tsx +1 -1
  33. package/src/components/ui/finance/shared/numbers-visibility-toggle.test.tsx +1 -1
  34. package/src/components/ui/finance/tags/tag-manager.test.tsx +2 -2
  35. package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
  36. package/src/components/ui/finance/transactions/columns.test.tsx +2 -2
  37. package/src/components/ui/finance/transactions/confidential-field.test.tsx +2 -2
  38. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +2 -2
  39. package/src/components/ui/finance/transactions/transaction-statistics.test.tsx +3 -3
  40. package/src/components/ui/finance/wallets/form.test.tsx +3 -3
  41. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +10 -0
  42. package/src/components/ui/legacy/calendar/calendar-cell.tsx +1 -1
  43. package/src/components/ui/storefront/accent-button.tsx +4 -1
  44. package/src/components/ui/storefront/browse-panel.tsx +6 -1
  45. package/src/components/ui/storefront/cart-summary-parts.tsx +18 -4
  46. package/src/components/ui/storefront/cart-summary.tsx +22 -6
  47. package/src/components/ui/storefront/hero-panel.tsx +13 -17
  48. package/src/components/ui/storefront/index.ts +2 -0
  49. package/src/components/ui/storefront/listing-card.tsx +6 -4
  50. package/src/components/ui/storefront/product-detail.tsx +51 -21
  51. package/src/components/ui/storefront/product-dialog.tsx +8 -1
  52. package/src/components/ui/storefront/storefront-surface.test.tsx +79 -3
  53. package/src/components/ui/storefront/storefront-surface.tsx +151 -100
  54. package/src/components/ui/storefront/types.ts +3 -0
  55. package/src/components/ui/storefront/utils.ts +5 -1
  56. package/src/components/ui/text-editor/__tests__/task-mention-checkbox.test.ts +23 -6
  57. package/src/components/ui/text-editor/__tests__/upload-placeholder.test.ts +1 -10
  58. package/src/components/ui/text-editor/color-controls.tsx +1 -1
  59. package/src/globals.css +20 -0
  60. package/src/globals.test.ts +75 -0
  61. package/src/hooks/__tests__/use-calendar-sync.test.tsx +103 -1
  62. package/src/hooks/use-calendar-sync.tsx +51 -14
  63. package/src/lib/task-personal-external.ts +1 -1
  64. package/vendor/xlsx/LICENSE +201 -0
  65. package/vendor/xlsx/types/index.d.ts +1065 -0
  66. package/vendor/xlsx/xlsx.js +28103 -0
  67. package/vendor/xlsx/xlsx.mjs +28228 -0
  68. package/vendor/xlsx-0.20.3.tgz +0 -0
@@ -8,6 +8,7 @@ import {
8
8
  } from './use-calendar-connections-manager';
9
9
 
10
10
  export type { CalendarConnectionsUnifiedVariant };
11
+ export { useCalendarConnectionsManager };
11
12
 
12
13
  export default function CalendarConnectionsUnified({
13
14
  wsId,
@@ -0,0 +1,155 @@
1
+ import { Badge } from '../../badge';
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectTrigger,
7
+ SelectValue,
8
+ } from '../../select';
9
+ import { Switch } from '../../switch';
10
+ import type { CalendarConnectionsManagerState } from './use-calendar-connections-manager';
11
+
12
+ export function CalendarSyncSettingsPanel({
13
+ state,
14
+ }: {
15
+ state: CalendarConnectionsManagerState;
16
+ }) {
17
+ const { syncPreferencesData, syncPreferencesMutation, t } = state;
18
+ const externalOptions =
19
+ syncPreferencesData?.options.filter(
20
+ (option) => option.provider !== 'tuturuuu'
21
+ ) ?? [];
22
+ const unavailable = syncPreferencesData?.settingsAvailable === false;
23
+ const disabled =
24
+ !syncPreferencesData || unavailable || syncPreferencesMutation.isPending;
25
+
26
+ return (
27
+ <div className="space-y-3 rounded-lg border p-3">
28
+ <div>
29
+ <h4 className="font-medium text-sm">
30
+ {t('two_way_sync') || 'Two-way sync'}
31
+ </h4>
32
+ <p className="text-muted-foreground text-xs">
33
+ {t('two_way_sync_desc') ||
34
+ 'Control how Tuturuuu imports provider changes and mirrors native events outward.'}
35
+ </p>
36
+ </div>
37
+
38
+ {unavailable && (
39
+ <p className="rounded-md bg-muted px-3 py-2 text-muted-foreground text-xs">
40
+ {t('calendar_sync_settings_unavailable') ||
41
+ 'Sync controls will be available after the latest calendar migration is applied.'}
42
+ </p>
43
+ )}
44
+
45
+ <div className="space-y-3">
46
+ <SyncToggle
47
+ title={t('import_external_changes') || 'Import external changes'}
48
+ description={
49
+ t('import_external_changes_desc') ||
50
+ 'Bring Google and Outlook event changes into Tuturuuu.'
51
+ }
52
+ checked={syncPreferencesData?.inboundSyncEnabled ?? true}
53
+ disabled={disabled}
54
+ onCheckedChange={(checked) =>
55
+ syncPreferencesMutation.mutate({ inboundSyncEnabled: checked })
56
+ }
57
+ />
58
+ <SyncToggle
59
+ title={
60
+ t('sync_tuturuuu_events_outward') || 'Sync Tuturuuu events outward'
61
+ }
62
+ description={
63
+ t('sync_tuturuuu_events_outward_desc') ||
64
+ 'Mirror native Tuturuuu event creates and edits to a connected provider calendar.'
65
+ }
66
+ checked={syncPreferencesData?.outboundSyncEnabled ?? false}
67
+ disabled={disabled || externalOptions.length === 0}
68
+ onCheckedChange={(checked) =>
69
+ syncPreferencesMutation.mutate({ outboundSyncEnabled: checked })
70
+ }
71
+ />
72
+ </div>
73
+
74
+ <Select
75
+ value={syncPreferencesData?.defaultOutboundConnectionId ?? 'none'}
76
+ onValueChange={(value) =>
77
+ syncPreferencesMutation.mutate({
78
+ defaultOutboundConnectionId: value === 'none' ? null : value,
79
+ })
80
+ }
81
+ disabled={disabled || externalOptions.length === 0}
82
+ >
83
+ <SelectTrigger className="w-full">
84
+ <SelectValue
85
+ placeholder={
86
+ t('choose_outbound_calendar') || 'Choose outbound calendar'
87
+ }
88
+ />
89
+ </SelectTrigger>
90
+ <SelectContent>
91
+ <SelectItem value="none">
92
+ {t('no_outbound_calendar') || 'No outbound calendar'}
93
+ </SelectItem>
94
+ {externalOptions.map((option) => (
95
+ <SelectItem key={option.id} value={option.connectionId}>
96
+ <div className="flex min-w-0 items-center gap-2">
97
+ <span
98
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
99
+ style={{ backgroundColor: option.color ?? undefined }}
100
+ />
101
+ <span className="truncate">{option.label}</span>
102
+ <Badge variant="secondary" className="capitalize">
103
+ {option.provider}
104
+ </Badge>
105
+ </div>
106
+ </SelectItem>
107
+ ))}
108
+ </SelectContent>
109
+ </Select>
110
+
111
+ <div className="flex items-center justify-between gap-3 rounded-md bg-muted/40 px-3 py-2">
112
+ <div className="min-w-0">
113
+ <p className="font-medium text-xs">
114
+ {t('conflict_policy') || 'Conflict policy'}
115
+ </p>
116
+ <p className="text-muted-foreground text-xs">
117
+ {t('latest_write_wins_desc') ||
118
+ 'When both sides changed, the most recently written event wins.'}
119
+ </p>
120
+ </div>
121
+ <Badge variant="outline">
122
+ {t('latest_write_wins') || 'Latest write wins'}
123
+ </Badge>
124
+ </div>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ function SyncToggle({
130
+ checked,
131
+ description,
132
+ disabled,
133
+ onCheckedChange,
134
+ title,
135
+ }: {
136
+ checked: boolean;
137
+ description: string;
138
+ disabled: boolean;
139
+ onCheckedChange: (checked: boolean) => void;
140
+ title: string;
141
+ }) {
142
+ return (
143
+ <div className="flex items-center justify-between gap-3">
144
+ <div className="min-w-0">
145
+ <p className="font-medium text-sm">{title}</p>
146
+ <p className="text-muted-foreground text-xs">{description}</p>
147
+ </div>
148
+ <Switch
149
+ checked={checked}
150
+ onCheckedChange={onCheckedChange}
151
+ disabled={disabled}
152
+ />
153
+ </div>
154
+ );
155
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { mergeProviderCalendarsByAccount } from './merge-provider-calendars';
3
+
4
+ describe('mergeProviderCalendarsByAccount', () => {
5
+ it('keeps persisted calendars visible while provider refresh is unavailable', () => {
6
+ const result = mergeProviderCalendarsByAccount({
7
+ wsId: 'workspace-1',
8
+ accounts: [
9
+ {
10
+ id: 'account-1',
11
+ provider: 'google',
12
+ account_email: 'member@example.com',
13
+ account_name: 'Member',
14
+ is_active: true,
15
+ created_at: '2026-07-20T00:00:00Z',
16
+ expires_at: null,
17
+ },
18
+ ],
19
+ calendarConnections: [
20
+ {
21
+ id: 'connection-1',
22
+ ws_id: 'workspace-1',
23
+ calendar_id: 'provider-calendar-1',
24
+ calendar_name: 'Stored calendar',
25
+ is_enabled: true,
26
+ color: '#123456',
27
+ provider: 'google',
28
+ auth_token_id: 'account-1',
29
+ created_at: '2026-07-20T00:00:00Z',
30
+ updated_at: '2026-07-20T00:00:00Z',
31
+ },
32
+ ],
33
+ liveCalendarsByAccount: { 'account-1': [] },
34
+ });
35
+
36
+ expect(result['account-1']).toEqual([
37
+ expect.objectContaining({
38
+ id: 'connection-1',
39
+ calendar_name: 'Stored calendar',
40
+ is_enabled: true,
41
+ connectionExists: true,
42
+ isFromAPI: false,
43
+ }),
44
+ ]);
45
+ });
46
+ });
@@ -0,0 +1,81 @@
1
+ import type { CalendarConnection } from '../../../../hooks/use-calendar-sync';
2
+ import type { ConnectedAccount, ProviderCalendar } from './calendar-types';
3
+
4
+ type SyncableCalendarConnection = CalendarConnection & {
5
+ sync_delete_enabled?: boolean | null;
6
+ sync_inbound_enabled?: boolean | null;
7
+ sync_outbound_enabled?: boolean | null;
8
+ };
9
+
10
+ export type ManagedProviderCalendar = {
11
+ accessRole: string;
12
+ accountId: string;
13
+ calendar_id: string;
14
+ calendar_name: string;
15
+ color: string | null;
16
+ connectionExists: boolean;
17
+ id: string;
18
+ is_enabled: boolean;
19
+ isFromAPI: boolean;
20
+ syncDeleteEnabled: boolean;
21
+ syncInboundEnabled: boolean;
22
+ syncOutboundEnabled: boolean;
23
+ ws_id: string;
24
+ };
25
+
26
+ export function mergeProviderCalendarsByAccount({
27
+ accounts,
28
+ calendarConnections,
29
+ liveCalendarsByAccount,
30
+ wsId,
31
+ }: {
32
+ accounts: ConnectedAccount[];
33
+ calendarConnections: CalendarConnection[];
34
+ liveCalendarsByAccount: Record<string, ProviderCalendar[]>;
35
+ wsId: string;
36
+ }) {
37
+ return accounts.reduce<Record<string, ManagedProviderCalendar[]>>(
38
+ (result, account) => {
39
+ const liveCalendars = liveCalendarsByAccount[account.id] ?? [];
40
+ const persistedConnections = calendarConnections.filter(
41
+ (connection) => connection.auth_token_id === account.id
42
+ ) as SyncableCalendarConnection[];
43
+ const liveByCalendarId = new Map(
44
+ liveCalendars.map((calendar) => [calendar.id, calendar])
45
+ );
46
+ const calendarIds = new Set([
47
+ ...persistedConnections.map((connection) => connection.calendar_id),
48
+ ...liveCalendars.map((calendar) => calendar.id),
49
+ ]);
50
+
51
+ result[account.id] = [...calendarIds].map((calendarId) => {
52
+ const liveCalendar = liveByCalendarId.get(calendarId);
53
+ const connection = persistedConnections.find(
54
+ (candidate) => candidate.calendar_id === calendarId
55
+ );
56
+
57
+ return {
58
+ id: connection?.id ?? calendarId,
59
+ ws_id: wsId,
60
+ calendar_id: calendarId,
61
+ calendar_name:
62
+ liveCalendar?.name ?? connection?.calendar_name ?? 'Calendar',
63
+ is_enabled: connection?.is_enabled ?? false,
64
+ color:
65
+ connection?.color ?? liveCalendar?.backgroundColor ?? '#4285f4',
66
+ isFromAPI: !!liveCalendar,
67
+ connectionExists: !!connection,
68
+ accountId: account.id,
69
+ accessRole:
70
+ liveCalendar?.accessRole ?? connection?.access_role ?? 'reader',
71
+ syncDeleteEnabled: connection?.sync_delete_enabled ?? true,
72
+ syncInboundEnabled: connection?.sync_inbound_enabled ?? true,
73
+ syncOutboundEnabled: connection?.sync_outbound_enabled ?? false,
74
+ };
75
+ });
76
+
77
+ return result;
78
+ },
79
+ {}
80
+ );
81
+ }
@@ -1,6 +1,5 @@
1
1
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
2
  import {
3
- type CalendarSourceOption,
4
3
  getGoogleCalendarAuthUrl,
5
4
  getWorkspaceCalendarDefaultSource,
6
5
  getWorkspaceCalendarSyncPreferences,
@@ -14,67 +13,22 @@ import { useTranslations } from 'next-intl';
14
13
  import { useMemo, useState } from 'react';
15
14
  import { useCalendarSync } from '../../../../hooks/use-calendar-sync';
16
15
  import { toast } from '../../sonner';
16
+ import {
17
+ type AccountsResponse,
18
+ getCalendarColor,
19
+ type ManualSyncResponse,
20
+ sourceInputFromOption,
21
+ type WorkspaceCalendarsResponse,
22
+ } from './calendar-connections-manager-helpers';
17
23
  import type {
18
24
  AuthResponse,
19
25
  CalendarSyncHealth,
20
- ConnectedAccount,
21
26
  ProviderCalendar,
22
27
  } from './calendar-types';
23
-
24
- interface AccountsResponse {
25
- accounts: ConnectedAccount[];
26
- grouped: {
27
- google: ConnectedAccount[];
28
- microsoft: ConnectedAccount[];
29
- };
30
- total: number;
31
- }
32
-
33
- interface WorkspaceCalendar {
34
- id: string;
35
- ws_id: string;
36
- name: string;
37
- description: string | null;
38
- color: string | null;
39
- calendar_type: 'primary' | 'tasks' | 'habits' | 'custom';
40
- is_system: boolean;
41
- is_enabled: boolean;
42
- position: number;
43
- }
44
-
45
- interface WorkspaceCalendarsResponse {
46
- calendars: WorkspaceCalendar[];
47
- grouped: {
48
- system: WorkspaceCalendar[];
49
- custom: WorkspaceCalendar[];
50
- };
51
- total: number;
52
- }
53
-
54
- interface ManualSyncResponse {
55
- ok: boolean;
56
- alreadyRunning?: boolean;
57
- code?: string;
58
- error?: string;
59
- retryAfterSeconds?: number | null;
60
- }
28
+ import { mergeProviderCalendarsByAccount } from './merge-provider-calendars';
61
29
 
62
30
  export type CalendarConnectionsUnifiedVariant = 'compact' | 'settings';
63
31
 
64
- function sourceInputFromOption(option: CalendarSourceOption) {
65
- if (option.provider === 'tuturuuu') {
66
- return {
67
- provider: 'tuturuuu' as const,
68
- workspaceCalendarId: option.workspaceCalendarId,
69
- };
70
- }
71
-
72
- return {
73
- provider: option.provider,
74
- connectionId: option.connectionId,
75
- };
76
- }
77
-
78
32
  export function useCalendarConnectionsManager(wsId: string) {
79
33
  const t = useTranslations('calendar');
80
34
  const router = useRouter();
@@ -294,22 +248,6 @@ export function useCalendarConnectionsManager(wsId: string) {
294
248
  const systemCalendars = workspaceCalendarsData?.grouped?.system || [];
295
249
  const customCalendars = workspaceCalendarsData?.grouped?.custom || [];
296
250
 
297
- // Calendar color mapping helper
298
- const getCalendarColor = (color: string): string => {
299
- const colorMap: Record<string, string> = {
300
- BLUE: '#3b82f6',
301
- RED: '#ef4444',
302
- GREEN: '#22c55e',
303
- YELLOW: '#eab308',
304
- ORANGE: '#f97316',
305
- PURPLE: '#a855f7',
306
- PINK: '#ec4899',
307
- CYAN: '#06b6d4',
308
- GRAY: '#6b7280',
309
- };
310
- return colorMap[color.toUpperCase()] || color;
311
- };
312
-
313
251
  // Calculate total enabled calendars count
314
252
  const tuturuuuEnabledCount = workspaceCalendars.filter(
315
253
  (c) => c.is_enabled
@@ -648,75 +586,29 @@ export function useCalendarConnectionsManager(wsId: string) {
648
586
  `/api/v1/calendar/auth/provider-calendars?wsId=${wsId}`,
649
587
  { cache: 'no-store' }
650
588
  );
651
- if (!response.ok) return { calendars: [], byAccount: {} };
589
+ if (!response.ok)
590
+ return { calendars: [], byAccount: {}, accountStatuses: {} };
652
591
  return response.json() as Promise<{
653
592
  calendars: ProviderCalendar[];
654
593
  byAccount: Record<string, ProviderCalendar[]>;
594
+ accountStatuses: Record<
595
+ string,
596
+ { state: 'connected' | 'reconnect_required' }
597
+ >;
655
598
  }>;
656
599
  },
657
600
  staleTime: 30_000,
658
601
  });
659
602
 
660
603
  const calendarsByAccountFromAPI = googleCalendarsData?.byAccount || {};
604
+ const providerAccountStatuses = googleCalendarsData?.accountStatuses || {};
661
605
 
662
- // Merge API calendars with existing connections for visibility state
663
- const calendarsByAccount = accounts.reduce(
664
- (acc, account) => {
665
- const apiCalendars = calendarsByAccountFromAPI[account.id] || [];
666
-
667
- // Map API calendars to connection-like objects, merging with existing connections
668
- acc[account.id] = apiCalendars.map((apiCal) => {
669
- // Find existing connection for this calendar
670
- const existingConnection = calendarConnections.find(
671
- (conn) =>
672
- conn.calendar_id === apiCal.id && conn.auth_token_id === account.id
673
- ) as
674
- | ((typeof calendarConnections)[number] & {
675
- sync_delete_enabled?: boolean | null;
676
- sync_inbound_enabled?: boolean | null;
677
- sync_outbound_enabled?: boolean | null;
678
- })
679
- | undefined;
680
-
681
- return {
682
- id: existingConnection?.id || apiCal.id,
683
- ws_id: wsId,
684
- calendar_id: apiCal.id,
685
- calendar_name: apiCal.name,
686
- is_enabled: existingConnection?.is_enabled ?? false, // Default to hidden if no connection exists
687
- color: existingConnection?.color || apiCal.backgroundColor,
688
- isFromAPI: true,
689
- connectionExists: !!existingConnection,
690
- accountId: account.id,
691
- accessRole: apiCal.accessRole,
692
- syncDeleteEnabled: existingConnection?.sync_delete_enabled ?? true,
693
- syncInboundEnabled: existingConnection?.sync_inbound_enabled ?? true,
694
- syncOutboundEnabled:
695
- existingConnection?.sync_outbound_enabled ?? false,
696
- };
697
- });
698
-
699
- return acc;
700
- },
701
- {} as Record<
702
- string,
703
- Array<{
704
- id: string;
705
- ws_id: string;
706
- calendar_id: string;
707
- calendar_name: string;
708
- is_enabled: boolean;
709
- color: string | null;
710
- isFromAPI: boolean;
711
- connectionExists: boolean;
712
- accountId: string;
713
- accessRole: string;
714
- syncDeleteEnabled: boolean;
715
- syncInboundEnabled: boolean;
716
- syncOutboundEnabled: boolean;
717
- }>
718
- >
719
- );
606
+ const calendarsByAccount = mergeProviderCalendarsByAccount({
607
+ accounts,
608
+ calendarConnections,
609
+ liveCalendarsByAccount: calendarsByAccountFromAPI,
610
+ wsId,
611
+ });
720
612
 
721
613
  // Note: Removed early return - we always show the full UI now so Tuturuuu calendars are visible
722
614
 
@@ -724,6 +616,7 @@ export function useCalendarConnectionsManager(wsId: string) {
724
616
  accounts,
725
617
  calendarConnections,
726
618
  calendarsByAccount,
619
+ providerAccountStatuses,
727
620
  createCalendarMutation,
728
621
  customCalendars,
729
622
  defaultSourceData,
@@ -284,7 +284,7 @@ describe('AgentOperationsPanel', () => {
284
284
  });
285
285
  });
286
286
 
287
- it('keeps personal Zalo actions busy while a phone sync job is running', async () => {
287
+ it('keeps only phone-sync cancellation available while a job is running', async () => {
288
288
  mocks.getAiAgentZaloPersonalStatus.mockResolvedValue({
289
289
  phoneSyncJob: {
290
290
  completedAt: null,
@@ -338,12 +338,26 @@ describe('AgentOperationsPanel', () => {
338
338
 
339
339
  await waitFor(() => {
340
340
  expect(
341
- screen.getByText('agent_zalo_personal_sync_phone').closest('button')
342
- ).toBeDisabled();
341
+ screen
342
+ .getByText('agent_zalo_personal_sync_phone_cancel')
343
+ .closest('button')
344
+ ).toBeEnabled();
343
345
  });
346
+ expect(
347
+ screen.getByText('agent_zalo_personal_validate').closest('button')
348
+ ).toBeDisabled();
344
349
  expect(
345
350
  screen.getByText('agent_zalo_personal_sync_phone_waiting')
346
351
  ).toBeInTheDocument();
352
+
353
+ fireEvent.click(screen.getByText('agent_zalo_personal_sync_phone_cancel'));
354
+ await waitFor(() => {
355
+ expect(mocks.runAiAgentZaloPersonalAction).toHaveBeenCalledWith(
356
+ 'agent-1',
357
+ 'channel-1',
358
+ 'cancel-sync-phone'
359
+ );
360
+ });
347
361
  });
348
362
 
349
363
  it('warns when personal Zalo phone transfer is approved but returns no payload', async () => {
@@ -188,6 +188,8 @@ const ZALO_PERSONAL_ERROR_KEYS = {
188
188
  zalo_personal_credentials_missing: 'agent_zalo_personal_credentials_missing',
189
189
  zalo_personal_feature_disabled: 'agent_zalo_personal_feature_disabled',
190
190
  zalo_personal_phone_sync_timed_out: 'agent_zalo_personal_sync_phone_failed',
191
+ zalo_personal_phone_sync_cancelled:
192
+ 'agent_zalo_personal_sync_phone_cancelled',
191
193
  zalo_personal_phone_sync_no_payload:
192
194
  'agent_zalo_personal_sync_phone_no_payload',
193
195
  zalo_personal_phone_sync_waiting_for_phone:
@@ -65,6 +65,7 @@ const QR_STATUS_KEYS = {
65
65
  } as const satisfies Record<AiAgentZaloPersonalQrLoginStatus, string>;
66
66
 
67
67
  const ACTION_LABEL_KEYS = {
68
+ 'cancel-sync-phone': 'agent_zalo_personal_sync_phone_cancel',
68
69
  start: 'agent_zalo_personal_start',
69
70
  stop: 'agent_zalo_personal_stop',
70
71
  'sync-history': 'agent_zalo_personal_sync_history',
@@ -73,6 +74,7 @@ const ACTION_LABEL_KEYS = {
73
74
  } as const satisfies Record<AiAgentZaloPersonalAction, string>;
74
75
 
75
76
  const ACTION_SUCCESS_KEYS = {
77
+ 'cancel-sync-phone': 'agent_zalo_personal_sync_phone_cancelled',
76
78
  start: 'agent_zalo_personal_start_success',
77
79
  stop: 'agent_zalo_personal_stop_success',
78
80
  validate: 'agent_zalo_personal_validate_success',
@@ -381,12 +383,21 @@ export function AgentZaloPersonalPanel({
381
383
  icon={<RefreshCw className="size-4" />}
382
384
  onRun={actionMutation.mutate}
383
385
  />
384
- <ActionButton
385
- action="sync-phone"
386
- busy={actionBusy}
387
- icon={<RefreshCw className="size-4" />}
388
- onRun={actionMutation.mutate}
389
- />
386
+ {phoneSyncRunning ? (
387
+ <ActionButton
388
+ action="cancel-sync-phone"
389
+ busy={isPending || actionMutation.isPending}
390
+ icon={<X className="size-4" />}
391
+ onRun={actionMutation.mutate}
392
+ />
393
+ ) : (
394
+ <ActionButton
395
+ action="sync-phone"
396
+ busy={actionBusy}
397
+ icon={<RefreshCw className="size-4" />}
398
+ onRun={actionMutation.mutate}
399
+ />
400
+ )}
390
401
  <ActionButton
391
402
  action="stop"
392
403
  busy={actionBusy}
@@ -79,6 +79,10 @@ function renderShell() {
79
79
 
80
80
  describe('SettingsDialogShell keyboard navigation', () => {
81
81
  beforeEach(() => {
82
+ Object.defineProperty(window, 'innerWidth', {
83
+ configurable: true,
84
+ value: 1024,
85
+ });
82
86
  window.matchMedia = vi.fn().mockImplementation((query: string) => ({
83
87
  addEventListener: vi.fn(),
84
88
  dispatchEvent: vi.fn(),
@@ -105,6 +109,37 @@ describe('SettingsDialogShell keyboard navigation', () => {
105
109
  ).toBeGreaterThan(0);
106
110
  });
107
111
 
112
+ it('keeps the mobile selector first, closes with an X, and uses compact padding', () => {
113
+ Object.defineProperty(window, 'innerWidth', {
114
+ configurable: true,
115
+ value: 375,
116
+ });
117
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
118
+ addEventListener: vi.fn(),
119
+ dispatchEvent: vi.fn(),
120
+ matches: true,
121
+ media: query,
122
+ onchange: null,
123
+ removeEventListener: vi.fn(),
124
+ }));
125
+
126
+ renderShell();
127
+
128
+ const selector = screen.getByRole('combobox');
129
+ const closeIcon = screen.getByTestId('settings-mobile-close-icon');
130
+ const closeButton = closeIcon.closest('button');
131
+
132
+ expect(closeButton).toHaveAccessibleName('common.close');
133
+ expect(
134
+ selector.compareDocumentPosition(closeButton as Node) &
135
+ Node.DOCUMENT_POSITION_FOLLOWING
136
+ ).toBeTruthy();
137
+ expect(closeButton?.parentElement).toHaveClass('px-2');
138
+ expect(
139
+ screen.getByTestId('active-tab').parentElement?.parentElement
140
+ ).toHaveClass('p-2', 'md:p-6');
141
+ });
142
+
108
143
  it('keeps settings groups expanded by default', () => {
109
144
  renderShell();
110
145
 
@@ -58,6 +58,23 @@ describe('mergeWorkspaceSelectWorkspaces', () => {
58
58
  /<AvatarFallback[\s\S]*<AvatarImage/u
59
59
  );
60
60
  });
61
+
62
+ it('lays out the short create and join actions in the command item grid', () => {
63
+ const workspaceSelectSource = readFileSync(
64
+ join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
65
+ 'utf8'
66
+ );
67
+
68
+ expect(workspaceSelectSource).toContain(
69
+ '[&_[cmdk-group-items]]:grid-cols-2'
70
+ );
71
+ expect(workspaceSelectSource).toContain(
72
+ "t('common.create_workspace_action')"
73
+ );
74
+ expect(workspaceSelectSource).toContain(
75
+ "t('common.join_workspace_action')"
76
+ );
77
+ });
61
78
  });
62
79
 
63
80
  describe('normalizeWorkspaceSwitchPath', () => {
@@ -158,6 +158,7 @@ describe('NavLink', () => {
158
158
  )?.[0];
159
159
 
160
160
  expect(event).toBeInstanceOf(CustomEvent);
161
+ expect(event?.cancelable).toBe(true);
161
162
  expect((event as CustomEvent).detail).toEqual({
162
163
  settingsTab: 'workspace_billing',
163
164
  });
@@ -371,7 +371,10 @@ export function NavLink({
371
371
  : undefined;
372
372
 
373
373
  window.dispatchEvent(
374
- new CustomEvent(SETTINGS_DIALOG_OPEN_INTENT_EVENT, { detail })
374
+ new CustomEvent(SETTINGS_DIALOG_OPEN_INTENT_EVENT, {
375
+ cancelable: true,
376
+ detail,
377
+ })
375
378
  );
376
379
  onClick();
377
380
  } else if (hasSubMenu) {