@tuturuuu/ui 0.30.2 → 0.32.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 (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
  16. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  17. package/src/components/ui/custom/nav-link.tsx +7 -7
  18. package/src/components/ui/custom/nav.tsx +15 -63
  19. package/src/components/ui/custom/notification-card.tsx +361 -0
  20. package/src/components/ui/custom/notification-list.tsx +178 -0
  21. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  22. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  24. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  25. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  26. package/src/components/ui/custom/satellite-content.tsx +148 -0
  27. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  28. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  29. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  30. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  31. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  32. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  33. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  34. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  35. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  36. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  37. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  38. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  39. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  40. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  41. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  42. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  43. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  44. package/src/components/ui/custom/structure.tsx +14 -0
  45. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  46. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  47. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  48. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  49. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  50. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  51. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  53. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  54. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  55. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  56. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  57. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  58. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  59. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  61. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  62. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  63. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  65. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  66. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  67. package/src/components/ui/pwa-status.test.tsx +53 -0
  68. package/src/components/ui/pwa-status.tsx +113 -0
  69. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  70. package/src/components/ui/report-problem-dialog.tsx +6 -975
  71. package/src/components/ui/report-problem-model.ts +133 -0
  72. package/src/components/ui/report-problem-mutation.ts +122 -0
  73. package/src/components/ui/save-notification.test.tsx +36 -0
  74. package/src/components/ui/save-notification.tsx +17 -0
  75. package/src/components/ui/use-report-problem.ts +488 -0
  76. package/src/declarations.d.ts +2 -0
  77. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  78. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  79. package/src/hooks/calendar-date-utils.ts +11 -0
  80. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  81. package/src/hooks/calendar-event-lookup.ts +47 -0
  82. package/src/hooks/calendar-range-cache.ts +52 -0
  83. package/src/hooks/pwa-install-prompt.ts +44 -0
  84. package/src/hooks/use-calendar-sync.tsx +23 -100
  85. package/src/hooks/use-calendar.tsx +21 -77
  86. package/src/hooks/use-notifications.ts +9 -24
  87. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  88. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  89. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  90. package/src/hooks/use-workspace-presence.ts +0 -18
  91. package/src/lib/tasks-app-url.test.ts +25 -0
  92. package/src/lib/tasks-app-url.ts +8 -0
@@ -0,0 +1,47 @@
1
+ import { AlertTriangle } from '@tuturuuu/icons';
2
+ import { useState } from 'react';
3
+ import { Button } from '../../button';
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogTrigger,
11
+ } from '../../dialog';
12
+ import { CalendarConnectionsSettingsContent } from './calendar-connections-settings-content';
13
+ import { needsCalendarSyncAttention } from './calendar-sync-recovery';
14
+ import type { CalendarConnectionsManagerState } from './use-calendar-connections-manager';
15
+
16
+ /** Keep recovery visible when the sidebar replaces the header calendar picker. */
17
+ export function CalendarSyncAttentionButton({
18
+ state,
19
+ }: {
20
+ state: CalendarConnectionsManagerState;
21
+ }) {
22
+ const [open, setOpen] = useState(false);
23
+ if (!open && !needsCalendarSyncAttention(state)) return null;
24
+ return (
25
+ <Dialog open={open} onOpenChange={setOpen}>
26
+ <DialogTrigger asChild>
27
+ <Button
28
+ variant="outline"
29
+ size="sm"
30
+ className="gap-1.5 border-dynamic-orange/40 text-dynamic-orange"
31
+ >
32
+ <AlertTriangle className="size-4 shrink-0" />
33
+ {state.t('sync_recovery.attention')}
34
+ </Button>
35
+ </DialogTrigger>
36
+ <DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-lg">
37
+ <DialogHeader>
38
+ <DialogTitle>{state.t('manage_calendar_accounts')}</DialogTitle>
39
+ <DialogDescription>
40
+ {state.t('manage_calendar_accounts_desc')}
41
+ </DialogDescription>
42
+ </DialogHeader>
43
+ <CalendarConnectionsSettingsContent state={state} />
44
+ </DialogContent>
45
+ </Dialog>
46
+ );
47
+ }
@@ -0,0 +1,112 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ CalendarSyncRecovery,
5
+ needsCalendarSyncAttention,
6
+ } from './calendar-sync-recovery';
7
+ import type { CalendarConnectionsManagerState } from './use-calendar-connections-manager';
8
+
9
+ function state(overrides: Partial<CalendarConnectionsManagerState> = {}) {
10
+ return {
11
+ accounts: [],
12
+ failedCalendars: [],
13
+ pauseFailedCalendarMutation: { mutate: vi.fn(), isPending: false },
14
+ providerAccountStatuses: {},
15
+ syncStatusError: false,
16
+ syncHealth: {
17
+ state: 'degraded',
18
+ reason: 'sync_stalled',
19
+ currentlyRunning: false,
20
+ lastSuccessAt: null,
21
+ lastFailureAt: null,
22
+ retryAfterSeconds: null,
23
+ },
24
+ retrySyncStatus: vi.fn(),
25
+ syncMutation: { mutate: vi.fn(), isPending: false },
26
+ manualSyncDisabled: false,
27
+ googleAuthMutation: { mutate: vi.fn(), isPending: false },
28
+ microsoftAuthMutation: { mutate: vi.fn(), isPending: false },
29
+ t: (key: string) => key,
30
+ ...overrides,
31
+ } as unknown as CalendarConnectionsManagerState;
32
+ }
33
+ describe('calendar sync recovery actions', () => {
34
+ it('shows a persistent warning with a working retry for the stalled-run case', () => {
35
+ const value = state();
36
+ render(<CalendarSyncRecovery state={value} />);
37
+ expect(screen.getByRole('alert').textContent).toContain(
38
+ 'sync_recovery.stalled'
39
+ );
40
+ fireEvent.click(screen.getByRole('button', { name: 'sync_now' }));
41
+ expect(value.syncMutation.mutate).toHaveBeenCalledOnce();
42
+ expect(screen.queryByText('sync_recovery.reconnect_google')).toBeNull();
43
+ });
44
+ it('rechecks failed status requests instead of starting an unobserved sync', () => {
45
+ const value = state({ syncStatusError: true });
46
+ render(<CalendarSyncRecovery state={value} />);
47
+ fireEvent.click(
48
+ screen.getByRole('button', { name: 'sync_recovery.check_again' })
49
+ );
50
+ expect(value.retrySyncStatus).toHaveBeenCalledOnce();
51
+ expect(value.syncMutation.mutate).not.toHaveBeenCalled();
52
+ });
53
+ it('offers Google reconnection for the affected account', () => {
54
+ const value = state({
55
+ accounts: [
56
+ {
57
+ id: 'google-account',
58
+ provider: 'google',
59
+ account_email: 'test@example.com',
60
+ } as never,
61
+ ],
62
+ providerAccountStatuses: {
63
+ 'google-account': { state: 'reconnect_required' },
64
+ },
65
+ });
66
+ render(<CalendarSyncRecovery state={value} />);
67
+ fireEvent.click(
68
+ screen.getByRole('button', { name: 'sync_recovery.reconnect_google' })
69
+ );
70
+ expect(value.googleAuthMutation.mutate).toHaveBeenCalledOnce();
71
+ });
72
+ it('does not send users through OAuth for a temporary provider outage', () => {
73
+ const value = state({
74
+ providerAccountStatuses: { google: { state: 'temporarily_unavailable' } },
75
+ });
76
+ render(<CalendarSyncRecovery state={value} />);
77
+ expect(screen.getByRole('alert').textContent).toContain(
78
+ 'sync_recovery.provider_unavailable'
79
+ );
80
+ expect(screen.queryByText('sync_recovery.reconnect_google')).toBeNull();
81
+ });
82
+ it('hides the warning after a healthy sync', () => {
83
+ const value = state();
84
+ value.syncHealth!.state = 'healthy';
85
+ expect(needsCalendarSyncAttention(value)).toBe(false);
86
+ render(<CalendarSyncRecovery state={value} />);
87
+ expect(screen.queryByRole('alert')).toBeNull();
88
+ });
89
+ });
90
+
91
+ it('can pause an identified calendar during the retry cooldown without deleting saved events', () => {
92
+ const value = state({
93
+ manualSyncDisabled: true,
94
+ failedCalendars: [
95
+ {
96
+ connectionId: 'broken',
97
+ calendarName: 'Unavailable calendar',
98
+ code: 'not_found',
99
+ },
100
+ ],
101
+ });
102
+ render(<CalendarSyncRecovery state={value} />);
103
+ expect(screen.getByRole('button', { name: 'sync_now' })).toBeDisabled();
104
+ expect(screen.getByText('Unavailable calendar')).toBeVisible();
105
+ fireEvent.click(
106
+ screen.getByRole('button', { name: 'sync_recovery.pause_calendar' })
107
+ );
108
+ expect(value.pauseFailedCalendarMutation.mutate).toHaveBeenCalledWith(
109
+ 'broken'
110
+ );
111
+ expect(value.syncMutation.mutate).not.toHaveBeenCalled();
112
+ });
@@ -0,0 +1,206 @@
1
+ import { AlertTriangle, RefreshCw } from '@tuturuuu/icons';
2
+ import { Alert, AlertDescription, AlertTitle } from '../../alert';
3
+ import { Button } from '../../button';
4
+ import type { CalendarConnectionsManagerState } from './use-calendar-connections-manager';
5
+
6
+ type RecoveryState = Pick<
7
+ CalendarConnectionsManagerState,
8
+ | 'failedCalendars'
9
+ | 'pauseFailedCalendarMutation'
10
+ | 'accounts'
11
+ | 'providerAccountStatuses'
12
+ | 'syncHealth'
13
+ | 'syncStatusError'
14
+ | 'retrySyncStatus'
15
+ | 'syncMutation'
16
+ | 'manualSyncDisabled'
17
+ | 'googleAuthMutation'
18
+ | 'microsoftAuthMutation'
19
+ | 't'
20
+ >;
21
+
22
+ export function needsCalendarSyncAttention(
23
+ state: Pick<
24
+ RecoveryState,
25
+ | 'syncHealth'
26
+ | 'syncStatusError'
27
+ | 'providerAccountStatuses'
28
+ | 'syncMutation'
29
+ >
30
+ ) {
31
+ const lastSuccess = state.syncHealth?.lastSuccessAt;
32
+ const failedAttemptIsNewer =
33
+ !lastSuccess || Date.parse(lastSuccess) < state.syncMutation.submittedAt;
34
+ return (
35
+ state.syncStatusError ||
36
+ (failedAttemptIsNewer &&
37
+ (state.syncMutation.isError || state.syncMutation.data?.ok === false)) ||
38
+ state.syncHealth?.state === 'degraded' ||
39
+ Object.values(state.providerAccountStatuses).some(
40
+ (status) => status.state !== 'connected'
41
+ )
42
+ );
43
+ }
44
+
45
+ export function CalendarSyncRecovery({ state }: { state: RecoveryState }) {
46
+ const { t, syncHealth, syncStatusError, providerAccountStatuses } = state;
47
+ if (!needsCalendarSyncAttention(state) && !state.syncMutation.isPending)
48
+ return null;
49
+ const reconnectAccounts = state.accounts.filter(
50
+ (account) =>
51
+ providerAccountStatuses[account.id]?.state === 'reconnect_required'
52
+ );
53
+ const reason = syncStatusError
54
+ ? 'unavailable'
55
+ : reconnectAccounts.length
56
+ ? 'reconnect_required'
57
+ : Object.values(providerAccountStatuses).some(
58
+ (status) => status.state === 'temporarily_unavailable'
59
+ )
60
+ ? 'provider_unavailable'
61
+ : syncHealth?.state === 'degraded'
62
+ ? syncHealth.reason
63
+ : 'last_run_failed';
64
+ const description =
65
+ reason === 'sync_stalled'
66
+ ? 'sync_recovery.stalled'
67
+ : reason === 'sync_stale'
68
+ ? 'sync_recovery.stale'
69
+ : reason === 'not_synced'
70
+ ? 'sync_recovery.not_synced'
71
+ : reason === 'unavailable'
72
+ ? 'sync_recovery.status_failed'
73
+ : reason === 'reconnect_required' || reason === 'auth'
74
+ ? 'sync_recovery.reconnect_description'
75
+ : reason === 'configuration'
76
+ ? 'sync_recovery.configuration'
77
+ : reason === 'api_limit'
78
+ ? 'sync_recovery.rate_limited'
79
+ : reason === 'provider_unavailable'
80
+ ? 'sync_recovery.provider_unavailable'
81
+ : 'sync_recovery.failed';
82
+ const reconnectProviders = new Set(
83
+ reconnectAccounts.map((account) => account.provider)
84
+ );
85
+ if (
86
+ !reconnectProviders.size &&
87
+ (reason === 'auth' || reason === 'reconnect_required')
88
+ ) {
89
+ for (const provider of state.accounts.length
90
+ ? state.accounts.map((account) => account.provider)
91
+ : (['google', 'microsoft'] as const))
92
+ reconnectProviders.add(provider);
93
+ }
94
+ return (
95
+ <Alert className="border-dynamic-orange/30 bg-dynamic-orange/5 text-foreground">
96
+ <AlertTriangle className="size-4 text-dynamic-orange" />
97
+ <AlertTitle>{t('sync_recovery.attention')}</AlertTitle>
98
+ <AlertDescription className="space-y-3">
99
+ <p>
100
+ {t(
101
+ state.syncMutation.isPending
102
+ ? 'sync_recovery.syncing_description'
103
+ : description
104
+ )}
105
+ </p>
106
+ <p className="text-muted-foreground text-xs">
107
+ {t('sync_recovery.saved_events')}
108
+ </p>
109
+ {!!state.failedCalendars?.length && (
110
+ <ul className="space-y-2">
111
+ {state.failedCalendars.map((failure) => (
112
+ <li
113
+ key={failure.connectionId}
114
+ className="space-y-2 rounded-md border p-3"
115
+ >
116
+ <p className="break-words font-medium">
117
+ {failure.calendarName}
118
+ </p>
119
+ <p className="text-muted-foreground text-xs">
120
+ {t(
121
+ failure.code === 'not_found' ||
122
+ failure.code === 'access_denied'
123
+ ? 'sync_recovery.calendar_unavailable'
124
+ : 'sync_recovery.calendar_failed'
125
+ )}
126
+ </p>
127
+ <Button
128
+ size="sm"
129
+ variant="outline"
130
+ disabled={state.pauseFailedCalendarMutation.isPending}
131
+ onClick={() =>
132
+ state.pauseFailedCalendarMutation.mutate(
133
+ failure.connectionId
134
+ )
135
+ }
136
+ >
137
+ {t('sync_recovery.pause_calendar')}
138
+ </Button>
139
+ </li>
140
+ ))}
141
+ </ul>
142
+ )}
143
+ {syncHealth?.lastSuccessAt && (
144
+ <p className="text-xs">
145
+ {t('last_synced_at', {
146
+ time: new Date(syncHealth.lastSuccessAt).toLocaleString(),
147
+ })}
148
+ </p>
149
+ )}
150
+ {reconnectAccounts.map((account) => (
151
+ <p key={account.id} className="break-all text-xs">
152
+ {account.account_email}
153
+ </p>
154
+ ))}
155
+ <div className="flex flex-wrap gap-2">
156
+ <Button
157
+ size="sm"
158
+ variant="outline"
159
+ disabled={syncStatusError ? false : state.manualSyncDisabled}
160
+ onClick={() =>
161
+ syncStatusError
162
+ ? void state.retrySyncStatus()
163
+ : state.syncMutation.mutate()
164
+ }
165
+ >
166
+ <RefreshCw
167
+ className={
168
+ state.syncMutation.isPending ? 'size-4 animate-spin' : 'size-4'
169
+ }
170
+ />
171
+ {syncStatusError
172
+ ? t('sync_recovery.check_again')
173
+ : state.syncMutation.isPending
174
+ ? t('syncing_calendars')
175
+ : t('sync_now')}
176
+ </Button>
177
+ {reconnectProviders.has('google') && (
178
+ <Button
179
+ size="sm"
180
+ disabled={state.googleAuthMutation.isPending}
181
+ onClick={() => state.googleAuthMutation.mutate()}
182
+ >
183
+ {t('sync_recovery.reconnect_google')}
184
+ </Button>
185
+ )}
186
+ {reconnectProviders.has('microsoft') && (
187
+ <Button
188
+ size="sm"
189
+ disabled={state.microsoftAuthMutation.isPending}
190
+ onClick={() => state.microsoftAuthMutation.mutate()}
191
+ >
192
+ {t('sync_recovery.reconnect_microsoft')}
193
+ </Button>
194
+ )}
195
+ </div>
196
+ {(syncHealth?.retryAfterSeconds ?? 0) > 0 && (
197
+ <p className="text-xs">
198
+ {t('sync_recovery.retry_after', {
199
+ seconds: syncHealth!.retryAfterSeconds!,
200
+ })}
201
+ </p>
202
+ )}
203
+ </AlertDescription>
204
+ </Alert>
205
+ );
206
+ }
@@ -37,7 +37,7 @@ export interface CalendarAuthToken {
37
37
  }
38
38
 
39
39
  export interface CalendarSyncHealth {
40
- state: 'disconnected' | 'syncing' | 'healthy' | 'degraded';
40
+ state: 'paused' | 'disconnected' | 'syncing' | 'healthy' | 'degraded';
41
41
  reason: string;
42
42
  lastSuccessAt: string | null;
43
43
  lastFailureAt: string | null;
@@ -3,6 +3,10 @@ import {
3
3
  getGoogleCalendarAuthUrl,
4
4
  getWorkspaceCalendarDefaultSource,
5
5
  getWorkspaceCalendarSyncPreferences,
6
+ getWorkspaceCalendarSyncStatus,
7
+ listCalendarAccounts,
8
+ listProviderCalendars,
9
+ syncWorkspaceCalendar,
6
10
  updateCalendarConnection as updateCalendarConnectionRequest,
7
11
  updateWorkspaceCalendarDefaultSource,
8
12
  updateWorkspaceCalendarSyncPreferences,
@@ -16,18 +20,16 @@ import { toast } from '../../sonner';
16
20
  import {
17
21
  type AccountsResponse,
18
22
  getCalendarColor,
19
- type ManualSyncResponse,
20
23
  sourceInputFromOption,
21
24
  type WorkspaceCalendarsResponse,
22
25
  } from './calendar-connections-manager-helpers';
23
- import type {
24
- AuthResponse,
25
- CalendarSyncHealth,
26
- ProviderCalendar,
27
- } from './calendar-types';
26
+ import type { AuthResponse } from './calendar-types';
28
27
  import { mergeProviderCalendarsByAccount } from './merge-provider-calendars';
29
28
 
30
- export type CalendarConnectionsUnifiedVariant = 'compact' | 'settings';
29
+ export type CalendarConnectionsUnifiedVariant =
30
+ | 'compact'
31
+ | 'settings'
32
+ | 'status';
31
33
 
32
34
  export function useCalendarConnectionsManager(wsId: string) {
33
35
  const t = useTranslations('calendar');
@@ -59,81 +61,73 @@ export function useCalendarConnectionsManager(wsId: string) {
59
61
  } = useCalendarSync();
60
62
 
61
63
  const syncMutation = useMutation({
62
- mutationFn: async () => {
63
- const response = await fetch(`/api/v1/workspaces/${wsId}/calendar/sync`, {
64
- method: 'POST',
65
- headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ direction: 'inbound', source: 'manual' }),
67
- });
68
-
69
- const result = (await response.json()) as ManualSyncResponse &
70
- Record<string, unknown>;
71
- if (!response.ok) {
72
- throw new Error(
73
- typeof result.error === 'string'
74
- ? result.error
75
- : 'Failed to sync calendars'
76
- );
77
- }
78
-
79
- return result;
64
+ mutationFn: () => syncWorkspaceCalendar(wsId),
65
+ onSuccess: (result) => {
66
+ if (!result.ok) toast.error(t('sync_recovery.partial_failure'));
67
+ else if (result.alreadyRunning) toast.info(t('syncing_calendars'));
68
+ else toast.success(t('sync_recovery.completed'));
69
+ },
70
+ onError: () => toast.error(t('sync_recovery.failed')),
71
+ onSettled: async () => {
72
+ await Promise.all(
73
+ [
74
+ ['calendar-sync-status', wsId],
75
+ ['provider-calendar-list', wsId],
76
+ ['databaseCalendarEvents', wsId],
77
+ ].map((queryKey) => queryClient.invalidateQueries({ queryKey }))
78
+ );
80
79
  },
81
- onSuccess: async (result) => {
82
- await queryClient.invalidateQueries({
83
- queryKey: ['calendar-sync-status', wsId],
84
- });
85
- await queryClient.invalidateQueries({
86
- queryKey: ['databaseCalendarEvents', wsId],
87
- exact: false,
88
- });
80
+ });
89
81
 
90
- if (!result.alreadyRunning) {
91
- toast.success(t('calendar_sync_started') || 'Calendar sync started');
92
- }
93
- },
94
- onError: (error: Error) => {
95
- toast.error(error.message);
82
+ const pauseFailedCalendarMutation = useMutation({
83
+ mutationFn: (connectionId: string) =>
84
+ updateCalendarConnectionRequest({
85
+ id: connectionId,
86
+ wsId,
87
+ syncInboundEnabled: false,
88
+ }),
89
+ onSuccess: async (_, connectionId) => {
90
+ setCalendarConnections((current) =>
91
+ current.map((connection) =>
92
+ connection.id === connectionId
93
+ ? { ...connection, sync_inbound_enabled: false }
94
+ : connection
95
+ )
96
+ );
97
+ await Promise.all(
98
+ [
99
+ ['calendar-sync-status', wsId],
100
+ ['calendar-connections', wsId],
101
+ ['provider-calendar-list', wsId],
102
+ ].map((queryKey) => queryClient.invalidateQueries({ queryKey }))
103
+ );
104
+ toast.success(t('sync_recovery.calendar_paused'));
96
105
  },
106
+ onError: () => toast.error(t('sync_recovery.pause_failed')),
97
107
  });
98
108
 
99
109
  // Fetch connected accounts
100
- const { data: accountsData, isLoading: isLoadingAccounts } = useQuery({
110
+ const accountsQuery = useQuery({
101
111
  queryKey: ['calendar-accounts', wsId],
102
- queryFn: async () => {
103
- const response = await fetch(
104
- `/api/v1/calendar/auth/accounts?wsId=${wsId}`,
105
- { cache: 'no-store' }
106
- );
107
- if (!response.ok)
108
- return {
109
- accounts: [],
110
- grouped: { google: [], microsoft: [] },
111
- total: 0,
112
- };
113
- return response.json() as Promise<AccountsResponse>;
114
- },
112
+ queryFn: () => listCalendarAccounts(wsId) as Promise<AccountsResponse>,
115
113
  staleTime: 30_000,
114
+ retry: 1,
116
115
  });
117
-
118
- const accounts = accountsData?.accounts || [];
116
+ const accounts = accountsQuery.data?.accounts || [];
117
+ const isLoadingAccounts = accountsQuery.isLoading;
119
118
  const hasConnectedAccounts = accounts.length > 0;
120
-
121
- const { data: syncStatusData } = useQuery({
119
+ const syncStatusQuery = useQuery({
122
120
  queryKey: ['calendar-sync-status', wsId],
123
- queryFn: async () => {
124
- const response = await fetch(
125
- `/api/v1/workspaces/${wsId}/calendar/sync-status`,
126
- { cache: 'no-store' }
127
- );
128
- if (!response.ok) return null;
129
- return response.json() as Promise<{
130
- health: CalendarSyncHealth;
131
- accountsSummary: { total: number; google: number; microsoft: number };
132
- connectionsSummary: { total: number; enabled: number };
133
- }>;
134
- },
121
+ queryFn: () => getWorkspaceCalendarSyncStatus(wsId),
135
122
  staleTime: 15_000,
123
+ retry: 1,
124
+ refetchInterval: (query) =>
125
+ query.state.data?.health.currentlyRunning ||
126
+ query.state.data?.health.retryAfterSeconds
127
+ ? 5_000
128
+ : 30_000,
136
129
  });
130
+ const syncStatusData = syncStatusQuery.data;
137
131
 
138
132
  const { data: defaultSourceData } = useQuery({
139
133
  queryKey: ['calendar-default-source', wsId],
@@ -571,37 +565,35 @@ export function useCalendarConnectionsManager(wsId: string) {
571
565
  ? 'bg-dynamic-blue/10 text-dynamic-blue'
572
566
  : syncHealth?.state === 'healthy'
573
567
  ? 'bg-dynamic-green/10 text-dynamic-green'
574
- : syncHealth?.state === 'disconnected'
568
+ : syncHealth?.state === 'disconnected' || syncHealth?.state === 'paused'
575
569
  ? 'bg-muted text-muted-foreground'
576
570
  : syncHealth?.state === 'degraded'
577
571
  ? 'bg-dynamic-orange/10 text-dynamic-orange'
578
572
  : 'bg-dynamic-red/10 text-dynamic-red';
579
573
 
580
574
  // Fetch Google calendars from API (must be before any conditional returns)
581
- const { data: googleCalendarsData } = useQuery({
575
+ const providerCalendarsQuery = useQuery({
582
576
  queryKey: ['provider-calendar-list', wsId],
583
577
  enabled: hasConnectedAccounts,
584
- queryFn: async () => {
585
- const response = await fetch(
586
- `/api/v1/calendar/auth/provider-calendars?wsId=${wsId}`,
587
- { cache: 'no-store' }
588
- );
589
- if (!response.ok)
590
- return { calendars: [], byAccount: {}, accountStatuses: {} };
591
- return response.json() as Promise<{
592
- calendars: ProviderCalendar[];
593
- byAccount: Record<string, ProviderCalendar[]>;
594
- accountStatuses: Record<
595
- string,
596
- { state: 'connected' | 'reconnect_required' }
597
- >;
598
- }>;
599
- },
578
+ queryFn: () => listProviderCalendars(wsId),
600
579
  staleTime: 30_000,
580
+ refetchInterval: 60_000,
581
+ retry: 1,
601
582
  });
602
-
603
- const calendarsByAccountFromAPI = googleCalendarsData?.byAccount || {};
604
- const providerAccountStatuses = googleCalendarsData?.accountStatuses || {};
583
+ const calendarsByAccountFromAPI =
584
+ providerCalendarsQuery.data?.byAccount || {};
585
+ const providerAccountStatuses =
586
+ providerCalendarsQuery.data?.accountStatuses || {};
587
+ const syncStatusError =
588
+ accountsQuery.isError ||
589
+ syncStatusQuery.isError ||
590
+ providerCalendarsQuery.isError;
591
+ const retrySyncStatus = () =>
592
+ Promise.all([
593
+ accountsQuery.refetch(),
594
+ syncStatusQuery.refetch(),
595
+ providerCalendarsQuery.refetch(),
596
+ ]);
605
597
 
606
598
  const calendarsByAccount = mergeProviderCalendarsByAccount({
607
599
  accounts,
@@ -614,6 +606,8 @@ export function useCalendarConnectionsManager(wsId: string) {
614
606
 
615
607
  return {
616
608
  accounts,
609
+ failedCalendars: syncStatusData?.failedCalendars ?? [],
610
+ pauseFailedCalendarMutation,
617
611
  calendarConnections,
618
612
  calendarsByAccount,
619
613
  providerAccountStatuses,
@@ -642,6 +636,8 @@ export function useCalendarConnectionsManager(wsId: string) {
642
636
  setShowCreateCalendarDialog,
643
637
  showCreateCalendarDialog,
644
638
  syncHealth,
639
+ syncStatusError,
640
+ retrySyncStatus,
645
641
  syncMutation,
646
642
  syncPreferencesData,
647
643
  syncPreferencesMutation,
@@ -4,8 +4,13 @@ import { cjk } from '@streamdown/cjk';
4
4
  import { code } from '@streamdown/code';
5
5
  import { createMathPlugin } from '@streamdown/math';
6
6
  import { mermaid as mermaidPlugin } from '@streamdown/mermaid';
7
- import { Component, type ErrorInfo, type ReactNode } from 'react';
8
- import { Streamdown } from 'streamdown';
7
+ import {
8
+ Component,
9
+ type ComponentProps,
10
+ type ErrorInfo,
11
+ type ReactNode,
12
+ } from 'react';
13
+ import { defaultRemarkPlugins, Streamdown } from 'streamdown';
9
14
 
10
15
  const math = createMathPlugin({ singleDollarTextMath: true });
11
16
  const markdownPlugins = {
@@ -40,9 +45,13 @@ class AiMarkdownErrorBoundary extends Component<
40
45
  export function AssistantMarkdown({
41
46
  isAnimating,
42
47
  text,
48
+ components,
49
+ remarkPlugins,
43
50
  }: {
44
51
  isAnimating?: boolean;
45
52
  text: string;
53
+ components?: ComponentProps<typeof Streamdown>['components'];
54
+ remarkPlugins?: ComponentProps<typeof Streamdown>['remarkPlugins'];
46
55
  }) {
47
56
  return (
48
57
  <div className="wrap-break-word [&_pre]:overflow-x-hidden! [&_pre]:whitespace-pre-wrap! [&_pre_code]:whitespace-pre-wrap! min-w-0 max-w-full overflow-hidden [&_a]:break-all [&_pre]:max-w-full">
@@ -50,6 +59,12 @@ export function AssistantMarkdown({
50
59
  fallback={<p className="whitespace-pre-wrap">{text}</p>}
51
60
  >
52
61
  <Streamdown
62
+ components={components}
63
+ remarkPlugins={
64
+ remarkPlugins
65
+ ? [...Object.values(defaultRemarkPlugins), ...remarkPlugins]
66
+ : undefined
67
+ }
53
68
  caret="block"
54
69
  controls={{ code: !isAnimating, mermaid: !isAnimating }}
55
70
  isAnimating={isAnimating}