@tuturuuu/ui 0.4.0 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -6
  3. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +62 -0
  4. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +8 -2
  5. package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +332 -0
  6. package/src/components/ui/chat/chat-sidebar-groups.test.ts +44 -0
  7. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +2 -0
  8. package/src/components/ui/chat/chat-sidebar-panel.tsx +6 -0
  9. package/src/components/ui/chat/chat-sidebar-sections.ts +199 -0
  10. package/src/components/ui/chat/chat-sidebar.tsx +11 -258
  11. package/src/components/ui/chat/chat-workspace.tsx +5 -0
  12. package/src/components/ui/chat/utils.ts +7 -0
  13. package/src/components/ui/custom/settings/task-settings.tsx +76 -0
  14. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +126 -0
  15. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  16. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  17. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  18. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  19. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +172 -0
  20. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  21. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +196 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +277 -0
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +189 -0
  27. package/src/components/ui/finance/wallets/query-invalidation.ts +2 -0
  28. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +7 -3
  29. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +10 -0
  30. package/src/components/ui/finance/wallets/wallets-page.test.tsx +7 -0
  31. package/src/components/ui/finance/wallets/wallets-page.tsx +21 -5
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  34. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  35. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +10 -0
  36. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +141 -0
  37. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +208 -0
  38. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  39. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  40. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +24 -0
  41. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +18 -3
  42. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  43. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  44. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  45. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +411 -323
  46. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  47. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  48. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  49. package/src/hooks/use-task-actions.ts +45 -0
@@ -0,0 +1,199 @@
1
+ import type { ChatConversation } from '@tuturuuu/internal-api';
2
+ import type { ChatConversationScope } from './utils';
3
+
4
+ export type ChatConversationSectionLabels = {
5
+ ai: string;
6
+ channel: string;
7
+ direct: string;
8
+ group: string;
9
+ };
10
+
11
+ export type ChatConversationSourceLabels = {
12
+ external: string;
13
+ zaloPersonal: string;
14
+ };
15
+
16
+ export interface ChatConversationSourceGroup {
17
+ conversations: ChatConversation[];
18
+ id: string;
19
+ label: string;
20
+ }
21
+
22
+ export interface ChatConversationSection {
23
+ conversations: ChatConversation[];
24
+ label: string | null;
25
+ sectionType: ChatConversation['type'];
26
+ sourceGroups: ChatConversationSourceGroup[];
27
+ }
28
+
29
+ const DEFAULT_SOURCE_LABELS = {
30
+ external: 'External source',
31
+ zaloPersonal: 'Zalo Personal',
32
+ } as const satisfies ChatConversationSourceLabels;
33
+
34
+ export function getChatConversationSections({
35
+ conversations,
36
+ labels,
37
+ scope,
38
+ sourceLabels = DEFAULT_SOURCE_LABELS,
39
+ }: {
40
+ conversations: ChatConversation[];
41
+ labels: ChatConversationSectionLabels;
42
+ scope?: ChatConversationScope;
43
+ sourceLabels?: ChatConversationSourceLabels;
44
+ }): ChatConversationSection[] {
45
+ if (scope === 'workspaces') {
46
+ return [
47
+ createChatConversationSection({
48
+ conversations: conversations.filter(
49
+ (conversation) => conversation.type === 'channel'
50
+ ),
51
+ label: labels.channel,
52
+ sectionType: 'channel',
53
+ sourceLabels,
54
+ }),
55
+ createChatConversationSection({
56
+ conversations: conversations.filter(
57
+ (conversation) => conversation.type === 'ai'
58
+ ),
59
+ label: labels.ai,
60
+ sectionType: 'ai',
61
+ sourceLabels,
62
+ }),
63
+ ];
64
+ }
65
+
66
+ if (scope === 'personal') {
67
+ return [
68
+ createChatConversationSection({
69
+ conversations: conversations.filter(
70
+ (conversation) => conversation.type === 'direct'
71
+ ),
72
+ label: labels.direct,
73
+ sectionType: 'direct',
74
+ sourceLabels,
75
+ }),
76
+ createChatConversationSection({
77
+ conversations: conversations.filter(
78
+ (conversation) => conversation.type === 'group'
79
+ ),
80
+ label: labels.group,
81
+ sectionType: 'group',
82
+ sourceLabels,
83
+ }),
84
+ createChatConversationSection({
85
+ conversations: conversations.filter(
86
+ (conversation) => conversation.type === 'channel'
87
+ ),
88
+ label: labels.channel,
89
+ sectionType: 'channel',
90
+ sourceLabels,
91
+ }),
92
+ createChatConversationSection({
93
+ conversations: conversations.filter(
94
+ (conversation) => conversation.type === 'ai'
95
+ ),
96
+ label: labels.ai,
97
+ sectionType: 'ai',
98
+ sourceLabels,
99
+ }),
100
+ ];
101
+ }
102
+
103
+ return [
104
+ createChatConversationSection({
105
+ conversations,
106
+ label: null,
107
+ sectionType: 'direct',
108
+ sourceLabels,
109
+ }),
110
+ ];
111
+ }
112
+
113
+ export function getChatConversationSourceGroups({
114
+ conversations,
115
+ labels = DEFAULT_SOURCE_LABELS,
116
+ }: {
117
+ conversations: ChatConversation[];
118
+ labels?: ChatConversationSourceLabels;
119
+ }) {
120
+ const groups = new Map<string, ChatConversationSourceGroup>();
121
+
122
+ for (const conversation of conversations) {
123
+ const sourceGroup = getChatConversationSourceGroup(conversation, labels);
124
+
125
+ if (!sourceGroup) continue;
126
+
127
+ const group = groups.get(sourceGroup.id);
128
+
129
+ if (group) {
130
+ group.conversations.push(conversation);
131
+ } else {
132
+ groups.set(sourceGroup.id, {
133
+ ...sourceGroup,
134
+ conversations: [conversation],
135
+ });
136
+ }
137
+ }
138
+
139
+ return [...groups.values()];
140
+ }
141
+
142
+ function createChatConversationSection({
143
+ conversations,
144
+ label,
145
+ sectionType,
146
+ sourceLabels,
147
+ }: {
148
+ conversations: ChatConversation[];
149
+ label: string | null;
150
+ sectionType: ChatConversation['type'];
151
+ sourceLabels: ChatConversationSourceLabels;
152
+ }): ChatConversationSection {
153
+ const sourceGroups = getChatConversationSourceGroups({
154
+ conversations,
155
+ labels: sourceLabels,
156
+ });
157
+ const sourceConversationIds = new Set(
158
+ sourceGroups.flatMap((group) =>
159
+ group.conversations.map((conversation) => conversation.id)
160
+ )
161
+ );
162
+
163
+ return {
164
+ conversations: conversations.filter(
165
+ (conversation) => !sourceConversationIds.has(conversation.id)
166
+ ),
167
+ label,
168
+ sectionType,
169
+ sourceGroups,
170
+ };
171
+ }
172
+
173
+ function getChatConversationSourceGroup(
174
+ conversation: ChatConversation,
175
+ labels: ChatConversationSourceLabels
176
+ ): Omit<ChatConversationSourceGroup, 'conversations'> | null {
177
+ const metadata = conversation.metadata ?? {};
178
+
179
+ if (metadata.source !== 'ai-agent-external-thread') return null;
180
+
181
+ const adapter = readString(metadata.adapter);
182
+
183
+ if (adapter !== 'zalo') return null;
184
+
185
+ const agentId = readString(metadata.agentId) ?? 'unknown-agent';
186
+ const channelId =
187
+ readString(metadata.channelId) ??
188
+ readString(metadata.externalChannelId) ??
189
+ 'unknown-channel';
190
+
191
+ return {
192
+ id: `external:${adapter}:${agentId}:${channelId}`,
193
+ label: labels.zaloPersonal,
194
+ };
195
+ }
196
+
197
+ function readString(value: unknown) {
198
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
199
+ }
@@ -1,6 +1,5 @@
1
1
  'use client';
2
2
 
3
- import { useVirtualizer } from '@tanstack/react-virtual';
4
3
  import {
5
4
  Bot,
6
5
  Funnel,
@@ -13,7 +12,7 @@ import {
13
12
  import type { ChatConversation, ChatMessage } from '@tuturuuu/internal-api';
14
13
  import { cn } from '@tuturuuu/utils/format';
15
14
  import { useTranslations } from 'next-intl';
16
- import { type ReactNode, type UIEvent, useMemo, useRef, useState } from 'react';
15
+ import { type ReactNode, useState } from 'react';
17
16
  import { Button } from '../button';
18
17
  import {
19
18
  DropdownMenu,
@@ -24,12 +23,18 @@ import {
24
23
  DropdownMenuTrigger,
25
24
  } from '../dropdown-menu';
26
25
  import { Input } from '../input';
27
- import { ConversationRow, SearchResultList } from './chat-sidebar-items';
26
+ import { ConversationGroups } from './chat-sidebar-conversation-groups';
27
+ import { SearchResultList } from './chat-sidebar-items';
28
28
  import type {
29
29
  ChatConversationArchiveFilter,
30
30
  ChatConversationScope,
31
31
  } from './utils';
32
32
 
33
+ export {
34
+ getChatConversationSections,
35
+ getChatConversationSourceGroups,
36
+ } from './chat-sidebar-sections';
37
+
33
38
  interface ChatSidebarProps {
34
39
  actions?: ReactNode;
35
40
  archiveFilter?: ChatConversationArchiveFilter;
@@ -51,6 +56,7 @@ interface ChatSidebarProps {
51
56
  scope?: ChatConversationScope;
52
57
  showControls?: boolean;
53
58
  showTitle?: boolean;
59
+ sourceGroupStorageKey?: string | null;
54
60
  searchValue: string;
55
61
  selectedConversationId?: string | null;
56
62
  }
@@ -76,6 +82,7 @@ export function ChatSidebar({
76
82
  scope,
77
83
  showControls = true,
78
84
  showTitle = true,
85
+ sourceGroupStorageKey,
79
86
  searchValue,
80
87
  selectedConversationId,
81
88
  }: ChatSidebarProps) {
@@ -154,6 +161,7 @@ export function ChatSidebar({
154
161
  archiveFilter={archiveFilter}
155
162
  scope={scope}
156
163
  selectedConversationId={selectedConversationId}
164
+ sourceGroupStorageKey={sourceGroupStorageKey}
157
165
  />
158
166
  ) : (
159
167
  <div className="p-6 text-center">
@@ -171,261 +179,6 @@ export function ChatSidebar({
171
179
  );
172
180
  }
173
181
 
174
- type ConversationListItem =
175
- | { key: string; label: string; type: 'archive-label' }
176
- | {
177
- key: string;
178
- label: string;
179
- sectionType: ChatConversation['type'];
180
- type: 'group-label';
181
- }
182
- | { conversation: ChatConversation; key: string; type: 'conversation' }
183
- | { key: string; type: 'loader' };
184
-
185
- type ChatConversationSectionLabels = {
186
- ai: string;
187
- channel: string;
188
- direct: string;
189
- group: string;
190
- };
191
-
192
- export function getChatConversationSections({
193
- conversations,
194
- labels,
195
- scope,
196
- }: {
197
- conversations: ChatConversation[];
198
- labels: ChatConversationSectionLabels;
199
- scope?: ChatConversationScope;
200
- }) {
201
- if (scope === 'workspaces') {
202
- return [
203
- {
204
- conversations: conversations.filter(
205
- (conversation) => conversation.type === 'channel'
206
- ),
207
- label: labels.channel,
208
- sectionType: 'channel' as const,
209
- },
210
- {
211
- conversations: conversations.filter(
212
- (conversation) => conversation.type === 'ai'
213
- ),
214
- label: labels.ai,
215
- sectionType: 'ai' as const,
216
- },
217
- ];
218
- }
219
-
220
- if (scope === 'personal') {
221
- return [
222
- {
223
- conversations: conversations.filter(
224
- (conversation) => conversation.type === 'direct'
225
- ),
226
- label: labels.direct,
227
- sectionType: 'direct' as const,
228
- },
229
- {
230
- conversations: conversations.filter(
231
- (conversation) => conversation.type === 'group'
232
- ),
233
- label: labels.group,
234
- sectionType: 'group' as const,
235
- },
236
- {
237
- conversations: conversations.filter(
238
- (conversation) => conversation.type === 'channel'
239
- ),
240
- label: labels.channel,
241
- sectionType: 'channel' as const,
242
- },
243
- {
244
- conversations: conversations.filter(
245
- (conversation) => conversation.type === 'ai'
246
- ),
247
- label: labels.ai,
248
- sectionType: 'ai' as const,
249
- },
250
- ];
251
- }
252
-
253
- return [
254
- {
255
- conversations,
256
- label: null,
257
- sectionType: 'direct' as const,
258
- },
259
- ];
260
- }
261
-
262
- function ConversationGroups({
263
- archiveFilter,
264
- conversations,
265
- currentUserId,
266
- hasMoreConversations,
267
- isFetchingMoreConversations,
268
- onArchiveConversation,
269
- onLoadMoreConversations,
270
- onPinConversation,
271
- onSelectConversation,
272
- scope,
273
- selectedConversationId,
274
- }: {
275
- archiveFilter: ChatConversationArchiveFilter;
276
- conversations: ChatConversation[];
277
- currentUserId: string;
278
- hasMoreConversations?: boolean;
279
- isFetchingMoreConversations?: boolean;
280
- onArchiveConversation?: (conversationId: string) => void;
281
- onLoadMoreConversations?: () => Promise<unknown> | undefined;
282
- onPinConversation?: (conversationId: string, pinned: boolean) => void;
283
- onSelectConversation: (conversationId: string) => void;
284
- scope?: ChatConversationScope;
285
- selectedConversationId?: string | null;
286
- }) {
287
- const t = useTranslations('chat');
288
- const parentRef = useRef<HTMLDivElement | null>(null);
289
- const groups = useMemo(
290
- () =>
291
- getChatConversationSections({
292
- conversations,
293
- labels: {
294
- ai: t('ai_agents'),
295
- channel: t('channels'),
296
- direct: t('direct_messages'),
297
- group: t('groups'),
298
- },
299
- scope,
300
- }),
301
- [conversations, scope, t]
302
- );
303
- const items = useMemo<ConversationListItem[]>(() => {
304
- const next: ConversationListItem[] = [];
305
- if (archiveFilter !== 'active') {
306
- next.push({
307
- key: 'archive-label',
308
- label:
309
- archiveFilter === 'archived'
310
- ? t('showing_archived_chats')
311
- : t('showing_all_chats'),
312
- type: 'archive-label',
313
- });
314
- }
315
-
316
- for (const [index, group] of groups.entries()) {
317
- if (group.conversations.length === 0) continue;
318
- if (group.label) {
319
- next.push({
320
- key: `group-${group.label}-${index}`,
321
- label: group.label,
322
- sectionType: group.sectionType,
323
- type: 'group-label',
324
- });
325
- }
326
-
327
- for (const conversation of group.conversations) {
328
- next.push({
329
- conversation,
330
- key: conversation.id,
331
- type: 'conversation',
332
- });
333
- }
334
- }
335
-
336
- if (hasMoreConversations) next.push({ key: 'loader', type: 'loader' });
337
- return next;
338
- }, [archiveFilter, groups, hasMoreConversations, t]);
339
-
340
- const virtualizer = useVirtualizer({
341
- count: items.length,
342
- estimateSize: (index) => {
343
- const item = items[index];
344
- if (item?.type === 'conversation') return 36;
345
- if (item?.type === 'loader') return 44;
346
- return 30;
347
- },
348
- getItemKey: (index) => items[index]?.key ?? index,
349
- getScrollElement: () => parentRef.current,
350
- overscan: 8,
351
- });
352
- const virtualItems = virtualizer.getVirtualItems();
353
-
354
- function maybeLoadMore(event: UIEvent<HTMLDivElement>) {
355
- if (!(hasMoreConversations && onLoadMoreConversations)) return;
356
- if (isFetchingMoreConversations) return;
357
-
358
- const target = event.currentTarget;
359
- const distanceToEnd =
360
- target.scrollHeight - target.scrollTop - target.clientHeight;
361
- if (distanceToEnd < 180) {
362
- void onLoadMoreConversations();
363
- }
364
- }
365
-
366
- return (
367
- <div
368
- className="h-full overflow-y-auto overflow-x-hidden overscroll-contain p-2"
369
- onScroll={maybeLoadMore}
370
- ref={parentRef}
371
- >
372
- <div
373
- className="relative"
374
- style={{ height: `${virtualizer.getTotalSize()}px` }}
375
- >
376
- {virtualItems.map((virtualItem) => {
377
- const item = items[virtualItem.index];
378
- if (!item) return null;
379
-
380
- return (
381
- <div
382
- className="absolute inset-x-0 top-0"
383
- data-index={virtualItem.index}
384
- key={virtualItem.key}
385
- ref={virtualizer.measureElement}
386
- style={{ transform: `translateY(${virtualItem.start}px)` }}
387
- >
388
- {item.type === 'archive-label' ? (
389
- <p className="px-2 py-1 text-muted-foreground text-xs">
390
- {item.label}
391
- </p>
392
- ) : item.type === 'group-label' ? (
393
- <h3 className="flex items-center gap-1.5 px-2 py-1.5 font-medium text-muted-foreground text-xs uppercase">
394
- <ConversationSectionIcon type={item.sectionType} />
395
- {item.label}
396
- </h3>
397
- ) : item.type === 'loader' ? (
398
- <div className="flex items-center justify-center py-2 text-muted-foreground text-xs">
399
- <LoaderCircle className="mr-2 size-3.5 animate-spin" />
400
- {t('loading_conversations')}
401
- </div>
402
- ) : (
403
- <ConversationRow
404
- conversation={item.conversation}
405
- currentUserId={currentUserId}
406
- isSelected={item.conversation.id === selectedConversationId}
407
- onArchiveConversation={onArchiveConversation}
408
- onPinConversation={onPinConversation}
409
- onSelectConversation={onSelectConversation}
410
- />
411
- )}
412
- </div>
413
- );
414
- })}
415
- </div>
416
- </div>
417
- );
418
- }
419
-
420
- function ConversationSectionIcon({ type }: { type: ChatConversation['type'] }) {
421
- const className = 'size-3.5 shrink-0';
422
-
423
- if (type === 'channel') return <Hash className={className} />;
424
- if (type === 'ai') return <Bot className={className} />;
425
- if (type === 'group') return <Users className={className} />;
426
- return <MessageCircle className={className} />;
427
- }
428
-
429
182
  export function ChatConversationFilterMenu({
430
183
  archiveFilter,
431
184
  className,
@@ -48,6 +48,7 @@ import {
48
48
  filterChatConversations,
49
49
  getChatConversationTypesForScope,
50
50
  getChatSelectionStorageKey,
51
+ getChatSourceGroupStorageKey,
51
52
  getConversationTitle,
52
53
  isReadOnlyChatConversation,
53
54
  normalizeChatConversationScope,
@@ -123,6 +124,9 @@ export function ChatWorkspace({
123
124
  const selectionStorageKey = conversationScope
124
125
  ? getChatSelectionStorageKey(wsId, conversationScope)
125
126
  : null;
127
+ const sourceGroupStorageKey = conversationScope
128
+ ? getChatSourceGroupStorageKey(wsId, conversationScope)
129
+ : null;
126
130
  const requestedConversationPending = Boolean(
127
131
  requestedConversationId &&
128
132
  !conversationIds.has(requestedConversationId) &&
@@ -491,6 +495,7 @@ export function ChatWorkspace({
491
495
  searchResults={searchResults}
492
496
  searchValue={searchValue}
493
497
  selectedConversationId={activeConversationId}
498
+ sourceGroupStorageKey={sourceGroupStorageKey}
494
499
  scope={conversationScope ?? undefined}
495
500
  />
496
501
  ) : null}
@@ -244,6 +244,13 @@ export function getChatSelectionStorageKey(
244
244
  return `tuturuuu.chat.selectedConversation.${wsId}.${scope}`;
245
245
  }
246
246
 
247
+ export function getChatSourceGroupStorageKey(
248
+ wsId: string,
249
+ scope: ChatConversationScope
250
+ ) {
251
+ return `tuturuuu.chat.collapsedSourceGroups.${wsId}.${scope}`;
252
+ }
253
+
247
254
  export function resolveChatConversationSelection({
248
255
  conversationIds,
249
256
  requestedConversationId,
@@ -21,6 +21,12 @@ import { Switch } from '@tuturuuu/ui/switch';
21
21
  import { useTranslations } from 'next-intl';
22
22
  import { useEffect } from 'react';
23
23
  import { TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID } from '../../tu-do/shared/task-due-date-visibility';
24
+ import {
25
+ clampTaskSoundEffectsVolume,
26
+ DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
27
+ TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID,
28
+ TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
29
+ } from '../../tu-do/shared/task-sound-effects';
24
30
 
25
31
  interface TaskSettingsData {
26
32
  task_auto_assign_to_self: boolean;
@@ -74,6 +80,18 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
74
80
  isLoading: showReviewDueDatesLoading,
75
81
  isPending: showReviewDueDatesPending,
76
82
  } = useUserBooleanConfig(TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID, false);
83
+ const {
84
+ value: soundEffectsEnabled,
85
+ setValue: setSoundEffectsEnabled,
86
+ isLoading: soundEffectsEnabledLoading,
87
+ isPending: soundEffectsEnabledPending,
88
+ } = useUserBooleanConfig(TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID, true);
89
+ const { data: soundEffectsVolume, isLoading: soundEffectsVolumeLoading } =
90
+ useUserConfig(
91
+ TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
92
+ String(DEFAULT_TASK_SOUND_EFFECTS_VOLUME)
93
+ );
94
+ const updateSoundEffectsVolume = useUpdateUserConfig();
77
95
 
78
96
  const { data: submitShortcut, isLoading: submitShortcutLoading } =
79
97
  useUserConfig('TASK_SUBMIT_SHORTCUT', 'enter');
@@ -128,9 +146,19 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
128
146
  updateSettings.mutate({ fade_completed_tasks: checked });
129
147
  };
130
148
 
149
+ const handleSoundEffectsVolumeChange = (value: string) => {
150
+ updateSoundEffectsVolume.mutate({
151
+ configId: TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
152
+ value: String(clampTaskSoundEffectsVolume(value)),
153
+ });
154
+ };
155
+
131
156
  const effectiveAutoAssignValue = isPersonalWorkspace
132
157
  ? true
133
158
  : (settings?.task_auto_assign_to_self ?? false);
159
+ const normalizedSoundEffectsVolume = String(
160
+ clampTaskSoundEffectsVolume(soundEffectsVolume)
161
+ );
134
162
 
135
163
  return (
136
164
  <div className="space-y-8">
@@ -163,6 +191,54 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
163
191
  />
164
192
  </SettingItemTab>
165
193
  <Separator />
194
+ <SettingItemTab
195
+ title={t('sound_effects')}
196
+ description={t('sound_effects_description')}
197
+ >
198
+ <Switch
199
+ aria-label={t('sound_effects')}
200
+ checked={soundEffectsEnabled}
201
+ onCheckedChange={setSoundEffectsEnabled}
202
+ disabled={soundEffectsEnabledLoading || soundEffectsEnabledPending}
203
+ />
204
+ </SettingItemTab>
205
+ <Separator />
206
+ <SettingItemTab
207
+ title={t('sound_effects_volume')}
208
+ description={t('sound_effects_volume_description')}
209
+ >
210
+ <Select
211
+ value={normalizedSoundEffectsVolume}
212
+ onValueChange={handleSoundEffectsVolumeChange}
213
+ disabled={
214
+ soundEffectsVolumeLoading ||
215
+ updateSoundEffectsVolume.isPending ||
216
+ !soundEffectsEnabled
217
+ }
218
+ >
219
+ <SelectTrigger
220
+ aria-label={t('sound_effects_volume')}
221
+ className="w-36"
222
+ >
223
+ <SelectValue />
224
+ </SelectTrigger>
225
+ <SelectContent>
226
+ <SelectItem value="15">
227
+ {t('sound_effects_volume_soft')}
228
+ </SelectItem>
229
+ <SelectItem value="35">
230
+ {t('sound_effects_volume_balanced')}
231
+ </SelectItem>
232
+ <SelectItem value="60">
233
+ {t('sound_effects_volume_lively')}
234
+ </SelectItem>
235
+ <SelectItem value="85">
236
+ {t('sound_effects_volume_bold')}
237
+ </SelectItem>
238
+ </SelectContent>
239
+ </Select>
240
+ </SettingItemTab>
241
+ <Separator />
166
242
  <SettingItemTab
167
243
  title={t('draft_mode')}
168
244
  description={t('draft_mode_description')}