@tuturuuu/ui 0.6.2 → 0.8.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 (108) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/biome.json +1 -1
  3. package/package.json +11 -11
  4. package/src/components/ui/calendar-app/components/calendar-connections.tsx +17 -13
  5. package/src/components/ui/calendar-app/components/connected-accounts-dialog.tsx +2 -5
  6. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +2 -5
  7. package/src/components/ui/calendar.test.tsx +24 -0
  8. package/src/components/ui/calendar.tsx +1 -0
  9. package/src/components/ui/currency-input.test.tsx +43 -0
  10. package/src/components/ui/currency-input.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  12. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  13. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  14. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  15. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  16. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  17. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  18. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  19. package/src/components/ui/date-time-picker.tsx +352 -234
  20. package/src/components/ui/finance/categories-tags-tabs.tsx +23 -1
  21. package/src/components/ui/finance/command/finance-command-actions.test.tsx +48 -0
  22. package/src/components/ui/finance/command/finance-command-actions.tsx +200 -0
  23. package/src/components/ui/finance/command/finance-command-provider.test.tsx +151 -0
  24. package/src/components/ui/finance/command/finance-command-provider.tsx +250 -0
  25. package/src/components/ui/finance/command/finance-command-results.tsx +262 -0
  26. package/src/components/ui/finance/invoices/pending-invoices-table.tsx +22 -9
  27. package/src/components/ui/finance/shared/quick-actions.tsx +39 -90
  28. package/src/components/ui/finance/tags/tag-manager.tsx +24 -5
  29. package/src/components/ui/finance/transactions/form-basic-tab.tsx +33 -49
  30. package/src/components/ui/finance/transactions/form-types.ts +5 -0
  31. package/src/components/ui/finance/transactions/form.test.tsx +105 -22
  32. package/src/components/ui/finance/transactions/form.tsx +116 -20
  33. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +13 -6
  34. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  35. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +25 -1
  36. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +16 -3
  37. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +3 -0
  38. package/src/components/ui/finance/transactions/transactionId/transaction-details-page.tsx +3 -0
  39. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +6 -0
  40. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +20 -2
  41. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -0
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +7 -2
  43. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +7 -2
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +38 -1
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +5 -0
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +18 -2
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +3 -0
  48. package/src/components/ui/finance/wallets/wallets-page.tsx +3 -0
  49. package/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +2 -9
  50. package/src/components/ui/money-input.test.tsx +64 -0
  51. package/src/components/ui/money-input.tsx +63 -0
  52. package/src/components/ui/optional-time-picker.tsx +95 -0
  53. package/src/components/ui/quick-command-center.test.tsx +90 -0
  54. package/src/components/ui/quick-command-center.tsx +190 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +126 -50
  56. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +23 -20
  58. package/src/components/ui/storefront/image-panel.tsx +6 -0
  59. package/src/components/ui/storefront/index.ts +11 -0
  60. package/src/components/ui/storefront/listing-card.tsx +84 -22
  61. package/src/components/ui/storefront/product-detail.tsx +289 -0
  62. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  63. package/src/components/ui/storefront/storefront-surface.test.tsx +132 -5
  64. package/src/components/ui/storefront/storefront-surface.tsx +371 -128
  65. package/src/components/ui/storefront/types.ts +25 -1
  66. package/src/components/ui/storefront/utils.ts +118 -13
  67. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  68. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  69. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  70. package/src/components/ui/text-editor/content-migration.ts +41 -18
  71. package/src/components/ui/text-editor/extensions.ts +1 -1
  72. package/src/components/ui/text-editor/image-extension.ts +40 -18
  73. package/src/components/ui/text-editor/video-extension.ts +11 -2
  74. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  75. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  76. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  77. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  78. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  79. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  80. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  81. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  82. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  83. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  84. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  85. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  86. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  87. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  88. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  89. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  90. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  91. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  92. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  93. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  94. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  95. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.test.ts +171 -0
  100. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +200 -36
  101. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +21 -2
  102. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  103. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  104. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  105. package/src/hooks/useBoardRealtime.ts +6 -3
  106. package/src/hooks/useBoardRealtime.types.ts +11 -0
  107. package/src/hooks/useCursorTracking.ts +91 -27
  108. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -1,11 +1,27 @@
1
1
  import {
2
+ abortWorkspaceTaskDescriptionChunks,
3
+ appendWorkspaceTaskDescriptionChunk,
4
+ beginWorkspaceTaskDescriptionChunks,
5
+ commitWorkspaceTaskDescriptionChunks,
2
6
  createWorkspaceTaskProject,
3
7
  getWorkspaceTask,
8
+ getWorkspaceTaskDescription,
9
+ updateWorkspaceTaskDescription as updateWorkspaceTaskDescriptionViaApi,
4
10
  updateWorkspaceTask as updateWorkspaceTaskViaApi,
11
+ type WorkspaceTaskDescriptionChunkField,
12
+ type WorkspaceTaskDescriptionChunkFields,
13
+ type WorkspaceTaskDescriptionResponse,
14
+ type WorkspaceTaskDescriptionUpdatePayload,
5
15
  type WorkspaceTaskUpdatePayload,
6
16
  } from '@tuturuuu/internal-api/tasks';
7
17
  import type { WorkspaceTaskLabel } from '../types';
8
18
 
19
+ const TASK_DESCRIPTION_DIRECT_BODY_LIMIT_BYTES = 192 * 1024;
20
+ const TASK_DESCRIPTION_CHUNK_TEXT_LENGTH = 96 * 1024;
21
+ const TASK_DESCRIPTION_TOO_LARGE_MESSAGE =
22
+ 'Description content is too large. Please shorten it or split it into smaller documents.';
23
+ const textEncoder = new TextEncoder();
24
+
9
25
  async function getErrorMessage(
10
26
  response: Response,
11
27
  fallback: string,
@@ -38,59 +54,207 @@ export async function fetchWorkspaceTask(wsId: string, taskId: string) {
38
54
  return getWorkspaceTask(wsId, taskId);
39
55
  }
40
56
 
41
- interface TaskDescriptionResponse {
42
- description: string | null;
43
- description_yjs_state: number[] | null;
44
- }
45
-
46
57
  export async function fetchWorkspaceTaskDescription(
47
58
  wsId: string,
48
59
  taskId: string
49
60
  ) {
50
- const requestPath = `/api/v1/workspaces/${wsId}/tasks/${taskId}/description`;
51
- const response = await fetch(requestPath, {
52
- method: 'GET',
53
- cache: 'no-store',
54
- });
61
+ return getWorkspaceTaskDescription(wsId, taskId);
62
+ }
55
63
 
56
- if (!response.ok) {
57
- throw new Error(
58
- await getErrorMessage(response, 'Failed to fetch task description', {
59
- requestPath,
60
- })
64
+ export async function updateWorkspaceTaskDescription(
65
+ wsId: string,
66
+ taskId: string,
67
+ payload: WorkspaceTaskDescriptionUpdatePayload
68
+ ) {
69
+ if (shouldChunkTaskDescriptionPayload(payload)) {
70
+ return updateWorkspaceTaskDescriptionChunked(wsId, taskId, payload);
71
+ }
72
+
73
+ try {
74
+ return await updateWorkspaceTaskDescriptionViaApi(wsId, taskId, payload);
75
+ } catch (error) {
76
+ if (isPayloadTooLargeError(error)) {
77
+ return updateWorkspaceTaskDescriptionChunked(wsId, taskId, payload);
78
+ }
79
+
80
+ throw normalizeTaskDescriptionError(
81
+ error,
82
+ 'Failed to update task description'
61
83
  );
62
84
  }
85
+ }
63
86
 
64
- return (await response.json()) as TaskDescriptionResponse;
87
+ export function shouldChunkTaskDescriptionPayload(
88
+ payload: WorkspaceTaskDescriptionUpdatePayload
89
+ ) {
90
+ return getJsonByteLength(payload) > TASK_DESCRIPTION_DIRECT_BODY_LIMIT_BYTES;
65
91
  }
66
92
 
67
- export async function updateWorkspaceTaskDescription(
93
+ export async function updateWorkspaceTaskDescriptionChunked(
68
94
  wsId: string,
69
95
  taskId: string,
70
- payload: {
71
- description?: string | null;
72
- description_yjs_state?: number[] | null;
96
+ payload: WorkspaceTaskDescriptionUpdatePayload
97
+ ): Promise<WorkspaceTaskDescriptionResponse> {
98
+ const chunksByField = buildTaskDescriptionChunks(payload);
99
+ const fields = buildTaskDescriptionChunkFields(chunksByField);
100
+
101
+ if (Object.keys(fields).length === 0) {
102
+ return updateWorkspaceTaskDescriptionViaApi(wsId, taskId, payload);
73
103
  }
74
- ) {
75
- const requestPath = `/api/v1/workspaces/${wsId}/tasks/${taskId}/description`;
76
- const response = await fetch(requestPath, {
77
- method: 'PATCH',
78
- headers: {
79
- 'Content-Type': 'application/json',
80
- },
81
- body: JSON.stringify(payload),
82
- cache: 'no-store',
83
- });
84
104
 
85
- if (!response.ok) {
86
- throw new Error(
87
- await getErrorMessage(response, 'Failed to update task description', {
88
- requestPath,
89
- })
105
+ const { session_id: sessionId } = await beginWorkspaceTaskDescriptionChunks(
106
+ wsId,
107
+ taskId,
108
+ fields
109
+ );
110
+
111
+ try {
112
+ for (const field of ['description', 'description_yjs_state'] as const) {
113
+ const chunks = chunksByField[field];
114
+ if (!chunks) continue;
115
+
116
+ for (let index = 0; index < chunks.length; index += 1) {
117
+ await appendWorkspaceTaskDescriptionChunk(wsId, taskId, {
118
+ chunk: chunks[index] ?? '',
119
+ chunk_index: index,
120
+ field,
121
+ session_id: sessionId,
122
+ });
123
+ }
124
+ }
125
+
126
+ return await commitWorkspaceTaskDescriptionChunks(wsId, taskId, sessionId);
127
+ } catch (error) {
128
+ await abortWorkspaceTaskDescriptionChunks(wsId, taskId, sessionId).catch(
129
+ () => undefined
130
+ );
131
+
132
+ throw normalizeTaskDescriptionError(
133
+ error,
134
+ 'Failed to update task description'
90
135
  );
91
136
  }
137
+ }
138
+
139
+ function getJsonByteLength(value: unknown) {
140
+ return textEncoder.encode(JSON.stringify(value)).byteLength;
141
+ }
142
+
143
+ function isPayloadTooLargeError(error: unknown) {
144
+ return (
145
+ typeof error === 'object' &&
146
+ error !== null &&
147
+ 'status' in error &&
148
+ (error as { status?: unknown }).status === 413
149
+ );
150
+ }
151
+
152
+ function normalizeTaskDescriptionError(error: unknown, fallback: string) {
153
+ if (isPayloadTooLargeError(error)) {
154
+ return new Error(TASK_DESCRIPTION_TOO_LARGE_MESSAGE);
155
+ }
156
+
157
+ return error instanceof Error ? error : new Error(fallback);
158
+ }
159
+
160
+ function splitStringIntoChunks(value: string) {
161
+ const chunks: string[] = [];
162
+
163
+ for (
164
+ let index = 0;
165
+ index < value.length;
166
+ index += TASK_DESCRIPTION_CHUNK_TEXT_LENGTH
167
+ ) {
168
+ chunks.push(value.slice(index, index + TASK_DESCRIPTION_CHUNK_TEXT_LENGTH));
169
+ }
170
+
171
+ return chunks.length > 0 ? chunks : [''];
172
+ }
173
+
174
+ function bytesToBase64(bytes: number[]) {
175
+ const byteArray = Uint8Array.from(bytes);
176
+
177
+ if (typeof btoa === 'function') {
178
+ let binary = '';
179
+ const step = 0x8000;
180
+
181
+ for (let index = 0; index < byteArray.length; index += step) {
182
+ binary += String.fromCharCode(...byteArray.subarray(index, index + step));
183
+ }
184
+
185
+ return btoa(binary);
186
+ }
187
+
188
+ const nodeBuffer = (
189
+ globalThis as typeof globalThis & {
190
+ Buffer?: {
191
+ from: (value: Uint8Array) => {
192
+ toString: (encoding: 'base64') => string;
193
+ };
194
+ };
195
+ }
196
+ ).Buffer;
197
+
198
+ if (!nodeBuffer) {
199
+ throw new Error('Unable to encode task description state.');
200
+ }
201
+
202
+ return nodeBuffer.from(byteArray).toString('base64');
203
+ }
204
+
205
+ function buildTaskDescriptionChunks(
206
+ payload: WorkspaceTaskDescriptionUpdatePayload
207
+ ): Partial<Record<WorkspaceTaskDescriptionChunkField, string[] | null>> {
208
+ const chunks: Partial<
209
+ Record<WorkspaceTaskDescriptionChunkField, string[] | null>
210
+ > = {};
211
+
212
+ if ('description' in payload) {
213
+ chunks.description =
214
+ payload.description === null
215
+ ? null
216
+ : splitStringIntoChunks(payload.description ?? '');
217
+ }
218
+
219
+ if ('description_yjs_state' in payload) {
220
+ chunks.description_yjs_state =
221
+ payload.description_yjs_state === null
222
+ ? null
223
+ : splitStringIntoChunks(
224
+ bytesToBase64(payload.description_yjs_state ?? [])
225
+ );
226
+ }
227
+
228
+ return chunks;
229
+ }
230
+
231
+ function buildTaskDescriptionChunkFields(
232
+ chunksByField: Partial<
233
+ Record<WorkspaceTaskDescriptionChunkField, string[] | null>
234
+ >
235
+ ) {
236
+ const fields: WorkspaceTaskDescriptionChunkFields = {};
237
+
238
+ for (const field of ['description', 'description_yjs_state'] as const) {
239
+ const chunks = chunksByField[field];
240
+ if (chunks === undefined) continue;
241
+
242
+ if (chunks === null) {
243
+ fields[field] = {
244
+ chunk_count: 0,
245
+ is_null: true,
246
+ total_length: 0,
247
+ };
248
+ continue;
249
+ }
250
+
251
+ fields[field] = {
252
+ chunk_count: chunks.length,
253
+ total_length: chunks.join('').length,
254
+ };
255
+ }
92
256
 
93
- return (await response.json()) as TaskDescriptionResponse;
257
+ return fields;
94
258
  }
95
259
 
96
260
  export async function createWorkspaceLabel(
@@ -34,6 +34,7 @@ import {
34
34
  updateTaskDescriptionCaches,
35
35
  } from '../utils';
36
36
  import {
37
+ shouldChunkTaskDescriptionPayload,
37
38
  updateWorkspaceTask,
38
39
  updateWorkspaceTaskDescription,
39
40
  } from './task-api';
@@ -945,9 +946,17 @@ export async function handleCreateTask({
945
946
  ];
946
947
  }
947
948
 
949
+ const createDescriptionPayload = {
950
+ description: descriptionString || '',
951
+ description_yjs_state: descriptionYjsState ?? undefined,
952
+ };
953
+ const shouldDeferDescription = shouldChunkTaskDescriptionPayload(
954
+ createDescriptionPayload
955
+ );
956
+
948
957
  const taskData: Partial<Task> = {
949
958
  name: name.trim(),
950
- description: descriptionString || '',
959
+ description: shouldDeferDescription ? '' : descriptionString || '',
951
960
  priority: priority,
952
961
  start_date: startDate ? startDate.toISOString() : undefined,
953
962
  end_date: endDate ? endDate.toISOString() : undefined,
@@ -957,7 +966,9 @@ export async function handleCreateTask({
957
966
  };
958
967
  const newTask = await createTask(wsId, selectedListId, {
959
968
  ...taskData,
960
- description_yjs_state: descriptionYjsState ?? undefined,
969
+ description_yjs_state: shouldDeferDescription
970
+ ? undefined
971
+ : (descriptionYjsState ?? undefined),
961
972
  label_ids: selectedLabels.map((label) => label.id),
962
973
  assignee_ids: desiredAssignees
963
974
  .map((assignee) => assignee.user_id || assignee.id)
@@ -965,6 +976,14 @@ export async function handleCreateTask({
965
976
  project_ids: selectedProjects.map((project) => project.id),
966
977
  });
967
978
 
979
+ if (shouldDeferDescription) {
980
+ await updateWorkspaceTaskDescription(wsId, newTask.id, {
981
+ description: descriptionString,
982
+ description_yjs_state: descriptionYjsState,
983
+ });
984
+ newTask.description = descriptionString ?? undefined;
985
+ }
986
+
968
987
  // Save per-user scheduling settings for the creator (if any were provided)
969
988
  const hasAnySchedulingValue =
970
989
  totalDuration != null ||
@@ -47,6 +47,7 @@ import { createInitialSuggestionState } from './mention-system/types';
47
47
  import { SyncWarningDialog } from './sync-warning-dialog';
48
48
  import {
49
49
  normalizeTaskDialogPresentation,
50
+ resolveTaskDialogOpeningPresentation,
50
51
  type TaskDialogPresentation,
51
52
  } from './task-dialog-presentation';
52
53
  import { CompactTaskDialogPanel } from './task-edit-dialog/components/compact-task-create-popover';
@@ -107,6 +108,7 @@ import {
107
108
  getDescriptionContent,
108
109
  getDraftStorageKey,
109
110
  getTaskDescriptionPercentLeft,
111
+ getTaskDescriptionPreviewText,
110
112
  getTaskDescriptionStorageLength,
111
113
  saveAndVerifyYjsDescriptionToDatabase,
112
114
  saveYjsDescriptionToDatabase,
@@ -494,6 +496,23 @@ export function TaskEditDialog({
494
496
  taskSearchQuery,
495
497
  sharedContext,
496
498
  });
499
+ const currentList = availableLists?.find(
500
+ (list) => list.id === formState.selectedListId
501
+ );
502
+ const normalizedDefaultPresentation = useMemo(
503
+ () => normalizeTaskDialogPresentation(defaultPresentation),
504
+ [defaultPresentation]
505
+ );
506
+ const openingPresentation = useMemo(
507
+ () =>
508
+ resolveTaskDialogOpeningPresentation({
509
+ defaultPresentation: normalizedDefaultPresentation,
510
+ draftId,
511
+ mode,
512
+ selectedListStatus: currentList?.status,
513
+ }),
514
+ [currentList?.status, draftId, mode, normalizedDefaultPresentation]
515
+ );
497
516
 
498
517
  // Update browser tab title
499
518
  useEffect(() => {
@@ -552,13 +571,8 @@ export function TaskEditDialog({
552
571
  useState(false);
553
572
  const [showShareDialog, setShowShareDialog] = useState(false);
554
573
  const [saveAsDraft, setSaveAsDraft] = useState(draftModeEnabled);
555
- const normalizedDefaultPresentation = useMemo(
556
- () => normalizeTaskDialogPresentation(defaultPresentation),
557
- [defaultPresentation]
558
- );
559
- const [presentation, setPresentation] = useState<TaskDialogPresentation>(
560
- normalizedDefaultPresentation
561
- );
574
+ const [presentation, setPresentation] =
575
+ useState<TaskDialogPresentation>(openingPresentation);
562
576
  const [smartSuggestions, setSmartSuggestions] = useState<
563
577
  WorkspaceTaskSuggestionTask[]
564
578
  >([]);
@@ -849,9 +863,6 @@ export function TaskEditDialog({
849
863
  onUpdate,
850
864
  });
851
865
 
852
- const currentList = availableLists?.find(
853
- (list) => list.id === formState.selectedListId
854
- );
855
866
  const doneList = availableLists?.find(
856
867
  (list) => list.status === 'done' && !list.deleted
857
868
  );
@@ -1946,7 +1957,7 @@ export function TaskEditDialog({
1946
1957
  previousOpenRef.current = isOpen;
1947
1958
 
1948
1959
  if (!isOpen) {
1949
- setPresentation(normalizedDefaultPresentation);
1960
+ setPresentation(openingPresentation);
1950
1961
  setSmartSuggestions([]);
1951
1962
  setSelectedSmartSuggestionIds([]);
1952
1963
  setSmartSuggestionError(null);
@@ -1957,9 +1968,14 @@ export function TaskEditDialog({
1957
1968
  }
1958
1969
 
1959
1970
  if (justOpened) {
1960
- setPresentation(draftId ? 'fullscreen' : normalizedDefaultPresentation);
1971
+ setPresentation(openingPresentation);
1972
+ return;
1973
+ }
1974
+
1975
+ if (!isCreateMode && currentList?.status === 'documents') {
1976
+ setPresentation('fullscreen');
1961
1977
  }
1962
- }, [isOpen, draftId, normalizedDefaultPresentation]);
1978
+ }, [currentList?.status, isCreateMode, isOpen, openingPresentation]);
1963
1979
 
1964
1980
  // Track whether the title input is scrolled out of view
1965
1981
  useEffect(() => {
@@ -2023,6 +2039,15 @@ export function TaskEditDialog({
2023
2039
  ]);
2024
2040
 
2025
2041
  const showCompactDialog = presentation === 'compact' && !draftId;
2042
+ const compactDescriptionPreview = useMemo(() => {
2043
+ if (isCreateMode) return null;
2044
+
2045
+ const previewText = getTaskDescriptionPreviewText(
2046
+ formState.description
2047
+ ).trim();
2048
+
2049
+ return previewText || null;
2050
+ }, [formState.description, isCreateMode]);
2026
2051
  const taskHydrationNotice = taskLoadError ? (
2027
2052
  <div
2028
2053
  className="mx-4 mb-2 flex items-center justify-between gap-3 rounded-md border border-dynamic-red/30 bg-dynamic-red/10 px-3 py-2 text-dynamic-red text-sm md:mx-8"
@@ -2235,7 +2260,7 @@ export function TaskEditDialog({
2235
2260
  showCloseButton={false}
2236
2261
  className={
2237
2262
  showCompactDialog
2238
- ? 'w-[min(calc(100vw-2rem),30rem)] max-w-[30rem] gap-0 overflow-hidden rounded-lg border p-0 shadow-xl'
2263
+ ? 'w-[min(calc(100vw-2rem),30rem)] max-w-[30rem] gap-0 overflow-visible rounded-lg border p-0 shadow-xl'
2239
2264
  : 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-bottom-2 data-[state=open]:slide-in-from-bottom-2 inset-0! top-0! left-0! flex h-screen max-h-screen w-screen max-w-none! translate-x-0! translate-y-0! gap-0 rounded-none! border-0 p-0'
2240
2265
  }
2241
2266
  onContextMenu={(e) => {
@@ -2271,6 +2296,8 @@ export function TaskEditDialog({
2271
2296
  iconBgClass={compactHeaderInfo.iconBgClass}
2272
2297
  iconRingClass={compactHeaderInfo.iconRingClass}
2273
2298
  showHeaderTitle={isCreateMode}
2299
+ descriptionPreview={compactDescriptionPreview}
2300
+ descriptionPreviewLabel={dialogT('open_fullscreen')}
2274
2301
  titleInput={
2275
2302
  <TaskNameInput
2276
2303
  name={formState.name}
@@ -2311,6 +2338,7 @@ export function TaskEditDialog({
2311
2338
  }
2312
2339
  onClose={handleAttemptClose}
2313
2340
  onFullscreen={() => setPresentation('fullscreen')}
2341
+ onDescriptionPreviewClick={() => setPresentation('fullscreen')}
2314
2342
  onSave={
2315
2343
  isCreateMode && !taskControlsDisabled ? handleSave : undefined
2316
2344
  }
@@ -248,7 +248,7 @@ describe('useBoardRealtime', () => {
248
248
  expect(mockChannel.subscribe).toHaveBeenCalledTimes(1);
249
249
  });
250
250
 
251
- it('should create channel with self: false config', () => {
251
+ it('should create a private channel with self: false config', () => {
252
252
  renderHook(() => useBoardRealtime('board-1', { enabled: true }), {
253
253
  wrapper,
254
254
  });
@@ -258,7 +258,7 @@ describe('useBoardRealtime', () => {
258
258
  )();
259
259
  expect(supabaseInstance.channel).toHaveBeenCalledWith(
260
260
  'board-realtime-board-1',
261
- { config: { broadcast: { self: false } } }
261
+ { config: { broadcast: { self: false }, private: true } }
262
262
  );
263
263
  });
264
264
 
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { act, renderHook, waitFor } from '@testing-library/react';
6
+ import { createClient } from '@tuturuuu/supabase/next/client';
7
+ import type { RefObject } from 'react';
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { PRIVATE_TASK_REALTIME_CHANNEL_CONFIG } from '../useBoardRealtime.types';
10
+ import { useCursorTracking } from '../useCursorTracking';
11
+
12
+ type BroadcastListener = (message: {
13
+ payload: Record<string, unknown>;
14
+ }) => void;
15
+
16
+ type MockSupabaseClient = {
17
+ channel: ReturnType<typeof vi.fn>;
18
+ removeChannel: ReturnType<typeof vi.fn>;
19
+ };
20
+
21
+ type MockCreateClientFn = {
22
+ (): MockSupabaseClient;
23
+ mockReturnValue: (value: MockSupabaseClient) => void;
24
+ };
25
+
26
+ vi.mock('@tuturuuu/supabase/next/client', () => ({
27
+ createClient: vi.fn(),
28
+ }));
29
+
30
+ vi.mock('@tuturuuu/utils/constants', () => ({
31
+ DEV_MODE: false,
32
+ }));
33
+
34
+ function createContainerRef(): RefObject<HTMLDivElement | null> {
35
+ const element = document.createElement('div');
36
+ element.getBoundingClientRect = () =>
37
+ ({
38
+ bottom: 100,
39
+ height: 100,
40
+ left: 0,
41
+ right: 100,
42
+ top: 0,
43
+ width: 100,
44
+ x: 0,
45
+ y: 0,
46
+ }) as DOMRect;
47
+ document.body.append(element);
48
+ return { current: element };
49
+ }
50
+
51
+ describe('useCursorTracking', () => {
52
+ let broadcastListeners: Map<string, BroadcastListener>;
53
+ let mockChannel: {
54
+ on: ReturnType<typeof vi.fn>;
55
+ send: ReturnType<typeof vi.fn>;
56
+ subscribe: ReturnType<typeof vi.fn>;
57
+ };
58
+ let mockRemoveChannel: ReturnType<typeof vi.fn>;
59
+ let requestAnimationFrameMock: ReturnType<typeof vi.fn>;
60
+ let animationFrameCallbacks: FrameRequestCallback[];
61
+
62
+ beforeEach(() => {
63
+ broadcastListeners = new Map();
64
+ animationFrameCallbacks = [];
65
+
66
+ requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
67
+ animationFrameCallbacks.push(callback);
68
+ return animationFrameCallbacks.length;
69
+ });
70
+ vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock);
71
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
72
+
73
+ mockChannel = {
74
+ on: vi.fn(
75
+ (
76
+ type: string,
77
+ config: { event?: string },
78
+ callback: BroadcastListener
79
+ ) => {
80
+ if (type === 'broadcast' && config.event) {
81
+ broadcastListeners.set(config.event, callback);
82
+ }
83
+ return mockChannel;
84
+ }
85
+ ),
86
+ send: vi.fn(),
87
+ subscribe: vi.fn(() => mockChannel),
88
+ };
89
+ mockRemoveChannel = vi.fn();
90
+
91
+ const mockCreateClient = createClient as unknown as MockCreateClientFn;
92
+ mockCreateClient.mockReturnValue({
93
+ channel: vi.fn(() => mockChannel),
94
+ removeChannel: mockRemoveChannel,
95
+ });
96
+ });
97
+
98
+ afterEach(() => {
99
+ document.body.replaceChildren();
100
+ vi.unstubAllGlobals();
101
+ });
102
+
103
+ it('subscribes to cursor channels with private realtime authorization', () => {
104
+ const containerRef = createContainerRef();
105
+
106
+ renderHook(() =>
107
+ useCursorTracking('board-realtime-board-1', containerRef, {
108
+ display_name: 'Current User',
109
+ id: 'user-current',
110
+ })
111
+ );
112
+
113
+ const supabaseInstance = (createClient as unknown as MockCreateClientFn)();
114
+ expect(supabaseInstance.channel).toHaveBeenCalledWith(
115
+ 'board-realtime-board-1',
116
+ PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
117
+ );
118
+ });
119
+
120
+ it('broadcasts cursor payloads without private profile fields', async () => {
121
+ const containerRef = createContainerRef();
122
+
123
+ renderHook(() =>
124
+ useCursorTracking(
125
+ 'board-realtime-board-1',
126
+ containerRef,
127
+ {
128
+ avatar_url: 'https://example.com/avatar.png',
129
+ display_name: 'Current User',
130
+ email: 'current@example.com',
131
+ id: 'user-current',
132
+ },
133
+ { cursorScope: { boardId: 'board-1', type: 'board' } }
134
+ )
135
+ );
136
+
137
+ act(() => {
138
+ containerRef.current?.dispatchEvent(
139
+ new MouseEvent('mousemove', { clientX: 80, clientY: 40 })
140
+ );
141
+ animationFrameCallbacks.shift()?.(0);
142
+ });
143
+
144
+ await waitFor(() => expect(mockChannel.send).toHaveBeenCalled());
145
+ expect(mockChannel.send).toHaveBeenCalledWith({
146
+ event: 'cursor-move',
147
+ payload: {
148
+ metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
149
+ user: {
150
+ avatar_url: 'https://example.com/avatar.png',
151
+ display_name: 'Current User',
152
+ id: 'user-current',
153
+ },
154
+ x: expect.any(Number),
155
+ y: expect.any(Number),
156
+ },
157
+ type: 'broadcast',
158
+ });
159
+ });
160
+
161
+ it('accepts only well-formed cursor payloads and strips private fields', () => {
162
+ const containerRef = createContainerRef();
163
+ const { result } = renderHook(() =>
164
+ useCursorTracking('board-realtime-board-1', containerRef, {
165
+ display_name: 'Current User',
166
+ id: 'user-current',
167
+ })
168
+ );
169
+
170
+ const listener = broadcastListeners.get('cursor-move');
171
+ expect(listener).toBeDefined();
172
+
173
+ act(() => {
174
+ listener?.({
175
+ payload: {
176
+ user: { email: 'bad@example.com' },
177
+ x: 12,
178
+ y: 18,
179
+ },
180
+ });
181
+ });
182
+ expect(result.current.cursors.size).toBe(0);
183
+
184
+ act(() => {
185
+ listener?.({
186
+ payload: {
187
+ metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
188
+ user: {
189
+ avatar_url: 'https://example.com/other.png',
190
+ display_name: 'Other User',
191
+ email: 'other@example.com',
192
+ id: 'user-other',
193
+ },
194
+ x: 12,
195
+ y: 18,
196
+ },
197
+ });
198
+ });
199
+
200
+ expect(result.current.cursors.get('user-other')).toEqual({
201
+ lastUpdatedAt: expect.any(Number),
202
+ metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
203
+ user: {
204
+ avatar_url: 'https://example.com/other.png',
205
+ display_name: 'Other User',
206
+ id: 'user-other',
207
+ },
208
+ x: 12,
209
+ y: 18,
210
+ });
211
+ });
212
+ });