@tuturuuu/ui 0.1.0 → 0.3.1

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 (128) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +82 -70
  3. package/src/components/ui/__tests__/avatar.test.tsx +8 -5
  4. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +414 -0
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager.tsx +5 -1
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +529 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +26 -1429
  8. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +711 -0
  9. package/src/components/ui/chart.test.tsx +29 -0
  10. package/src/components/ui/chart.tsx +12 -3
  11. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +43 -13
  12. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +138 -74
  13. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +70 -0
  14. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +60 -1
  15. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +13 -5
  16. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +110 -0
  17. package/src/components/ui/chat/chat-sidebar-panel.tsx +13 -3
  18. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +24 -1
  19. package/src/components/ui/custom/__tests__/tuturuuu-logo.test.ts +12 -3
  20. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +39 -0
  21. package/src/components/ui/custom/common-footer.tsx +16 -1
  22. package/src/components/ui/custom/production-indicator.tsx +1 -1
  23. package/src/components/ui/custom/settings/sidebar-settings.tsx +1 -1
  24. package/src/components/ui/custom/settings/task-settings.tsx +18 -0
  25. package/src/components/ui/custom/settings-dialog-shell.tsx +38 -23
  26. package/src/components/ui/custom/sidebar-context-compile-graph.test.ts +60 -0
  27. package/src/components/ui/custom/sidebar-context.tsx +61 -61
  28. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +123 -0
  29. package/src/components/ui/custom/tuturuuu-logo-urls.ts +6 -0
  30. package/src/components/ui/custom/tuturuuu-logo.tsx +25 -7
  31. package/src/components/ui/custom/workspace-select-helpers.ts +20 -0
  32. package/src/components/ui/custom/workspace-select.tsx +33 -12
  33. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +7 -1
  34. package/src/components/ui/finance/invoices/components/invoice-payment-settings.tsx +3 -0
  35. package/src/components/ui/finance/invoices/components/invoice-products-permission-warning.tsx +58 -0
  36. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +12 -20
  37. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +10 -9
  38. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +10 -5
  39. package/src/components/ui/finance/invoices/hooks.ts +75 -20
  40. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +137 -0
  41. package/src/components/ui/finance/invoices/new-invoice-page.tsx +86 -37
  42. package/src/components/ui/finance/invoices/product-selection.test.tsx +8 -26
  43. package/src/components/ui/finance/invoices/product-selection.tsx +2 -10
  44. package/src/components/ui/finance/invoices/standard-invoice.tsx +88 -26
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +154 -46
  46. package/src/components/ui/finance/invoices/utils.test.ts +50 -0
  47. package/src/components/ui/finance/invoices/utils.ts +75 -17
  48. package/src/components/ui/finance/shared/finance-display-amount.tsx +3 -1
  49. package/src/components/ui/finance/shared/finance-permission-warning-dialog.test.tsx +34 -0
  50. package/src/components/ui/finance/shared/finance-permission-warning-dialog.tsx +157 -0
  51. package/src/components/ui/finance/transactions/form-basic-tab.tsx +8 -0
  52. package/src/components/ui/finance/transactions/form-more-tab.tsx +8 -0
  53. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  54. package/src/components/ui/finance/transactions/form.test.tsx +43 -0
  55. package/src/components/ui/finance/transactions/form.tsx +60 -0
  56. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +27 -0
  57. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +13 -1
  58. package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +4 -0
  59. package/src/components/ui/finance/transactions/transactions-page.tsx +23 -1
  60. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  61. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +5 -0
  62. package/src/components/ui/legacy/calendar/calendar-content.tsx +9 -1
  63. package/src/components/ui/legacy/calendar/event-modal.tsx +146 -2
  64. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +200 -0
  65. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +76 -0
  66. package/src/components/ui/legacy/calendar/smart-calendar.tsx +13 -1
  67. package/src/components/ui/legacy/meet/page.test.ts +180 -0
  68. package/src/components/ui/legacy/meet/page.tsx +87 -39
  69. package/src/components/ui/legacy/meet/planId/page.tsx +10 -4
  70. package/src/components/ui/text-editor/__tests__/task-mention-chip.test.tsx +203 -6
  71. package/src/components/ui/text-editor/task-mention-chip.tsx +29 -7
  72. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +79 -25
  73. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-external-workspaces.test.tsx +392 -0
  74. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.test.tsx +57 -0
  75. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-island.tsx +106 -0
  76. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +106 -161
  77. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-assignees.ts +96 -150
  78. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-labels.ts +63 -79
  79. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-relations-projects.ts +64 -83
  80. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +115 -155
  81. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-utils.ts +319 -2
  82. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +8 -1
  83. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +63 -37
  84. package/src/components/ui/tu-do/boards/boardId/kanban/kanban-column-collapse.ts +16 -0
  85. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +46 -0
  86. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +5 -3
  87. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +19 -7
  88. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +181 -2
  89. package/src/components/ui/tu-do/boards/boardId/menus/index.ts +1 -0
  90. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +463 -0
  91. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-utils.ts +109 -0
  92. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +4 -0
  93. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardCheckbox.tsx +6 -3
  94. package/src/components/ui/tu-do/boards/boardId/task-card/TaskCardDates.tsx +26 -9
  95. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-checkbox-style.ts +39 -0
  96. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.test.ts +43 -0
  97. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +33 -0
  98. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.test.ts +31 -0
  99. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-completion-checkbox-visibility.ts +9 -0
  100. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.test.tsx +124 -0
  101. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-identifier-row.tsx +88 -0
  102. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +151 -76
  103. package/src/components/ui/tu-do/boards/boardId/task-card/task-scheduling-badge.tsx +174 -0
  104. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +34 -13
  105. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +54 -1
  106. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +158 -0
  107. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +5 -2
  108. package/src/components/ui/tu-do/shared/board-client.tsx +12 -2
  109. package/src/components/ui/tu-do/shared/board-views.tsx +195 -328
  110. package/src/components/ui/tu-do/shared/list-view.tsx +18 -8
  111. package/src/components/ui/tu-do/shared/task-due-date-visibility.test.ts +72 -0
  112. package/src/components/ui/tu-do/shared/task-due-date-visibility.ts +38 -0
  113. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-realtime-sync.test.tsx +37 -9
  114. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +6 -3
  115. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +89 -70
  116. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +2 -2
  117. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +33 -0
  118. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +74 -3
  119. package/src/hooks/__tests__/use-task-actions.test.tsx +118 -0
  120. package/src/hooks/__tests__/use-user-config.test.tsx +65 -0
  121. package/src/hooks/__tests__/use-workspace-presence.test.tsx +1 -1
  122. package/src/hooks/use-calendar-sync.tsx +22 -277
  123. package/src/hooks/use-calendar.tsx +95 -525
  124. package/src/hooks/use-task-actions.ts +43 -117
  125. package/src/hooks/use-user-config.ts +1 -1
  126. package/src/hooks/use-workspace-config.ts +6 -2
  127. package/src/hooks/use-workspace-presence.ts +1 -1
  128. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-actions-bar.tsx +0 -94
@@ -0,0 +1,29 @@
1
+ import { renderToString } from 'react-dom/server';
2
+ import { Bar, BarChart } from 'recharts';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+ import { ChartContainer } from './chart';
5
+
6
+ describe('ChartContainer', () => {
7
+ afterEach(() => {
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ it('does not emit Recharts dimension warnings during server rendering', () => {
12
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
13
+
14
+ renderToString(
15
+ <ChartContainer
16
+ className="h-64 w-full"
17
+ config={{ clicks: { color: 'var(--primary)', label: 'Clicks' } }}
18
+ >
19
+ <BarChart data={[{ clicks: 1, day: 'Mon' }]}>
20
+ <Bar dataKey="clicks" />
21
+ </BarChart>
22
+ </ChartContainer>
23
+ );
24
+
25
+ expect(warn).not.toHaveBeenCalledWith(
26
+ expect.stringContaining('The width(-1) and height(-1) of chart')
27
+ );
28
+ });
29
+ });
@@ -73,6 +73,11 @@ const ChartContainer = React.forwardRef<
73
73
  >(({ id, className, children, config, ...props }, ref) => {
74
74
  const uniqueId = React.useId();
75
75
  const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
76
+ const [mounted, setMounted] = React.useState(false);
77
+
78
+ React.useEffect(() => {
79
+ setMounted(true);
80
+ }, []);
76
81
 
77
82
  return (
78
83
  <ChartContext.Provider value={{ config }}>
@@ -87,9 +92,13 @@ const ChartContainer = React.forwardRef<
87
92
  {...props}
88
93
  >
89
94
  <ChartStyle id={chartId} config={config} />
90
- <RechartsPrimitive.ResponsiveContainer>
91
- {children}
92
- </RechartsPrimitive.ResponsiveContainer>
95
+ {mounted ? (
96
+ <RechartsPrimitive.ResponsiveContainer>
97
+ {children}
98
+ </RechartsPrimitive.ResponsiveContainer>
99
+ ) : (
100
+ <div aria-hidden className="h-full w-full" />
101
+ )}
93
102
  </div>
94
103
  </ChartContext.Provider>
95
104
  );
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
2
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { AgentExternalThreadPanel } from './chat-agent-details-external-thread-panel';
5
+ import type { AgentConversationMetadata } from './chat-agent-details-utils';
5
6
 
6
7
  const mocks = vi.hoisted(() => ({
7
8
  draftAiAgentExternalResponse: vi.fn(),
@@ -35,7 +36,18 @@ vi.mock('../sonner', () => ({
35
36
  },
36
37
  }));
37
38
 
38
- function renderPanel(onRefresh = vi.fn()) {
39
+ function renderPanel(
40
+ onRefresh = vi.fn(),
41
+ metadata: AgentConversationMetadata = {
42
+ agentId: 'agent-1',
43
+ channelId: 'channel-1',
44
+ externalChannelId: 'external-channel-1',
45
+ externalThreadId: 'discord-thread-1',
46
+ externalThreadUuid: 'thread-uuid-1',
47
+ messageCount: 2,
48
+ source: 'ai-agent-external-thread',
49
+ }
50
+ ) {
39
51
  const queryClient = new QueryClient({
40
52
  defaultOptions: {
41
53
  mutations: { retry: false },
@@ -45,18 +57,7 @@ function renderPanel(onRefresh = vi.fn()) {
45
57
 
46
58
  render(
47
59
  <QueryClientProvider client={queryClient}>
48
- <AgentExternalThreadPanel
49
- metadata={{
50
- agentId: 'agent-1',
51
- channelId: 'channel-1',
52
- externalChannelId: 'external-channel-1',
53
- externalThreadId: 'discord-thread-1',
54
- externalThreadUuid: 'thread-uuid-1',
55
- messageCount: 2,
56
- source: 'ai-agent-external-thread',
57
- }}
58
- onRefresh={onRefresh}
59
- />
60
+ <AgentExternalThreadPanel metadata={metadata} onRefresh={onRefresh} />
60
61
  </QueryClientProvider>
61
62
  );
62
63
 
@@ -72,8 +73,10 @@ describe('AgentExternalThreadPanel', () => {
72
73
  mocks.listAiAgentExternalThreads.mockResolvedValue({
73
74
  threads: [
74
75
  {
76
+ externalThreadId: 'discord-thread-1',
75
77
  id: 'thread-uuid-1',
76
78
  lastSyncedAt: '2026-06-02T01:02:03.000Z',
79
+ messageCount: 2,
77
80
  },
78
81
  ],
79
82
  });
@@ -100,6 +103,33 @@ describe('AgentExternalThreadPanel', () => {
100
103
  expect(onRefresh).toHaveBeenCalled();
101
104
  });
102
105
 
106
+ it('lists and syncs recent threads from an agent setup conversation', async () => {
107
+ const { onRefresh } = renderPanel(vi.fn(), {
108
+ agentId: 'agent-1',
109
+ channelId: 'channel-1',
110
+ source: 'ai-agent' as const,
111
+ });
112
+
113
+ await waitFor(() => {
114
+ expect(mocks.listAiAgentExternalThreads).toHaveBeenCalledWith({
115
+ agentId: 'agent-1',
116
+ channelId: 'channel-1',
117
+ });
118
+ });
119
+ await waitFor(() => {
120
+ expect(screen.getByText('discord-thread-1')).toBeInTheDocument();
121
+ });
122
+
123
+ fireEvent.click(screen.getByText('agent_external_sync'));
124
+
125
+ await waitFor(() => {
126
+ expect(mocks.syncAiAgentExternalThread).toHaveBeenCalledWith(
127
+ 'thread-uuid-1'
128
+ );
129
+ });
130
+ expect(onRefresh).toHaveBeenCalled();
131
+ });
132
+
103
133
  it('drafts and sends an external response', async () => {
104
134
  const { onRefresh } = renderPanel();
105
135
 
@@ -36,29 +36,34 @@ export function AgentExternalThreadPanel({
36
36
  const [draft, setDraft] = useState('');
37
37
  const [prompt, setPrompt] = useState('');
38
38
  const threadId = metadata.externalThreadUuid;
39
- const threadQuery = useQuery({
40
- enabled: Boolean(threadId),
39
+ const threadsQuery = useQuery({
40
+ enabled: Boolean(metadata.agentId && metadata.channelId),
41
41
  queryFn: async () => {
42
42
  const result = await listAiAgentExternalThreads({
43
43
  agentId: metadata.agentId,
44
44
  channelId: metadata.channelId,
45
45
  });
46
46
 
47
- return result.threads.find((thread) => thread.id === threadId) ?? null;
47
+ return result.threads;
48
48
  },
49
49
  queryKey: [
50
50
  'chat',
51
- 'ai-agent-external-thread',
51
+ 'ai-agent-external-threads',
52
52
  metadata.agentId,
53
53
  metadata.channelId,
54
- threadId,
55
54
  ],
56
55
  staleTime: 30_000,
57
56
  });
57
+ const threads = threadsQuery.data ?? [];
58
+ const selectedThread = threadId
59
+ ? threads.find((thread) => thread.id === threadId)
60
+ : null;
58
61
  const syncMutation = useMutation({
59
- mutationFn: () => {
60
- if (!threadId) throw new Error(t('agent_external_thread_missing'));
61
- return syncAiAgentExternalThread(threadId);
62
+ mutationFn: (targetThreadId?: string) => {
63
+ if (!targetThreadId) {
64
+ throw new Error(t('agent_external_thread_missing'));
65
+ }
66
+ return syncAiAgentExternalThread(targetThreadId);
62
67
  },
63
68
  onError: (error) =>
64
69
  toast.error(error.message || t('agent_external_sync_failed')),
@@ -73,7 +78,7 @@ export function AgentExternalThreadPanel({
73
78
  ? t('agent_external_sync_no_new')
74
79
  : t('agent_external_sync_success', { count: result.synced })
75
80
  );
76
- void threadQuery.refetch();
81
+ void threadsQuery.refetch();
77
82
  onRefresh();
78
83
  },
79
84
  });
@@ -128,77 +133,136 @@ export function AgentExternalThreadPanel({
128
133
  />
129
134
  <KeyValue
130
135
  label={t('agent_external_last_sync')}
131
- value={threadQuery.data?.lastSyncedAt ?? t('unknown')}
136
+ value={selectedThread?.lastSyncedAt ?? t('unknown')}
132
137
  />
133
138
  </div>
134
139
  </PanelSection>
135
140
 
136
- <Button
137
- className="w-full"
138
- disabled={!threadId || syncMutation.isPending}
139
- onClick={() => syncMutation.mutate()}
140
- type="button"
141
- variant="outline"
142
- >
143
- {syncMutation.isPending ? (
144
- <LoaderCircle className="size-4 animate-spin" />
145
- ) : (
146
- <RefreshCw className="size-4" />
147
- )}
148
- {t('agent_external_sync')}
149
- </Button>
141
+ {threadId ? (
142
+ <Button
143
+ className="w-full"
144
+ disabled={syncMutation.isPending}
145
+ onClick={() => syncMutation.mutate(threadId)}
146
+ type="button"
147
+ variant="outline"
148
+ >
149
+ {syncMutation.isPending ? (
150
+ <LoaderCircle className="size-4 animate-spin" />
151
+ ) : (
152
+ <RefreshCw className="size-4" />
153
+ )}
154
+ {t('agent_external_sync')}
155
+ </Button>
156
+ ) : (
157
+ <PanelSection
158
+ icon={<RefreshCw className="size-4" />}
159
+ title={t('agent_external_recent_threads')}
160
+ >
161
+ <div className="space-y-2">
162
+ {threadsQuery.isLoading ? (
163
+ <p className="flex items-center gap-2 text-muted-foreground text-xs">
164
+ <LoaderCircle className="size-3.5 animate-spin" />
165
+ {t('loading_ai_settings')}
166
+ </p>
167
+ ) : threads.length > 0 ? (
168
+ threads.map((thread) => (
169
+ <div
170
+ className="space-y-2 rounded-md border bg-muted/20 p-2"
171
+ key={thread.id}
172
+ >
173
+ <div className="min-w-0 text-xs">
174
+ <div className="truncate font-medium">
175
+ {thread.title || thread.externalThreadId}
176
+ </div>
177
+ {thread.title ? (
178
+ <div className="truncate text-muted-foreground">
179
+ {thread.externalThreadId}
180
+ </div>
181
+ ) : null}
182
+ </div>
183
+ <div className="flex items-center justify-between gap-2 text-muted-foreground text-xs">
184
+ <span>
185
+ {t('agent_external_message_count')}: {thread.messageCount}
186
+ </span>
187
+ <Button
188
+ disabled={syncMutation.isPending}
189
+ onClick={() => syncMutation.mutate(thread.id)}
190
+ size="sm"
191
+ type="button"
192
+ variant="outline"
193
+ >
194
+ {syncMutation.isPending ? (
195
+ <LoaderCircle className="size-3.5 animate-spin" />
196
+ ) : (
197
+ <RefreshCw className="size-3.5" />
198
+ )}
199
+ {t('agent_external_sync')}
200
+ </Button>
201
+ </div>
202
+ </div>
203
+ ))
204
+ ) : (
205
+ <p className="text-muted-foreground text-xs">
206
+ {t('agent_external_no_threads')}
207
+ </p>
208
+ )}
209
+ </div>
210
+ </PanelSection>
211
+ )}
150
212
 
151
- <PanelSection
152
- icon={<Sparkles className="size-4" />}
153
- title={t('agent_external_response')}
154
- >
155
- <div className="space-y-2">
156
- <Textarea
157
- onChange={(event) => setPrompt(event.target.value)}
158
- placeholder={t('agent_external_prompt_placeholder')}
159
- rows={3}
160
- value={prompt}
161
- />
162
- <Textarea
163
- onChange={(event) => setDraft(event.target.value)}
164
- placeholder={t('agent_external_draft_placeholder')}
165
- rows={5}
166
- value={draft}
167
- />
168
- <div className="grid grid-cols-2 gap-2">
169
- <Button
170
- disabled={!threadId || draftMutation.isPending}
171
- onClick={() => draftMutation.mutate()}
172
- type="button"
173
- variant="secondary"
174
- >
175
- {draftMutation.isPending ? (
176
- <LoaderCircle className="size-4 animate-spin" />
177
- ) : (
178
- <Sparkles className="size-4" />
179
- )}
180
- {t('agent_external_draft')}
181
- </Button>
182
- <Button
183
- disabled={!threadId || !draft.trim() || sendMutation.isPending}
184
- onClick={() => sendMutation.mutate()}
185
- type="button"
186
- >
187
- {sendMutation.isPending ? (
188
- <LoaderCircle className="size-4 animate-spin" />
189
- ) : (
190
- <Send className="size-4" />
191
- )}
192
- {t('agent_external_send')}
193
- </Button>
213
+ {!threadId ? null : (
214
+ <PanelSection
215
+ icon={<Sparkles className="size-4" />}
216
+ title={t('agent_external_response')}
217
+ >
218
+ <div className="space-y-2">
219
+ <Textarea
220
+ onChange={(event) => setPrompt(event.target.value)}
221
+ placeholder={t('agent_external_prompt_placeholder')}
222
+ rows={3}
223
+ value={prompt}
224
+ />
225
+ <Textarea
226
+ onChange={(event) => setDraft(event.target.value)}
227
+ placeholder={t('agent_external_draft_placeholder')}
228
+ rows={5}
229
+ value={draft}
230
+ />
231
+ <div className="grid grid-cols-2 gap-2">
232
+ <Button
233
+ disabled={!threadId || draftMutation.isPending}
234
+ onClick={() => draftMutation.mutate()}
235
+ type="button"
236
+ variant="secondary"
237
+ >
238
+ {draftMutation.isPending ? (
239
+ <LoaderCircle className="size-4 animate-spin" />
240
+ ) : (
241
+ <Sparkles className="size-4" />
242
+ )}
243
+ {t('agent_external_draft')}
244
+ </Button>
245
+ <Button
246
+ disabled={!threadId || !draft.trim() || sendMutation.isPending}
247
+ onClick={() => sendMutation.mutate()}
248
+ type="button"
249
+ >
250
+ {sendMutation.isPending ? (
251
+ <LoaderCircle className="size-4 animate-spin" />
252
+ ) : (
253
+ <Send className="size-4" />
254
+ )}
255
+ {t('agent_external_send')}
256
+ </Button>
257
+ </div>
258
+ {isPending ? (
259
+ <p className="text-muted-foreground text-xs">
260
+ {t('agent_external_refresh_after_action')}
261
+ </p>
262
+ ) : null}
194
263
  </div>
195
- {isPending ? (
196
- <p className="text-muted-foreground text-xs">
197
- {t('agent_external_refresh_after_action')}
198
- </p>
199
- ) : null}
200
- </div>
201
- </PanelSection>
264
+ </PanelSection>
265
+ )}
202
266
  </div>
203
267
  );
204
268
  }
@@ -0,0 +1,70 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { AgentOperationsPanel } from './chat-agent-details-operations-panel';
4
+
5
+ vi.mock('next-intl', () => ({
6
+ useTranslations: () => (key: string) => key,
7
+ }));
8
+
9
+ const channel = {
10
+ adapter: 'discord' as const,
11
+ autoRespond: true,
12
+ discordGuildId: 'guild-1',
13
+ displayName: 'Discord',
14
+ enabled: true,
15
+ externalChannelId: 'channel-1',
16
+ historySyncEnabled: true,
17
+ id: 'channel-1',
18
+ lastDeployedAt: '2026-06-02T00:00:00.000Z',
19
+ lastError: null,
20
+ lastEventAt: '2026-06-02T00:01:00.000Z',
21
+ mentionRoleIds: [],
22
+ secrets: [],
23
+ status: 'deployed' as const,
24
+ webhookUrl: 'https://example.com/webhook',
25
+ workspaceId: 'workspace-1',
26
+ };
27
+
28
+ describe('AgentOperationsPanel', () => {
29
+ it('renders structured diagnostics after a channel test', () => {
30
+ render(
31
+ <AgentOperationsPanel
32
+ channel={channel}
33
+ isPending={false}
34
+ onCopySecret={vi.fn()}
35
+ onDeploy={vi.fn()}
36
+ onPause={vi.fn()}
37
+ onRotateSecret={vi.fn()}
38
+ onTest={vi.fn()}
39
+ secretPreview={null}
40
+ testResult={{
41
+ checks: [
42
+ {
43
+ detail: 'All required channel secrets are configured.',
44
+ id: 'required_secrets',
45
+ label: 'Required secrets',
46
+ ok: true,
47
+ },
48
+ {
49
+ detail: 'Set the Discord guild ID.',
50
+ id: 'adapter_account',
51
+ label: 'Discord guild mapping',
52
+ ok: false,
53
+ },
54
+ ],
55
+ ok: false,
56
+ response: 'AI agent channel needs attention.',
57
+ }}
58
+ />
59
+ );
60
+
61
+ expect(screen.getByText('agent_diagnostics')).toBeInTheDocument();
62
+ expect(
63
+ screen.getByText('agent_diagnostic_required_secrets')
64
+ ).toBeInTheDocument();
65
+ expect(
66
+ screen.getByText('agent_diagnostic_adapter_account')
67
+ ).toBeInTheDocument();
68
+ expect(screen.getByText('Set the Discord guild ID.')).toBeInTheDocument();
69
+ });
70
+ });
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import {
4
+ Check,
4
5
  Copy,
5
6
  FlaskConical,
6
7
  LoaderCircle,
@@ -8,8 +9,12 @@ import {
8
9
  Play,
9
10
  RotateCw,
10
11
  Webhook,
12
+ X,
11
13
  } from '@tuturuuu/icons';
12
- import type { AiAgentChannelConfig } from '@tuturuuu/internal-api/infrastructure';
14
+ import type {
15
+ AiAgentChannelConfig,
16
+ AiAgentTestResponse,
17
+ } from '@tuturuuu/internal-api/infrastructure';
13
18
  import { useTranslations } from 'next-intl';
14
19
  import { useState } from 'react';
15
20
  import { Button } from '../button';
@@ -20,6 +25,18 @@ import {
20
25
  PanelSection,
21
26
  } from './chat-agent-details-utils';
22
27
 
28
+ const DIAGNOSTIC_LABEL_KEYS = {
29
+ adapter_account: 'agent_diagnostic_adapter_account',
30
+ agent_enabled: 'agent_diagnostic_agent_enabled',
31
+ channel_deployed: 'agent_diagnostic_channel_deployed',
32
+ channel_enabled: 'agent_diagnostic_channel_enabled',
33
+ last_error: 'agent_diagnostic_last_error',
34
+ last_event: 'agent_diagnostic_last_event',
35
+ required_secrets: 'agent_diagnostic_required_secrets',
36
+ webhook_url: 'agent_diagnostic_webhook_url',
37
+ workspace_mapping: 'agent_diagnostic_workspace_mapping',
38
+ } as const;
39
+
23
40
  export function AgentOperationsPanel({
24
41
  channel,
25
42
  isPending,
@@ -29,6 +46,7 @@ export function AgentOperationsPanel({
29
46
  onRotateSecret,
30
47
  onTest,
31
48
  secretPreview,
49
+ testResult,
32
50
  }: {
33
51
  channel: AiAgentChannelConfig;
34
52
  isPending: boolean;
@@ -38,10 +56,19 @@ export function AgentOperationsPanel({
38
56
  onRotateSecret: () => void;
39
57
  onTest: (prompt?: string) => void;
40
58
  secretPreview: { label: string; value: string } | null;
59
+ testResult?: AiAgentTestResponse | null;
41
60
  }) {
42
61
  const t = useTranslations('chat');
43
62
  const [testPrompt, setTestPrompt] = useState('');
44
63
  const webhookUrl = channel.webhookUrl;
64
+ const diagnosticLabel = (
65
+ check: NonNullable<AiAgentTestResponse['checks']>[number]
66
+ ) => {
67
+ const key =
68
+ DIAGNOSTIC_LABEL_KEYS[check.id as keyof typeof DIAGNOSTIC_LABEL_KEYS];
69
+
70
+ return key ? t(key) : check.label;
71
+ };
45
72
 
46
73
  return (
47
74
  <div className="space-y-4">
@@ -135,6 +162,38 @@ export function AgentOperationsPanel({
135
162
  </div>
136
163
  </PanelSection>
137
164
 
165
+ {testResult?.checks?.length ? (
166
+ <PanelSection
167
+ icon={<FlaskConical className="size-4" />}
168
+ title={t('agent_diagnostics')}
169
+ >
170
+ <ul className="space-y-2">
171
+ {testResult.checks.map((check) => (
172
+ <li
173
+ className="rounded-md border bg-muted/20 p-2 text-xs"
174
+ key={check.id}
175
+ >
176
+ <div className="flex min-w-0 items-center gap-2">
177
+ {check.ok ? (
178
+ <Check className="size-3.5 shrink-0 text-dynamic-green" />
179
+ ) : (
180
+ <X className="size-3.5 shrink-0 text-dynamic-red" />
181
+ )}
182
+ <span className="min-w-0 flex-1 truncate font-medium">
183
+ {diagnosticLabel(check)}
184
+ </span>
185
+ </div>
186
+ {check.detail ? (
187
+ <p className="mt-1 break-words text-muted-foreground">
188
+ {check.detail}
189
+ </p>
190
+ ) : null}
191
+ </li>
192
+ ))}
193
+ </ul>
194
+ </PanelSection>
195
+ ) : null}
196
+
138
197
  {channel.adapter === 'zalo' ? (
139
198
  <PanelSection
140
199
  icon={<RotateCw className="size-4" />}
@@ -3,7 +3,10 @@
3
3
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
4
  import { Bot, LoaderCircle } from '@tuturuuu/icons';
5
5
  import type { ChatConversation } from '@tuturuuu/internal-api';
6
- import type { SaveAiAgentPayload } from '@tuturuuu/internal-api/infrastructure';
6
+ import type {
7
+ AiAgentTestResponse,
8
+ SaveAiAgentPayload,
9
+ } from '@tuturuuu/internal-api/infrastructure';
7
10
  import {
8
11
  deployAiAgentChannel,
9
12
  listAiAgents,
@@ -45,6 +48,9 @@ export function ChatAgentDetailsSidebar({
45
48
  label: string;
46
49
  value: string;
47
50
  } | null>(null);
51
+ const [testResult, setTestResult] = useState<AiAgentTestResponse | null>(
52
+ null
53
+ );
48
54
  const agentsQuery = useQuery({
49
55
  enabled: open && Boolean(metadata),
50
56
  queryFn: () => listAiAgents(),
@@ -65,10 +71,7 @@ export function ChatAgentDetailsSidebar({
65
71
  : undefined,
66
72
  [agent, metadata]
67
73
  );
68
- const tabs: AgentTab[] =
69
- metadata?.source === 'ai-agent-external-thread'
70
- ? ['setup', 'operations', 'thread']
71
- : ['setup', 'operations'];
74
+ const tabs: AgentTab[] = ['setup', 'operations', 'thread'];
72
75
  const refreshAgent = () =>
73
76
  queryClient.invalidateQueries({ queryKey: AGENT_QUERY_KEY });
74
77
  const refreshChat = () => {
@@ -122,6 +125,7 @@ export function ChatAgentDetailsSidebar({
122
125
  : Promise.reject(new Error(t('agent_not_found'))),
123
126
  onError: (error) => toast.error(error.message || t('agent_test_failed')),
124
127
  onSuccess: (result) => {
128
+ setTestResult(result);
125
129
  if (result.ok) toast.success(result.response || t('agent_test_success'));
126
130
  else toast.error(result.response || t('agent_test_failed'));
127
131
  },
@@ -204,6 +208,7 @@ export function ChatAgentDetailsSidebar({
204
208
  onTest: (prompt) => testMutation.mutate(prompt),
205
209
  secretPreview,
206
210
  tabs,
211
+ testResult,
207
212
  t,
208
213
  agent,
209
214
  })}
@@ -229,6 +234,7 @@ function renderContent({
229
234
  onTest,
230
235
  secretPreview,
231
236
  tabs,
237
+ testResult,
232
238
  t,
233
239
  }: {
234
240
  agent?: Awaited<ReturnType<typeof listAiAgents>>['agents'][number];
@@ -248,6 +254,7 @@ function renderContent({
248
254
  onTest: (prompt?: string) => void;
249
255
  secretPreview: { label: string; value: string } | null;
250
256
  tabs: AgentTab[];
257
+ testResult: AiAgentTestResponse | null;
251
258
  t: ReturnType<typeof useTranslations>;
252
259
  }) {
253
260
  if (!metadata) {
@@ -296,6 +303,7 @@ function renderContent({
296
303
  onRotateSecret={onRotateSecret}
297
304
  onTest={onTest}
298
305
  secretPreview={secretPreview}
306
+ testResult={testResult}
299
307
  />
300
308
  </TabsContent>
301
309
  {tabs.includes('thread') ? (