@tuturuuu/ui 0.9.0 → 0.11.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 (123) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  17. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  18. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  20. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  21. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  22. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  23. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  24. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  25. package/src/components/ui/finance/invoices/utils.ts +240 -7
  26. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  27. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  28. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  29. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  30. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  31. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  33. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  34. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  35. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  36. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  37. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  38. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  39. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  40. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  41. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  42. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  43. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  44. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  46. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  50. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  51. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  52. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  53. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  54. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  55. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  56. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  57. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  58. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  59. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  60. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  61. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  62. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  63. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +123 -1
  64. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  65. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  66. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  67. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +58 -0
  68. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  69. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  70. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  71. package/src/components/ui/tu-do/shared/board-client.tsx +16 -4
  72. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  73. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  74. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  75. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  76. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  77. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +58 -4
  78. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  79. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  102. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  103. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  104. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  105. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  106. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  107. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  108. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +390 -34
  109. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  110. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  111. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  112. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  113. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  114. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  115. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  116. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  117. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  118. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
  119. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  120. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  121. package/src/hooks/useBoardPresence.ts +364 -0
  122. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  123. package/src/lib/workspace-actions.ts +2 -6
@@ -11,10 +11,12 @@ import {
11
11
  uploadWorkspaceTaskFile,
12
12
  } from '@tuturuuu/internal-api';
13
13
  import type { WorkspaceTaskSuggestionTask } from '@tuturuuu/internal-api/tasks';
14
+ import { getWorkspaceTaskHistory } from '@tuturuuu/internal-api/tasks';
14
15
  import type { Task } from '@tuturuuu/types/primitives/Task';
15
16
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
16
17
  import { Button } from '@tuturuuu/ui/button';
17
18
  import { Dialog, DialogContent, DialogTitle } from '@tuturuuu/ui/dialog';
19
+ import { SUPABASE_PROVIDER_SYNC_ORIGIN } from '@tuturuuu/ui/hooks/supabase-provider';
18
20
  import { useYjsCollaboration } from '@tuturuuu/ui/hooks/use-yjs-collaboration';
19
21
  import { toast } from '@tuturuuu/ui/sonner';
20
22
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
@@ -30,8 +32,9 @@ import {
30
32
  } from '@tuturuuu/utils/yjs-helper';
31
33
  import dayjs from 'dayjs';
32
34
  import { usePathname } from 'next/navigation';
33
- import { useTranslations } from 'next-intl';
35
+ import { useLocale, useTranslations } from 'next-intl';
34
36
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
37
+ import * as Y from 'yjs';
35
38
  import { BoardEstimationConfigDialog } from '../boards/boardId/task-dialogs/BoardEstimationConfigDialog';
36
39
  import { TaskNewLabelDialog } from '../boards/boardId/task-dialogs/TaskNewLabelDialog';
37
40
  import { TaskNewProjectDialog } from '../boards/boardId/task-dialogs/TaskNewProjectDialog';
@@ -64,7 +67,14 @@ import {
64
67
  import { TaskNameInput } from './task-edit-dialog/components/task-name-input';
65
68
  import { TaskSuggestionMenus } from './task-edit-dialog/components/task-suggestion-menus';
66
69
  import { NAME_UPDATE_DEBOUNCE_MS } from './task-edit-dialog/constants';
67
- import { fetchWorkspaceTaskDescription } from './task-edit-dialog/hooks/task-api';
70
+ import {
71
+ buildRecoverableTaskDescriptionVersions,
72
+ type RecoverableTaskDescriptionVersion,
73
+ } from './task-edit-dialog/description-versions';
74
+ import {
75
+ fetchWorkspaceTaskDescription,
76
+ updateWorkspaceTaskDescription,
77
+ } from './task-edit-dialog/hooks/task-api';
68
78
  import { useEditorCommands } from './task-edit-dialog/hooks/use-editor-commands';
69
79
  import { useSuggestionMenus } from './task-edit-dialog/hooks/use-suggestion-menus';
70
80
  import { useTaskChangeDetection } from './task-edit-dialog/hooks/use-task-change-detection';
@@ -85,6 +95,8 @@ import { useTaskYjsSync } from './task-edit-dialog/hooks/use-task-yjs-sync';
85
95
  import { PersonalOverridesSection } from './task-edit-dialog/personal-overrides-section';
86
96
  import { TaskActivitySection } from './task-edit-dialog/task-activity-section';
87
97
  import { TaskDeleteDialog } from './task-edit-dialog/task-delete-dialog';
98
+ import { TaskDescriptionRestoreBanner } from './task-edit-dialog/task-description-restore-banner';
99
+ import { TaskDescriptionVersionRestoreDialog } from './task-edit-dialog/task-description-version-restore-dialog';
88
100
  import { TaskInstancesSection } from './task-edit-dialog/task-instances-section';
89
101
  import { TaskPropertiesSection } from './task-edit-dialog/task-properties-section';
90
102
  import { TaskRelationshipsProperties } from './task-edit-dialog/task-relationships-properties';
@@ -104,16 +116,22 @@ import {
104
116
  } from './task-edit-dialog/user-display';
105
117
  import {
106
118
  broadcastTaskDescriptionUpsert,
119
+ canPersistTaskDescriptionSnapshot,
107
120
  clearDraft,
121
+ createTaskDescriptionPersistenceGuardState,
108
122
  getDescriptionContent,
109
123
  getDraftStorageKey,
110
124
  getTaskDescriptionPercentLeft,
111
125
  getTaskDescriptionPreviewText,
112
126
  getTaskDescriptionStorageLength,
127
+ normalizeTaskDescriptionSnapshot,
128
+ recordTaskDescriptionEditorSnapshot,
113
129
  saveAndVerifyYjsDescriptionToDatabase,
114
130
  saveYjsDescriptionToDatabase,
115
- serializeTaskDescriptionContent,
131
+ serializeTaskDescriptionPersistenceSnapshot,
116
132
  shouldPreserveNativeContextMenu,
133
+ type TaskDescriptionPersistenceGuardState,
134
+ updateTaskDescriptionCaches,
117
135
  } from './task-edit-dialog/utils';
118
136
  import {
119
137
  resolveInlineTaskTargetList,
@@ -123,6 +141,13 @@ import { TaskShareDialog } from './task-share-dialog';
123
141
  import type { TaskFilters } from './types';
124
142
  import { UnsavedChangesWarningDialog } from './unsaved-changes-warning-dialog';
125
143
 
144
+ type AssigneeMemberSource = 'workspace' | 'board' | 'workspace-and-board';
145
+
146
+ const EMPTY_TASK_DESCRIPTION_DOC: JSONContent = {
147
+ type: 'doc',
148
+ content: [{ type: 'paragraph' }],
149
+ };
150
+
126
151
  export {
127
152
  type DialogHeaderInfo,
128
153
  getTaskDialogHeaderInfo,
@@ -149,6 +174,8 @@ export interface TaskEditDialogProps {
149
174
  taskLoadError?: boolean;
150
175
  taskHydrationVersion?: number;
151
176
  isPersonalWorkspace?: boolean;
177
+ canUseBoardAssignees?: boolean;
178
+ assigneeMemberSource?: AssigneeMemberSource;
152
179
  parentTaskId?: string;
153
180
  parentTaskName?: string;
154
181
  pendingRelationship?: PendingRelationship;
@@ -189,6 +216,8 @@ export function TaskEditDialog({
189
216
  taskLoadError = false,
190
217
  taskHydrationVersion = 0,
191
218
  isPersonalWorkspace = false,
219
+ canUseBoardAssignees,
220
+ assigneeMemberSource,
192
221
  parentTaskId,
193
222
  parentTaskName,
194
223
  pendingRelationship,
@@ -210,10 +239,12 @@ export function TaskEditDialog({
210
239
  const isCreateMode = mode === 'create';
211
240
  const effectiveTaskWsId = !isCreateMode ? (taskWsId ?? wsId) : wsId;
212
241
  const pathname = usePathname();
242
+ const locale = useLocale();
213
243
  const queryClient = useQueryClient();
214
244
  const t = useTranslations('common');
215
245
  const rootT = useTranslations();
216
246
  const dialogT = useTranslations('ws-task-boards.dialog');
247
+ const historyT = useTranslations('tasks.history');
217
248
  const { registerCloseRequestHandler } = useTaskDialogContext();
218
249
 
219
250
  // Access workspace tier for tier-aware collaboration settings (null outside the provider)
@@ -284,7 +315,18 @@ export function TaskEditDialog({
284
315
  const handleConvertToTaskRef = useRef<(() => Promise<void>) | null>(null);
285
316
  const descriptionRef = useRef<JSONContent | null>(formState.description);
286
317
  const persistedDescriptionRef = useRef<string | null>(null);
318
+ const descriptionPersistenceGuardRef =
319
+ useRef<TaskDescriptionPersistenceGuardState>(
320
+ createTaskDescriptionPersistenceGuardState({
321
+ persistedDescription: null,
322
+ })
323
+ );
287
324
  const taskRealtimeBroadcastRef = useRef<BoardBroadcastFn | null>(null);
325
+ const [hasHydratedYjsState, setHasHydratedYjsState] = useState(false);
326
+ const [showDescriptionVersionsDialog, setShowDescriptionVersionsDialog] =
327
+ useState(false);
328
+ const [restoringDescriptionVersionId, setRestoringDescriptionVersionId] =
329
+ useState<string | null>(null);
288
330
 
289
331
  useEffect(() => {
290
332
  descriptionRef.current = formState.description;
@@ -361,6 +403,54 @@ export function TaskEditDialog({
361
403
  refetchOnReconnect: false,
362
404
  });
363
405
 
406
+ const persistedTaskDescriptionSnapshot = useMemo(
407
+ () =>
408
+ serializeTaskDescriptionPersistenceSnapshot(
409
+ getDescriptionContent(task?.description)
410
+ ) ?? null,
411
+ [task?.description]
412
+ );
413
+
414
+ useEffect(() => {
415
+ if (!isOpen || isCreateMode) {
416
+ persistedDescriptionRef.current = null;
417
+ descriptionPersistenceGuardRef.current =
418
+ createTaskDescriptionPersistenceGuardState({
419
+ persistedDescription: null,
420
+ });
421
+ return;
422
+ }
423
+
424
+ persistedDescriptionRef.current = persistedTaskDescriptionSnapshot;
425
+ descriptionPersistenceGuardRef.current =
426
+ createTaskDescriptionPersistenceGuardState({
427
+ persistedDescription: persistedTaskDescriptionSnapshot,
428
+ trustPersistedDescription: !effectiveRealtimeEnabled,
429
+ });
430
+ }, [
431
+ effectiveRealtimeEnabled,
432
+ isOpen,
433
+ isCreateMode,
434
+ persistedTaskDescriptionSnapshot,
435
+ ]);
436
+
437
+ const canConfirmEmptyDescriptionSnapshot =
438
+ !effectiveRealtimeEnabled || hasHydratedYjsState;
439
+
440
+ const recordDescriptionSnapshot = useCallback(
441
+ (nextDescription: JSONContent | null) => {
442
+ descriptionPersistenceGuardRef.current =
443
+ recordTaskDescriptionEditorSnapshot(
444
+ descriptionPersistenceGuardRef.current,
445
+ nextDescription,
446
+ {
447
+ canConfirmEmptySnapshot: canConfirmEmptyDescriptionSnapshot,
448
+ }
449
+ );
450
+ },
451
+ [canConfirmEmptyDescriptionSnapshot]
452
+ );
453
+
364
454
  const loadTaskDescriptionState = useCallback(async () => {
365
455
  if (!task?.id) return null;
366
456
  const response = await fetchWorkspaceTaskDescription(
@@ -387,7 +477,9 @@ export function TaskEditDialog({
387
477
  return false;
388
478
  }
389
479
 
390
- const serializedDescription = serializeTaskDescriptionContent(content);
480
+ content = normalizeTaskDescriptionSnapshot(content);
481
+ const serializedDescription =
482
+ serializeTaskDescriptionPersistenceSnapshot(content);
391
483
 
392
484
  if (
393
485
  serializedDescription &&
@@ -396,6 +488,15 @@ export function TaskEditDialog({
396
488
  return false;
397
489
  }
398
490
 
491
+ if (
492
+ !canPersistTaskDescriptionSnapshot({
493
+ currentSerializedDescription: serializedDescription,
494
+ guardState: descriptionPersistenceGuardRef.current,
495
+ })
496
+ ) {
497
+ return false;
498
+ }
499
+
399
500
  const didPersist = await saveYjsDescriptionToDatabase({
400
501
  wsId: effectiveTaskWsId,
401
502
  taskId: task.id,
@@ -409,6 +510,11 @@ export function TaskEditDialog({
409
510
  if (!didPersist) return false;
410
511
 
411
512
  persistedDescriptionRef.current = serializedDescription;
513
+ descriptionPersistenceGuardRef.current =
514
+ createTaskDescriptionPersistenceGuardState({
515
+ persistedDescription: serializedDescription,
516
+ trustPersistedDescription: true,
517
+ });
412
518
 
413
519
  const broadcast =
414
520
  getActiveBroadcast() ?? taskRealtimeBroadcastRef.current;
@@ -438,8 +544,6 @@ export function TaskEditDialog({
438
544
  saveDocumentState: saveTaskDescriptionState,
439
545
  });
440
546
 
441
- const [hasHydratedYjsState, setHasHydratedYjsState] = useState(false);
442
-
443
547
  useEffect(() => {
444
548
  if (!isOpen || isCreateMode || !effectiveRealtimeEnabled || !task?.id) {
445
549
  setHasHydratedYjsState(false);
@@ -469,6 +573,49 @@ export function TaskEditDialog({
469
573
  synced,
470
574
  ]);
471
575
 
576
+ const { data: descriptionHistoryData } = useQuery({
577
+ queryKey: ['task-history', effectiveTaskWsId, task?.id, 'description'],
578
+ queryFn: async () => {
579
+ if (!task?.id) return null;
580
+
581
+ return getWorkspaceTaskHistory(effectiveTaskWsId, task.id, {
582
+ limit: 100,
583
+ changeType: 'field_updated',
584
+ fieldName: 'description',
585
+ });
586
+ },
587
+ enabled:
588
+ isOpen &&
589
+ !isCreateMode &&
590
+ !taskControlsDisabled &&
591
+ Boolean(effectiveTaskWsId) &&
592
+ Boolean(task?.id),
593
+ staleTime: 30 * 1000,
594
+ });
595
+
596
+ const recoverableDescriptionVersions = useMemo(
597
+ () =>
598
+ buildRecoverableTaskDescriptionVersions(
599
+ descriptionHistoryData?.history ?? []
600
+ ),
601
+ [descriptionHistoryData?.history]
602
+ );
603
+
604
+ const currentSerializedDescription = useMemo(
605
+ () =>
606
+ serializeTaskDescriptionPersistenceSnapshot(formState.description) ??
607
+ null,
608
+ [formState.description]
609
+ );
610
+
611
+ const latestRestorableDescriptionVersion = useMemo(() => {
612
+ const latestVersion = recoverableDescriptionVersions[0] ?? null;
613
+ if (!latestVersion) return null;
614
+ return latestVersion.description === currentSerializedDescription
615
+ ? null
616
+ : latestVersion;
617
+ }, [currentSerializedDescription, recoverableDescriptionVersions]);
618
+
472
619
  // Update user when props change
473
620
  useEffect(() => {
474
621
  if (propsCurrentUser) {
@@ -494,6 +641,8 @@ export function TaskEditDialog({
494
641
  isOpen,
495
642
  propAvailableLists,
496
643
  taskSearchQuery,
644
+ canUseBoardAssignees: canUseBoardAssignees ?? !isPersonalWorkspace,
645
+ assigneeMemberSource,
497
646
  sharedContext,
498
647
  });
499
648
  const currentList = availableLists?.find(
@@ -776,34 +925,45 @@ export function TaskEditDialog({
776
925
  });
777
926
 
778
927
  // Change detection
779
- const { hasUnsavedChanges, canSave, parseDescription } =
780
- useTaskChangeDetection({
781
- task,
782
- name: formState.name,
783
- description: formState.description,
784
- priority: formState.priority,
785
- startDate: formState.startDate,
786
- endDate: formState.endDate,
787
- selectedListId: formState.selectedListId,
788
- estimationPoints: formState.estimationPoints,
789
- selectedLabels: formState.selectedLabels,
790
- selectedAssignees: formState.selectedAssignees,
791
- isCreateMode,
792
- isLoading: isLoading || taskControlsDisabled,
793
- collaborationMode: effectiveCollaborationMode,
794
- draftId,
795
- });
928
+ const { hasUnsavedChanges, canSave } = useTaskChangeDetection({
929
+ task,
930
+ name: formState.name,
931
+ description: formState.description,
932
+ priority: formState.priority,
933
+ startDate: formState.startDate,
934
+ endDate: formState.endDate,
935
+ selectedListId: formState.selectedListId,
936
+ estimationPoints: formState.estimationPoints,
937
+ selectedLabels: formState.selectedLabels,
938
+ selectedAssignees: formState.selectedAssignees,
939
+ isCreateMode,
940
+ isLoading: isLoading || taskControlsDisabled,
941
+ collaborationMode: effectiveCollaborationMode,
942
+ draftId,
943
+ });
796
944
 
797
945
  useEffect(() => {
798
- if (!isOpen || isCreateMode) {
799
- persistedDescriptionRef.current = null;
946
+ if (!isOpen || isCreateMode) return;
947
+ if (effectiveRealtimeEnabled && !hasHydratedYjsState) return;
948
+ if (
949
+ serializeTaskDescriptionPersistenceSnapshot(formState.description) ===
950
+ null
951
+ ) {
800
952
  return;
801
953
  }
802
954
 
803
- persistedDescriptionRef.current =
804
- serializeTaskDescriptionContent(parseDescription(task?.description)) ??
805
- null;
806
- }, [isOpen, isCreateMode, parseDescription, task?.description]);
955
+ descriptionPersistenceGuardRef.current =
956
+ recordTaskDescriptionEditorSnapshot(
957
+ descriptionPersistenceGuardRef.current,
958
+ formState.description
959
+ );
960
+ }, [
961
+ effectiveRealtimeEnabled,
962
+ formState.description,
963
+ hasHydratedYjsState,
964
+ isOpen,
965
+ isCreateMode,
966
+ ]);
807
967
 
808
968
  // Realtime sync for task fields and relations. Description sync remains Yjs-only.
809
969
  const { broadcast: taskRealtimeBroadcast } = useTaskRealtimeSync({
@@ -1020,6 +1180,135 @@ export function TaskEditDialog({
1020
1180
  );
1021
1181
  }, []);
1022
1182
 
1183
+ const applyRestoredDescriptionToOpenEditor = useCallback(
1184
+ ({
1185
+ content,
1186
+ yjsState,
1187
+ }: {
1188
+ content: JSONContent | null;
1189
+ yjsState: number[] | null;
1190
+ }) => {
1191
+ const nextContent = content ?? EMPTY_TASK_DESCRIPTION_DOC;
1192
+
1193
+ const nextYjsState =
1194
+ yjsState && yjsState.length > 0
1195
+ ? Uint8Array.from(yjsState)
1196
+ : doc && editorInstance?.schema
1197
+ ? convertJsonContentToYjsState(nextContent, editorInstance.schema)
1198
+ : null;
1199
+
1200
+ if (doc && nextYjsState) {
1201
+ const syncOrigin = provider ?? SUPABASE_PROVIDER_SYNC_ORIGIN;
1202
+ doc.transact(() => {
1203
+ const fragment = doc.getXmlFragment('prosemirror');
1204
+ if (fragment.length > 0) {
1205
+ fragment.delete(0, fragment.length);
1206
+ }
1207
+ }, syncOrigin);
1208
+ Y.applyUpdate(doc, nextYjsState, syncOrigin);
1209
+ return;
1210
+ }
1211
+
1212
+ editorInstance?.commands.setContent(nextContent, {
1213
+ emitUpdate: false,
1214
+ });
1215
+ },
1216
+ [doc, editorInstance, provider]
1217
+ );
1218
+
1219
+ const handleRestoreDescriptionVersion = useCallback(
1220
+ async (version: RecoverableTaskDescriptionVersion) => {
1221
+ if (!task?.id) return;
1222
+
1223
+ setRestoringDescriptionVersionId(version.id);
1224
+
1225
+ try {
1226
+ const response = await updateWorkspaceTaskDescription(
1227
+ effectiveTaskWsId,
1228
+ task.id,
1229
+ { description: version.description }
1230
+ );
1231
+ const restoredDescription = response.description ?? version.description;
1232
+ const restoredContent = normalizeTaskDescriptionSnapshot(
1233
+ getDescriptionContent(restoredDescription)
1234
+ );
1235
+ const serializedRestoredDescription =
1236
+ serializeTaskDescriptionPersistenceSnapshot(restoredContent) ?? null;
1237
+
1238
+ applyRestoredDescriptionToOpenEditor({
1239
+ content: restoredContent,
1240
+ yjsState: response.description_yjs_state,
1241
+ });
1242
+ formState.setDescription(restoredContent);
1243
+ setDescriptionStorageLength(
1244
+ getTaskDescriptionStorageLength(restoredContent)
1245
+ );
1246
+ persistedDescriptionRef.current = serializedRestoredDescription;
1247
+ descriptionPersistenceGuardRef.current =
1248
+ createTaskDescriptionPersistenceGuardState({
1249
+ persistedDescription: serializedRestoredDescription,
1250
+ trustPersistedDescription: true,
1251
+ });
1252
+ setHasHydratedYjsState(true);
1253
+
1254
+ updateTaskDescriptionCaches({
1255
+ taskId: task.id,
1256
+ descriptionString: serializedRestoredDescription,
1257
+ boardId,
1258
+ queryClient,
1259
+ });
1260
+
1261
+ await Promise.all([
1262
+ queryClient.invalidateQueries({
1263
+ queryKey: ['task-history', effectiveTaskWsId, task.id],
1264
+ }),
1265
+ queryClient.invalidateQueries({
1266
+ queryKey: ['task', task.id],
1267
+ }),
1268
+ queryClient.invalidateQueries({
1269
+ queryKey: ['task-snapshot', effectiveTaskWsId, task.id],
1270
+ }),
1271
+ ]);
1272
+
1273
+ const broadcast =
1274
+ getActiveBroadcast() ?? taskRealtimeBroadcastRef.current;
1275
+ broadcastTaskDescriptionUpsert({
1276
+ taskId: task.id,
1277
+ descriptionString: serializedRestoredDescription,
1278
+ broadcast: broadcast ?? undefined,
1279
+ });
1280
+
1281
+ onUpdate();
1282
+ setShowDescriptionVersionsDialog(false);
1283
+ toast.success(
1284
+ historyT('description_restore_success', {
1285
+ defaultValue: 'Description restored',
1286
+ })
1287
+ );
1288
+ } catch (error) {
1289
+ toast.error(
1290
+ error instanceof Error
1291
+ ? error.message
1292
+ : historyT('description_restore_failed', {
1293
+ defaultValue: 'Failed to restore description',
1294
+ })
1295
+ );
1296
+ } finally {
1297
+ setRestoringDescriptionVersionId(null);
1298
+ }
1299
+ },
1300
+ [
1301
+ applyRestoredDescriptionToOpenEditor,
1302
+ boardId,
1303
+ effectiveTaskWsId,
1304
+ formState.setDescription,
1305
+ historyT,
1306
+ onUpdate,
1307
+ queryClient,
1308
+ task?.id,
1309
+ ]
1310
+ );
1311
+
1023
1312
  const isDescriptionOverLimit =
1024
1313
  descriptionStorageLength > MAX_TASK_DESCRIPTION_LENGTH;
1025
1314
  const descriptionPercentLeft = getTaskDescriptionPercentLeft(
@@ -1385,7 +1674,8 @@ export function TaskEditDialog({
1385
1674
  return createWorkspaceTaskSuggestions(effectiveTaskWsId, {
1386
1675
  boardId,
1387
1676
  prompt,
1388
- description: serializeTaskDescriptionContent(currentDescription),
1677
+ description:
1678
+ serializeTaskDescriptionPersistenceSnapshot(currentDescription),
1389
1679
  currentListId: formState.selectedListId || undefined,
1390
1680
  clientTimezone:
1391
1681
  Intl.DateTimeFormat().resolvedOptions().timeZone || undefined,
@@ -1682,9 +1972,11 @@ export function TaskEditDialog({
1682
1972
 
1683
1973
  closeBlockedByOverflowRef.current = false;
1684
1974
 
1685
- const currentContent = flushEditorPendingRef.current();
1975
+ const currentContent = normalizeTaskDescriptionSnapshot(
1976
+ flushEditorPendingRef.current()
1977
+ );
1686
1978
  const currentSerializedDescription =
1687
- serializeTaskDescriptionContent(currentContent) ?? null;
1979
+ serializeTaskDescriptionPersistenceSnapshot(currentContent) ?? null;
1688
1980
 
1689
1981
  if (
1690
1982
  currentSerializedDescription &&
@@ -1701,6 +1993,15 @@ export function TaskEditDialog({
1701
1993
  return true;
1702
1994
  }
1703
1995
 
1996
+ if (
1997
+ !canPersistTaskDescriptionSnapshot({
1998
+ currentSerializedDescription,
1999
+ guardState: descriptionPersistenceGuardRef.current,
2000
+ })
2001
+ ) {
2002
+ return false;
2003
+ }
2004
+
1704
2005
  const yjsState =
1705
2006
  currentContent && editorInstance?.schema
1706
2007
  ? Array.from(
@@ -1720,6 +2021,11 @@ export function TaskEditDialog({
1720
2021
 
1721
2022
  if (didPersist) {
1722
2023
  persistedDescriptionRef.current = currentSerializedDescription;
2024
+ descriptionPersistenceGuardRef.current =
2025
+ createTaskDescriptionPersistenceGuardState({
2026
+ persistedDescription: currentSerializedDescription,
2027
+ trustPersistedDescription: true,
2028
+ });
1723
2029
  }
1724
2030
 
1725
2031
  return didPersist;
@@ -1737,9 +2043,11 @@ export function TaskEditDialog({
1737
2043
  return false;
1738
2044
  }
1739
2045
 
1740
- const currentContent = flushEditorPendingRef.current();
2046
+ const currentContent = normalizeTaskDescriptionSnapshot(
2047
+ flushEditorPendingRef.current()
2048
+ );
1741
2049
  const currentSerializedDescription =
1742
- serializeTaskDescriptionContent(currentContent) ?? null;
2050
+ serializeTaskDescriptionPersistenceSnapshot(currentContent) ?? null;
1743
2051
 
1744
2052
  if (
1745
2053
  currentSerializedDescription &&
@@ -2166,6 +2474,7 @@ export function TaskEditDialog({
2166
2474
  selectedAssignees={formState.selectedAssignees}
2167
2475
  isLoading={isLoading}
2168
2476
  isPersonalWorkspace={isPersonalWorkspace}
2477
+ canUseBoardAssignees={canUseBoardAssignees ?? !isPersonalWorkspace}
2169
2478
  totalDuration={formState.totalDuration}
2170
2479
  isSplittable={formState.isSplittable}
2171
2480
  minSplitDurationMinutes={formState.minSplitDurationMinutes}
@@ -2512,6 +2821,30 @@ export function TaskEditDialog({
2512
2821
  />
2513
2822
  )}
2514
2823
 
2824
+ {!taskControlsDisabled && !isCreateMode && (
2825
+ <TaskDescriptionRestoreBanner
2826
+ isRestoring={Boolean(restoringDescriptionVersionId)}
2827
+ latestVersion={latestRestorableDescriptionVersion}
2828
+ onRestoreLatest={() => {
2829
+ if (latestRestorableDescriptionVersion) {
2830
+ void handleRestoreDescriptionVersion(
2831
+ latestRestorableDescriptionVersion
2832
+ );
2833
+ }
2834
+ }}
2835
+ onViewVersions={() =>
2836
+ setShowDescriptionVersionsDialog(true)
2837
+ }
2838
+ t={
2839
+ historyT as (
2840
+ key: string,
2841
+ options?: { count?: number; defaultValue?: string }
2842
+ ) => string
2843
+ }
2844
+ versionCount={recoverableDescriptionVersions.length}
2845
+ />
2846
+ )}
2847
+
2515
2848
  <TaskDescriptionEditor
2516
2849
  description={formState.description}
2517
2850
  setDescription={formState.setDescription}
@@ -2545,6 +2878,7 @@ export function TaskEditDialog({
2545
2878
  onImageUpload={imageUploadHandler}
2546
2879
  onEditorReady={handleEditorReady}
2547
2880
  onConvertToTask={handleConvertToTask}
2881
+ onDescriptionSnapshotChange={recordDescriptionSnapshot}
2548
2882
  onDescriptionStorageLengthChange={
2549
2883
  handleDescriptionStorageLengthChange
2550
2884
  }
@@ -2624,7 +2958,13 @@ export function TaskEditDialog({
2624
2958
  id: p.id,
2625
2959
  })),
2626
2960
  }}
2961
+ onRestoreDescriptionVersion={
2962
+ handleRestoreDescriptionVersion
2963
+ }
2627
2964
  revertDisabled={true}
2965
+ restoringDescriptionVersionId={
2966
+ restoringDescriptionVersionId
2967
+ }
2628
2968
  />
2629
2969
  )}
2630
2970
  </div>
@@ -2646,6 +2986,22 @@ export function TaskEditDialog({
2646
2986
  </DialogContent>
2647
2987
  </Dialog>
2648
2988
 
2989
+ <TaskDescriptionVersionRestoreDialog
2990
+ currentDescription={currentSerializedDescription}
2991
+ isOpen={showDescriptionVersionsDialog}
2992
+ locale={locale}
2993
+ onClose={() => setShowDescriptionVersionsDialog(false)}
2994
+ onRestoreVersion={handleRestoreDescriptionVersion}
2995
+ restoringVersionId={restoringDescriptionVersionId}
2996
+ t={
2997
+ historyT as (
2998
+ key: string,
2999
+ options?: { count?: number; defaultValue?: string }
3000
+ ) => string
3001
+ }
3002
+ versions={recoverableDescriptionVersions}
3003
+ />
3004
+
2649
3005
  <TaskDeleteDialog
2650
3006
  open={showDeleteConfirm}
2651
3007
  onOpenChange={setShowDeleteConfirm}
@@ -41,6 +41,7 @@ import {
41
41
  TaskSchedulingMenu,
42
42
  } from '../boards/boardId/menus';
43
43
  import { useTaskDialog } from '../hooks/useTaskDialog';
44
+ import type { TaskAssigneeMemberSource } from '../providers/task-dialog-provider';
44
45
  import { useTasksHref } from '../tasks-route-context';
45
46
 
46
47
  interface TaskRowActionsMenuProps {
@@ -49,6 +50,8 @@ interface TaskRowActionsMenuProps {
49
50
  workspaceId: string;
50
51
  lists: TaskList[];
51
52
  isPersonalWorkspace?: boolean;
53
+ canUseBoardAssignees?: boolean;
54
+ assigneeMemberSource?: TaskAssigneeMemberSource;
52
55
  onUpdate: () => void;
53
56
  open?: boolean;
54
57
  onOpenChange?: (open: boolean) => void;
@@ -78,6 +81,8 @@ export function TaskRowActionsMenu({
78
81
  workspaceId,
79
82
  lists,
80
83
  isPersonalWorkspace = false,
84
+ canUseBoardAssignees,
85
+ assigneeMemberSource,
81
86
  onUpdate,
82
87
  open,
83
88
  onOpenChange,
@@ -171,6 +176,12 @@ export function TaskRowActionsMenu({
171
176
  taskWorkspacePersonal: task.source_workspace_id
172
177
  ? false
173
178
  : isPersonalWorkspace,
179
+ canUseBoardAssignees:
180
+ canUseBoardAssignees ??
181
+ (task.source_workspace_id ? true : !isPersonalWorkspace),
182
+ assigneeMemberSource: task.source_workspace_id
183
+ ? 'workspace'
184
+ : assigneeMemberSource,
174
185
  }
175
186
  );
176
187
  setMenuOpen(false);
@@ -93,6 +93,7 @@ export function useProgressiveBoardLoader(
93
93
  offset: page * PAGE_SIZE,
94
94
  includeCount: true,
95
95
  ...options,
96
+ includeRelationshipSummary: false,
96
97
  });
97
98
  const tasks = payload.tasks ?? [];
98
99
  const loadedThrough = page * PAGE_SIZE + tasks.length;
@@ -206,6 +207,7 @@ export function useProgressiveBoardLoader(
206
207
  offset: page * PAGE_SIZE,
207
208
  includeCount: true,
208
209
  ...listOptionsRef.current[listId],
210
+ includeRelationshipSummary: false,
209
211
  })
210
212
  )
211
213
  );