@tuturuuu/ui 0.5.0 → 0.6.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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +50 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
  28. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  29. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  30. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  31. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  32. package/src/components/ui/finance/wallets/form.tsx +41 -197
  33. package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
  34. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  35. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  36. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  40. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  41. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
  42. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
  43. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  44. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  45. package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
  46. package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
  47. package/src/components/ui/storefront/accent-button.tsx +33 -0
  48. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  49. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  50. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  51. package/src/components/ui/storefront/image-panel.tsx +40 -0
  52. package/src/components/ui/storefront/index.ts +12 -0
  53. package/src/components/ui/storefront/listing-card.tsx +129 -0
  54. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  55. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  56. package/src/components/ui/storefront/types.ts +99 -0
  57. package/src/components/ui/storefront/utils.ts +90 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  59. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  60. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  61. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  62. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  63. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  64. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  65. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
  66. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  67. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  68. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  69. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  70. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  71. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  72. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  73. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  85. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
  86. package/src/hooks/useBoardRealtime.ts +54 -1
  87. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  88. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -9,8 +9,8 @@ import {
9
9
  useTaskDialogContext,
10
10
  } from '../task-dialog-provider';
11
11
 
12
- const { mockGetCurrentUserTask } = vi.hoisted(() => ({
13
- mockGetCurrentUserTask: vi.fn(),
12
+ const { mockGetTaskDialogHydration } = vi.hoisted(() => ({
13
+ mockGetTaskDialogHydration: vi.fn(),
14
14
  }));
15
15
 
16
16
  vi.mock('@tuturuuu/internal-api/tasks', async () => {
@@ -20,7 +20,7 @@ vi.mock('@tuturuuu/internal-api/tasks', async () => {
20
20
 
21
21
  return {
22
22
  ...actual,
23
- getCurrentUserTask: mockGetCurrentUserTask,
23
+ getTaskDialogHydration: mockGetTaskDialogHydration,
24
24
  };
25
25
  });
26
26
 
@@ -59,13 +59,25 @@ const wrapper = ({ children }: { children: ReactNode }) => (
59
59
  <TaskDialogProvider>{children}</TaskDialogProvider>
60
60
  );
61
61
 
62
+ function createDeferred<T>() {
63
+ let resolve!: (value: T) => void;
64
+ let reject!: (reason?: unknown) => void;
65
+ const promise = new Promise<T>((promiseResolve, promiseReject) => {
66
+ resolve = promiseResolve;
67
+ reject = promiseReject;
68
+ });
69
+
70
+ return { promise, resolve, reject };
71
+ }
72
+
62
73
  describe('TaskDialogProvider', () => {
63
74
  afterEach(() => {
75
+ mockGetTaskDialogHydration.mockReset();
64
76
  vi.useRealTimers();
65
77
  });
66
78
 
67
79
  it('should enable collaborationMode for paid task workspaces opened by id', async () => {
68
- mockGetCurrentUserTask.mockResolvedValueOnce({
80
+ mockGetTaskDialogHydration.mockResolvedValueOnce({
69
81
  task: {
70
82
  ...mockTask,
71
83
  list: { board_id: 'board-1' },
@@ -87,7 +99,7 @@ describe('TaskDialogProvider', () => {
87
99
  });
88
100
 
89
101
  it('should disable collaborationMode for free task workspaces opened by id', async () => {
90
- mockGetCurrentUserTask.mockResolvedValueOnce({
102
+ mockGetTaskDialogHydration.mockResolvedValueOnce({
91
103
  task: {
92
104
  ...mockTask,
93
105
  list: { board_id: 'board-1' },
@@ -108,6 +120,206 @@ describe('TaskDialogProvider', () => {
108
120
  expect(result.current.state.taskWorkspaceTier).toBe('FREE');
109
121
  });
110
122
 
123
+ it('opens by id immediately from an initial snapshot before hydrating task details', async () => {
124
+ const initialSharedContext = {
125
+ boardConfig: {
126
+ id: 'board-1',
127
+ name: 'Visible board',
128
+ ws_id: 'workspace-1',
129
+ ticket_prefix: 'VIS',
130
+ },
131
+ availableLists: [mockList],
132
+ workspaceLabels: [],
133
+ workspaceMembers: [],
134
+ workspaceProjects: [],
135
+ };
136
+ const deferred = createDeferred<{
137
+ task: Task & { list?: { board_id?: string | null } | null };
138
+ availableLists: TaskList[];
139
+ taskWsId: string;
140
+ taskWorkspacePersonal: boolean;
141
+ taskWorkspaceTier: 'PRO';
142
+ }>();
143
+ mockGetTaskDialogHydration.mockReturnValueOnce(deferred.promise);
144
+
145
+ const { result } = renderHook(() => useTaskDialogContext(), { wrapper });
146
+ let openPromise!: Promise<boolean>;
147
+
148
+ act(() => {
149
+ openPromise = result.current.openTaskById(mockTask.id, {
150
+ initialTask: { ...mockTask, name: 'Visible snapshot' },
151
+ boardId: 'board-1',
152
+ availableLists: [mockList],
153
+ taskWsId: 'workspace-1',
154
+ initialSharedContext,
155
+ });
156
+ });
157
+
158
+ expect(result.current.state).toMatchObject({
159
+ isOpen: true,
160
+ isHydratingTask: true,
161
+ taskLoadError: false,
162
+ boardId: 'board-1',
163
+ realtimeEnabled: false,
164
+ initialSharedContext,
165
+ task: {
166
+ id: mockTask.id,
167
+ name: 'Visible snapshot',
168
+ },
169
+ });
170
+
171
+ await act(async () => {
172
+ deferred.resolve({
173
+ task: {
174
+ ...mockTask,
175
+ name: 'Hydrated task',
176
+ list: { board_id: 'board-1' },
177
+ },
178
+ availableLists: [mockList],
179
+ taskWsId: 'workspace-1',
180
+ taskWorkspacePersonal: false,
181
+ taskWorkspaceTier: 'PRO',
182
+ });
183
+ await openPromise;
184
+ });
185
+
186
+ expect(mockGetTaskDialogHydration).toHaveBeenCalledWith(
187
+ mockTask.id,
188
+ {
189
+ taskWsId: 'workspace-1',
190
+ taskWorkspacePersonal: undefined,
191
+ taskWorkspaceTier: undefined,
192
+ },
193
+ expect.objectContaining({
194
+ fetch: expect.any(Function),
195
+ })
196
+ );
197
+ expect(result.current.state).toMatchObject({
198
+ isOpen: true,
199
+ isHydratingTask: false,
200
+ taskLoadError: false,
201
+ collaborationMode: true,
202
+ realtimeEnabled: true,
203
+ taskHydrationVersion: 1,
204
+ taskWorkspaceTier: 'PRO',
205
+ task: {
206
+ id: mockTask.id,
207
+ name: 'Hydrated task',
208
+ },
209
+ });
210
+ expect(result.current.state.initialSharedContext).toBeUndefined();
211
+ });
212
+
213
+ it('keeps the dialog open in a non-editable error state when hydration fails', async () => {
214
+ const initialSharedContext = {
215
+ boardConfig: {
216
+ id: 'board-1',
217
+ name: 'Visible board',
218
+ ws_id: 'workspace-1',
219
+ ticket_prefix: 'VIS',
220
+ },
221
+ availableLists: [mockList],
222
+ workspaceLabels: [],
223
+ workspaceMembers: [],
224
+ workspaceProjects: [],
225
+ };
226
+ const deferred = createDeferred<never>();
227
+ mockGetTaskDialogHydration.mockReturnValueOnce(deferred.promise);
228
+
229
+ const { result } = renderHook(() => useTaskDialogContext(), { wrapper });
230
+ let openPromise!: Promise<boolean>;
231
+
232
+ act(() => {
233
+ openPromise = result.current.openTaskById(mockTask.id, {
234
+ initialTask: { ...mockTask, name: 'Visible snapshot' },
235
+ boardId: 'board-1',
236
+ initialSharedContext,
237
+ });
238
+ });
239
+
240
+ expect(result.current.state.isOpen).toBe(true);
241
+ expect(result.current.state.isHydratingTask).toBe(true);
242
+
243
+ await act(async () => {
244
+ deferred.reject(new Error('network failed'));
245
+ await openPromise;
246
+ });
247
+
248
+ expect(result.current.state).toMatchObject({
249
+ isOpen: true,
250
+ isHydratingTask: false,
251
+ taskLoadError: true,
252
+ initialSharedContext,
253
+ task: {
254
+ id: mockTask.id,
255
+ name: 'Visible snapshot',
256
+ },
257
+ });
258
+ });
259
+
260
+ it('ignores stale hydration responses after another task opens', async () => {
261
+ const firstDeferred = createDeferred<{
262
+ task: Task & { list?: { board_id?: string | null } | null };
263
+ availableLists: TaskList[];
264
+ taskWsId: string;
265
+ taskWorkspacePersonal: boolean;
266
+ taskWorkspaceTier: 'PRO';
267
+ }>();
268
+ mockGetTaskDialogHydration.mockReturnValueOnce(firstDeferred.promise);
269
+
270
+ const { result } = renderHook(() => useTaskDialogContext(), { wrapper });
271
+ let firstOpenPromise!: Promise<boolean>;
272
+
273
+ act(() => {
274
+ firstOpenPromise = result.current.openTaskById('task-1', {
275
+ initialTask: { ...mockTask, id: 'task-1', name: 'First snapshot' },
276
+ boardId: 'board-1',
277
+ });
278
+ });
279
+ act(() => {
280
+ result.current.closeDialog();
281
+ });
282
+ act(() => {
283
+ result.current.openTask(
284
+ { ...mockTask, id: 'task-2', name: 'Second task' },
285
+ 'board-1',
286
+ [mockList]
287
+ );
288
+ });
289
+
290
+ expect(result.current.state).toMatchObject({
291
+ isOpen: true,
292
+ task: {
293
+ id: 'task-2',
294
+ name: 'Second task',
295
+ },
296
+ });
297
+
298
+ await act(async () => {
299
+ firstDeferred.resolve({
300
+ task: {
301
+ ...mockTask,
302
+ id: 'task-1',
303
+ name: 'Stale hydrated task',
304
+ list: { board_id: 'board-1' },
305
+ },
306
+ availableLists: [mockList],
307
+ taskWsId: 'workspace-1',
308
+ taskWorkspacePersonal: false,
309
+ taskWorkspaceTier: 'PRO',
310
+ });
311
+ await firstOpenPromise;
312
+ });
313
+
314
+ expect(result.current.state).toMatchObject({
315
+ isOpen: true,
316
+ task: {
317
+ id: 'task-2',
318
+ name: 'Second task',
319
+ },
320
+ });
321
+ });
322
+
111
323
  it('should provide initial dialog state', () => {
112
324
  const { result } = renderHook(() => useTaskDialogContext(), { wrapper });
113
325
 
@@ -14,6 +14,7 @@ import {
14
14
  useRef,
15
15
  useState,
16
16
  } from 'react';
17
+ import type { SharedTaskContext } from '../shared/task-edit-dialog/hooks/use-task-data';
17
18
  import type {
18
19
  PendingRelationship,
19
20
  PendingRelationshipType,
@@ -58,6 +59,26 @@ interface TaskDialogState {
58
59
  taskWorkspacePersonal?: boolean;
59
60
  /** The task workspace tier used to gate cursor tracking for edit mode */
60
61
  taskWorkspaceTier?: WorkspaceProductTier;
62
+ /** Initial board/list context used for immediate partial-task rendering. */
63
+ initialSharedContext?: SharedTaskContext;
64
+ /** True while an existing task was opened from a partial snapshot and is hydrating. */
65
+ isHydratingTask?: boolean;
66
+ /** True when the latest hydration request failed after the dialog already opened. */
67
+ taskLoadError?: boolean;
68
+ /** Bumps when async task hydration replaces a partial snapshot. */
69
+ taskHydrationVersion?: number;
70
+ taskOpenRequestId?: number;
71
+ }
72
+
73
+ interface OpenTaskByIdOptions {
74
+ initialTask?: Partial<Task>;
75
+ boardId?: string;
76
+ availableLists?: TaskList[];
77
+ fakeTaskUrl?: boolean;
78
+ taskWsId?: string;
79
+ taskWorkspacePersonal?: boolean;
80
+ taskWorkspaceTier?: WorkspaceProductTier;
81
+ initialSharedContext?: SharedTaskContext;
61
82
  }
62
83
 
63
84
  interface TaskDialogContextValue {
@@ -85,7 +106,10 @@ interface TaskDialogContextValue {
85
106
  ) => void;
86
107
 
87
108
  // Open task by ID (fetches task data first)
88
- openTaskById: (taskId: string) => Promise<boolean>;
109
+ openTaskById: (
110
+ taskId: string,
111
+ options?: OpenTaskByIdOptions
112
+ ) => Promise<boolean>;
89
113
 
90
114
  // Open dialog for creating new task
91
115
  createTask: (
@@ -178,9 +202,11 @@ export function TaskDialogProvider({
178
202
  const [state, setState] = useState<TaskDialogState>({
179
203
  isOpen: false,
180
204
  });
205
+ const stateRef = useRef(state);
181
206
  const isDialogOpenRef = useRef(state.isOpen);
182
207
  const queuedDialogStatesRef = useRef<TaskDialogState[]>([]);
183
208
  const queuedOpenTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
209
+ const taskOpenRequestIdRef = useRef(0);
184
210
  const closeRequestHandlerRef = useRef<
185
211
  (() => boolean | undefined | Promise<boolean | undefined>) | null
186
212
  >(null);
@@ -283,7 +309,52 @@ export function TaskDialogProvider({
283
309
  [closeDialog]
284
310
  );
285
311
 
312
+ const replaceHydratingDialogState = useCallback(
313
+ (requestId: number, nextDialogState: TaskDialogState) => {
314
+ queuedDialogStatesRef.current = queuedDialogStatesRef.current.map(
315
+ (queuedDialogState) =>
316
+ queuedDialogState.taskOpenRequestId === requestId
317
+ ? nextDialogState
318
+ : queuedDialogState
319
+ );
320
+
321
+ setState((currentState) =>
322
+ currentState.taskOpenRequestId === requestId
323
+ ? nextDialogState
324
+ : currentState
325
+ );
326
+ },
327
+ []
328
+ );
329
+
330
+ const markHydratingDialogFailed = useCallback((requestId: number) => {
331
+ queuedDialogStatesRef.current = queuedDialogStatesRef.current.map(
332
+ (queuedDialogState) =>
333
+ queuedDialogState.taskOpenRequestId === requestId
334
+ ? {
335
+ ...queuedDialogState,
336
+ isHydratingTask: false,
337
+ taskLoadError: true,
338
+ taskHydrationVersion:
339
+ (queuedDialogState.taskHydrationVersion ?? 0) + 1,
340
+ }
341
+ : queuedDialogState
342
+ );
343
+
344
+ setState((currentState) =>
345
+ currentState.taskOpenRequestId === requestId
346
+ ? {
347
+ ...currentState,
348
+ isHydratingTask: false,
349
+ taskLoadError: true,
350
+ taskHydrationVersion: (currentState.taskHydrationVersion ?? 0) + 1,
351
+ }
352
+ : currentState
353
+ );
354
+ }, []);
355
+
286
356
  useEffect(() => {
357
+ stateRef.current = state;
287
358
  isDialogOpenRef.current = state.isOpen;
288
359
 
289
360
  if (state.isOpen) {
@@ -292,7 +363,7 @@ export function TaskDialogProvider({
292
363
 
293
364
  closeRequestInFlightRef.current = false;
294
365
  flushQueuedDialogState();
295
- }, [flushQueuedDialogState, state.isOpen]);
366
+ }, [flushQueuedDialogState, state]);
296
367
 
297
368
  useEffect(() => {
298
369
  return () => {
@@ -344,48 +415,108 @@ export function TaskDialogProvider({
344
415
  );
345
416
 
346
417
  const openTaskById = useCallback(
347
- async (taskId: string) => {
348
- try {
349
- let response:
350
- | {
351
- task: Task & {
352
- list?: {
353
- board_id?: string | null;
354
- } | null;
355
- };
356
- availableLists: TaskList[];
357
- taskWsId: string;
358
- taskWorkspacePersonal: boolean;
359
- taskWorkspaceTier: WorkspaceProductTier;
360
- }
361
- | undefined;
418
+ async (taskId: string, options?: OpenTaskByIdOptions) => {
419
+ const requestId = taskOpenRequestIdRef.current + 1;
420
+ taskOpenRequestIdRef.current = requestId;
421
+
422
+ const initialTaskSnapshot = options?.initialTask;
423
+ const now = new Date().toISOString();
424
+ const initialTask = {
425
+ name: '',
426
+ description: '',
427
+ list_id: '',
428
+ display_number: 0,
429
+ created_at: now,
430
+ updated_at: now,
431
+ deleted: false,
432
+ archived: false,
433
+ labels: [],
434
+ assignees: [],
435
+ projects: [],
436
+ ...initialTaskSnapshot,
437
+ id: taskId,
438
+ } as Task;
439
+ const initialTaskWithBoard = initialTaskSnapshot as
440
+ | (Partial<Task> & {
441
+ board_id?: string | null;
442
+ list?: { board_id?: string | null } | null;
443
+ })
444
+ | undefined;
445
+ const initialBoardId =
446
+ options?.boardId ??
447
+ initialTaskWithBoard?.board_id ??
448
+ initialTaskWithBoard?.list?.board_id ??
449
+ undefined;
450
+ const initialTaskWorkspacePersonal =
451
+ options?.taskWorkspacePersonal ?? isPersonalWorkspace;
362
452
 
363
- try {
364
- const { getCurrentUserTask } = await import(
365
- '@tuturuuu/internal-api/tasks'
366
- );
453
+ const initialDialogState: TaskDialogState = {
454
+ isOpen: true,
455
+ task: initialTask,
456
+ boardId: initialBoardId,
457
+ mode: 'edit',
458
+ availableLists: options?.availableLists,
459
+ collaborationMode: false,
460
+ realtimeEnabled: false,
461
+ fakeTaskUrl: options?.fakeTaskUrl,
462
+ taskWsId: options?.taskWsId,
463
+ taskWorkspacePersonal: initialTaskWorkspacePersonal,
464
+ taskWorkspaceTier: options?.taskWorkspaceTier,
465
+ initialSharedContext: options?.initialSharedContext,
466
+ isHydratingTask: true,
467
+ taskLoadError: false,
468
+ taskHydrationVersion: 0,
469
+ taskOpenRequestId: requestId,
470
+ };
471
+ const currentState = stateRef.current;
472
+
473
+ if (
474
+ currentState.isOpen &&
475
+ currentState.task?.id === taskId &&
476
+ currentState.taskLoadError
477
+ ) {
478
+ setState(initialDialogState);
479
+ } else {
480
+ queueDialogState(initialDialogState);
481
+ }
367
482
 
368
- response = await getCurrentUserTask(taskId, {
483
+ try {
484
+ const { getTaskDialogHydration } = await import(
485
+ '@tuturuuu/internal-api/tasks'
486
+ );
487
+
488
+ const response = await getTaskDialogHydration(
489
+ taskId,
490
+ {
491
+ taskWsId: options?.taskWsId,
492
+ taskWorkspacePersonal: options?.taskWorkspacePersonal,
493
+ taskWorkspaceTier: options?.taskWorkspaceTier,
494
+ },
495
+ {
369
496
  fetch: (input, init) =>
370
497
  fetch(new URL(String(input), window.location.origin).toString(), {
371
498
  ...init,
372
499
  cache: 'no-store',
373
500
  }),
374
- });
375
- } catch {
376
- return false;
377
- }
501
+ }
502
+ );
378
503
 
379
504
  if (!response) {
505
+ markHydratingDialogFailed(requestId);
380
506
  return false;
381
507
  }
382
508
 
383
- const transformedTask = response.task;
509
+ const transformedTask = response.task as Task & {
510
+ board_id?: string | null;
511
+ list?: {
512
+ board_id?: string | null;
513
+ } | null;
514
+ };
384
515
  const taskWsId = response.taskWsId;
385
516
  const taskWorkspacePersonal = response.taskWorkspacePersonal;
386
517
  const taskWorkspaceTier = response.taskWorkspaceTier;
387
518
  const isTaskWorkspacePersonal =
388
- taskWorkspacePersonal ?? isPersonalWorkspace;
519
+ taskWorkspacePersonal ?? initialTaskWorkspacePersonal;
389
520
 
390
521
  // Realtime sync (auto-save via Yjs) is always enabled in edit mode.
391
522
  // Cursor presence requires tier check and non-personal workspace.
@@ -394,26 +525,40 @@ export function TaskDialogProvider({
394
525
  taskWorkspaceTier
395
526
  );
396
527
 
397
- // Open the task in edit mode
398
- queueDialogState({
528
+ replaceHydratingDialogState(requestId, {
399
529
  isOpen: true,
400
530
  task: transformedTask as Task,
401
- boardId: transformedTask.list?.board_id ?? undefined,
531
+ boardId:
532
+ transformedTask.board_id ??
533
+ transformedTask.list?.board_id ??
534
+ initialBoardId,
402
535
  mode: 'edit',
403
- availableLists: response.availableLists || undefined,
536
+ availableLists:
537
+ response.availableLists || options?.availableLists || undefined,
404
538
  collaborationMode: shouldEnableCursors,
405
539
  realtimeEnabled: true,
540
+ fakeTaskUrl: options?.fakeTaskUrl,
406
541
  taskWsId,
407
542
  taskWorkspacePersonal: isTaskWorkspacePersonal,
408
543
  taskWorkspaceTier,
544
+ isHydratingTask: false,
545
+ taskLoadError: false,
546
+ taskHydrationVersion: 1,
547
+ taskOpenRequestId: requestId,
409
548
  });
410
549
  return true;
411
- } catch (error) {
412
- console.error('Failed to open task:', error);
550
+ } catch {
551
+ markHydratingDialogFailed(requestId);
413
552
  return false;
414
553
  }
415
554
  },
416
- [canUseTaskCursors, isPersonalWorkspace, queueDialogState]
555
+ [
556
+ canUseTaskCursors,
557
+ isPersonalWorkspace,
558
+ markHydratingDialogFailed,
559
+ queueDialogState,
560
+ replaceHydratingDialogState,
561
+ ]
417
562
  );
418
563
 
419
564
  const createTask = useCallback(