@nextclaw/ui 0.5.21 → 0.5.23

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 (43) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/assets/ChannelsList-AKnD2r1L.js +1 -0
  3. package/dist/assets/ChatPage-DidO_pAN.js +32 -0
  4. package/dist/assets/{CronConfig-9dYfTRJl.js → CronConfig-C1pm-oKA.js} +1 -1
  5. package/dist/assets/{DocBrowser-BIV0vpA0.js → DocBrowser-BY90Lf6L.js} +1 -1
  6. package/dist/assets/{MarketplacePage-2Zi0JSVi.js → MarketplacePage-BRtmhP3G.js} +1 -1
  7. package/dist/assets/{ModelConfig-h21P5rV0.js → ModelConfig-Dga1Ko7_.js} +1 -1
  8. package/dist/assets/{ProvidersList-DEaK1a3y.js → ProvidersList-DvCoBTrT.js} +1 -1
  9. package/dist/assets/{RuntimeConfig-DXMzf-gF.js → RuntimeConfig-2aqBJ6Xn.js} +1 -1
  10. package/dist/assets/SecretsConfig-wlnh__z0.js +3 -0
  11. package/dist/assets/{SessionsConfig-SdXvn_9E.js → SessionsConfig-CN2WymbH.js} +2 -2
  12. package/dist/assets/{action-link-C9xMkxl2.js → action-link-DLZDwUfD.js} +1 -1
  13. package/dist/assets/{card-Cnqfntk5.js → card-D3dD-I5t.js} +1 -1
  14. package/dist/assets/chat-message-DZV2Z5oc.js +5 -0
  15. package/dist/assets/{dialog-DJs630RE.js → dialog-DZ0VC-RD.js} +1 -1
  16. package/dist/assets/index-BsDasSXm.css +1 -0
  17. package/dist/assets/index-D_vv0E-O.js +2 -0
  18. package/dist/assets/{label-CXGuE6Oa.js → label-CJIvvG6o.js} +1 -1
  19. package/dist/assets/{page-layout-BVZlyPFt.js → page-layout-CLgr0qym.js} +1 -1
  20. package/dist/assets/{switch-BLF45eI3.js → switch-C-0Q8OH2.js} +1 -1
  21. package/dist/assets/{tabs-custom-DQ0GpEV5.js → tabs-custom-D4Gs3BGM.js} +1 -1
  22. package/dist/assets/useConfig-R5uGhZtD.js +1 -0
  23. package/dist/assets/{useConfirmDialog-CK7KAyDf.js → useConfirmDialog-AMeSTA83.js} +1 -1
  24. package/dist/assets/{vendor-RXIbhDBC.js → vendor-H2M3a_4Z.js} +1 -1
  25. package/dist/index.html +3 -3
  26. package/package.json +1 -1
  27. package/src/App.tsx +2 -0
  28. package/src/api/config.ts +16 -0
  29. package/src/api/types.ts +52 -0
  30. package/src/components/chat/ChatPage.tsx +67 -9
  31. package/src/components/chat/ChatThread.tsx +12 -3
  32. package/src/components/config/ChannelForm.tsx +9 -0
  33. package/src/components/config/SecretsConfig.tsx +469 -0
  34. package/src/components/layout/Sidebar.tsx +6 -1
  35. package/src/hooks/useConfig.ts +17 -0
  36. package/src/lib/chat-message.ts +80 -2
  37. package/src/lib/i18n.ts +42 -0
  38. package/dist/assets/ChannelsList-TFFw4Cem.js +0 -1
  39. package/dist/assets/ChatPage-BUm3UPap.js +0 -32
  40. package/dist/assets/chat-message-B7oqvJ2d.js +0 -3
  41. package/dist/assets/index-CrUDzcei.js +0 -2
  42. package/dist/assets/index-Zy7fAOe1.css +0 -1
  43. package/dist/assets/useConfig-vFQvF4kn.js +0 -1
@@ -0,0 +1,469 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useConfig, useUpdateSecrets } from '@/hooks/useConfig';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Label } from '@/components/ui/label';
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
+ import { Switch } from '@/components/ui/switch';
9
+ import { PageHeader, PageLayout } from '@/components/layout/page-layout';
10
+ import type { SecretProviderView, SecretRefView, SecretSourceView } from '@/api/types';
11
+ import { t } from '@/lib/i18n';
12
+ import { Plus, Save, Trash2 } from 'lucide-react';
13
+ import { toast } from 'sonner';
14
+
15
+ type ProviderRow = {
16
+ alias: string;
17
+ source: SecretSourceView;
18
+ prefix: string;
19
+ path: string;
20
+ command: string;
21
+ argsText: string;
22
+ cwd: string;
23
+ timeoutMs: number;
24
+ };
25
+
26
+ type RefRow = {
27
+ path: string;
28
+ source: SecretSourceView;
29
+ provider: string;
30
+ id: string;
31
+ };
32
+
33
+ const SOURCE_OPTIONS: SecretSourceView[] = ['env', 'file', 'exec'];
34
+
35
+ function createProviderRow(alias = ''): ProviderRow {
36
+ return {
37
+ alias,
38
+ source: 'env',
39
+ prefix: '',
40
+ path: '',
41
+ command: '',
42
+ argsText: '',
43
+ cwd: '',
44
+ timeoutMs: 5000
45
+ };
46
+ }
47
+
48
+ function createRefRow(): RefRow {
49
+ return {
50
+ path: '',
51
+ source: 'env',
52
+ provider: '',
53
+ id: ''
54
+ };
55
+ }
56
+
57
+ function providerToRow(alias: string, provider: SecretProviderView): ProviderRow {
58
+ if (provider.source === 'env') {
59
+ return {
60
+ ...createProviderRow(alias),
61
+ source: 'env',
62
+ prefix: provider.prefix ?? ''
63
+ };
64
+ }
65
+
66
+ if (provider.source === 'file') {
67
+ return {
68
+ ...createProviderRow(alias),
69
+ source: 'file',
70
+ path: provider.path
71
+ };
72
+ }
73
+
74
+ return {
75
+ ...createProviderRow(alias),
76
+ source: 'exec',
77
+ command: provider.command,
78
+ argsText: (provider.args ?? []).join('\n'),
79
+ cwd: provider.cwd ?? '',
80
+ timeoutMs: provider.timeoutMs ?? 5000
81
+ };
82
+ }
83
+
84
+ function rowToProvider(row: ProviderRow): SecretProviderView {
85
+ if (row.source === 'env') {
86
+ return {
87
+ source: 'env',
88
+ ...(row.prefix.trim() ? { prefix: row.prefix.trim() } : {})
89
+ };
90
+ }
91
+
92
+ if (row.source === 'file') {
93
+ return {
94
+ source: 'file',
95
+ path: row.path.trim(),
96
+ format: 'json'
97
+ };
98
+ }
99
+
100
+ return {
101
+ source: 'exec',
102
+ command: row.command.trim(),
103
+ args: row.argsText
104
+ .split('\n')
105
+ .map((item) => item.trim())
106
+ .filter(Boolean),
107
+ ...(row.cwd.trim() ? { cwd: row.cwd.trim() } : {}),
108
+ timeoutMs: Math.max(1, Math.trunc(row.timeoutMs || 5000))
109
+ };
110
+ }
111
+
112
+ export function SecretsConfig() {
113
+ const { data: config, isLoading } = useConfig();
114
+ const updateSecrets = useUpdateSecrets();
115
+
116
+ const [enabled, setEnabled] = useState(true);
117
+ const [defaultEnv, setDefaultEnv] = useState('');
118
+ const [defaultFile, setDefaultFile] = useState('');
119
+ const [defaultExec, setDefaultExec] = useState('');
120
+ const [providers, setProviders] = useState<ProviderRow[]>([]);
121
+ const [refs, setRefs] = useState<RefRow[]>([]);
122
+
123
+ useEffect(() => {
124
+ const secrets = config?.secrets;
125
+ if (!secrets) {
126
+ setEnabled(true);
127
+ setDefaultEnv('');
128
+ setDefaultFile('');
129
+ setDefaultExec('');
130
+ setProviders([]);
131
+ setRefs([]);
132
+ return;
133
+ }
134
+
135
+ setEnabled(Boolean(secrets.enabled));
136
+ setDefaultEnv(secrets.defaults.env ?? '');
137
+ setDefaultFile(secrets.defaults.file ?? '');
138
+ setDefaultExec(secrets.defaults.exec ?? '');
139
+
140
+ const nextProviders = Object.entries(secrets.providers).map(([alias, provider]) =>
141
+ providerToRow(alias, provider)
142
+ );
143
+ const nextRefs = Object.entries(secrets.refs).map(([path, ref]) => ({
144
+ path,
145
+ source: ref.source,
146
+ provider: ref.provider ?? '',
147
+ id: ref.id
148
+ }));
149
+
150
+ setProviders(nextProviders);
151
+ setRefs(nextRefs);
152
+ }, [config?.secrets]);
153
+
154
+ const providerAliases = useMemo(() => {
155
+ const aliases = providers
156
+ .map((item) => item.alias.trim())
157
+ .filter(Boolean);
158
+ return Array.from(new Set(aliases));
159
+ }, [providers]);
160
+
161
+ const updateProvider = (index: number, patch: Partial<ProviderRow>) => {
162
+ setProviders((prev) => prev.map((entry, cursor) => (cursor === index ? { ...entry, ...patch } : entry)));
163
+ };
164
+
165
+ const updateRef = (index: number, patch: Partial<RefRow>) => {
166
+ setRefs((prev) => prev.map((entry, cursor) => (cursor === index ? { ...entry, ...patch } : entry)));
167
+ };
168
+
169
+ const handleSave = () => {
170
+ try {
171
+ const providerMap: Record<string, SecretProviderView> = {};
172
+ for (const [index, row] of providers.entries()) {
173
+ const alias = row.alias.trim();
174
+ if (!alias) {
175
+ throw new Error(`${t('providerAlias')} #${index + 1} ${t('isRequired')}`);
176
+ }
177
+
178
+ if (providerMap[alias]) {
179
+ throw new Error(`${t('providerAlias')}: ${alias} (${t('duplicate')})`);
180
+ }
181
+
182
+ if (row.source === 'file' && !row.path.trim()) {
183
+ throw new Error(`${t('secretFilePath')} #${index + 1} ${t('isRequired')}`);
184
+ }
185
+
186
+ if (row.source === 'exec' && !row.command.trim()) {
187
+ throw new Error(`${t('secretExecCommand')} #${index + 1} ${t('isRequired')}`);
188
+ }
189
+
190
+ providerMap[alias] = rowToProvider(row);
191
+ }
192
+
193
+ const refMap: Record<string, SecretRefView> = {};
194
+ for (const [index, row] of refs.entries()) {
195
+ const path = row.path.trim();
196
+ const id = row.id.trim();
197
+ if (!path) {
198
+ throw new Error(`${t('secretConfigPath')} #${index + 1} ${t('isRequired')}`);
199
+ }
200
+ if (!id) {
201
+ throw new Error(`${t('secretId')} #${index + 1} ${t('isRequired')}`);
202
+ }
203
+
204
+ const provider = row.provider.trim();
205
+ if (provider && !providerMap[provider]) {
206
+ throw new Error(`${t('secretProviderAlias')}: ${provider} ${t('notFound')}`);
207
+ }
208
+
209
+ refMap[path] = {
210
+ source: row.source,
211
+ ...(provider ? { provider } : {}),
212
+ id
213
+ };
214
+ }
215
+
216
+ updateSecrets.mutate({
217
+ data: {
218
+ enabled,
219
+ defaults: {
220
+ env: defaultEnv.trim() || null,
221
+ file: defaultFile.trim() || null,
222
+ exec: defaultExec.trim() || null
223
+ },
224
+ providers: providerMap,
225
+ refs: refMap
226
+ }
227
+ });
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error);
230
+ toast.error(message);
231
+ }
232
+ };
233
+
234
+ if (isLoading) {
235
+ return <div className="p-8 text-gray-400">{t('loading')}</div>;
236
+ }
237
+
238
+ return (
239
+ <PageLayout className="space-y-6">
240
+ <PageHeader title={t('secretsPageTitle')} description={t('secretsPageDescription')} />
241
+
242
+ <Card>
243
+ <CardHeader>
244
+ <CardTitle>{t('secrets')}</CardTitle>
245
+ <CardDescription>{t('secretsEnabledHelp')}</CardDescription>
246
+ </CardHeader>
247
+ <CardContent className="space-y-4">
248
+ <div className="flex items-center justify-between rounded-xl border border-gray-200 p-3">
249
+ <div>
250
+ <p className="text-sm font-medium text-gray-800">{t('enabled')}</p>
251
+ <p className="text-xs text-gray-500">{t('secretsEnabledHelp')}</p>
252
+ </div>
253
+ <Switch checked={enabled} onCheckedChange={setEnabled} />
254
+ </div>
255
+
256
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
257
+ <div className="space-y-2">
258
+ <Label>{t('defaultEnvProvider')}</Label>
259
+ <Select value={defaultEnv || '__none__'} onValueChange={(value) => setDefaultEnv(value === '__none__' ? '' : value)}>
260
+ <SelectTrigger>
261
+ <SelectValue placeholder={t('noneOption')} />
262
+ </SelectTrigger>
263
+ <SelectContent>
264
+ <SelectItem value="__none__">{t('noneOption')}</SelectItem>
265
+ {providerAliases.map((alias) => (
266
+ <SelectItem key={alias} value={alias}>
267
+ {alias}
268
+ </SelectItem>
269
+ ))}
270
+ </SelectContent>
271
+ </Select>
272
+ </div>
273
+
274
+ <div className="space-y-2">
275
+ <Label>{t('defaultFileProvider')}</Label>
276
+ <Select value={defaultFile || '__none__'} onValueChange={(value) => setDefaultFile(value === '__none__' ? '' : value)}>
277
+ <SelectTrigger>
278
+ <SelectValue placeholder={t('noneOption')} />
279
+ </SelectTrigger>
280
+ <SelectContent>
281
+ <SelectItem value="__none__">{t('noneOption')}</SelectItem>
282
+ {providerAliases.map((alias) => (
283
+ <SelectItem key={alias} value={alias}>
284
+ {alias}
285
+ </SelectItem>
286
+ ))}
287
+ </SelectContent>
288
+ </Select>
289
+ </div>
290
+
291
+ <div className="space-y-2">
292
+ <Label>{t('defaultExecProvider')}</Label>
293
+ <Select value={defaultExec || '__none__'} onValueChange={(value) => setDefaultExec(value === '__none__' ? '' : value)}>
294
+ <SelectTrigger>
295
+ <SelectValue placeholder={t('noneOption')} />
296
+ </SelectTrigger>
297
+ <SelectContent>
298
+ <SelectItem value="__none__">{t('noneOption')}</SelectItem>
299
+ {providerAliases.map((alias) => (
300
+ <SelectItem key={alias} value={alias}>
301
+ {alias}
302
+ </SelectItem>
303
+ ))}
304
+ </SelectContent>
305
+ </Select>
306
+ </div>
307
+ </div>
308
+ </CardContent>
309
+ </Card>
310
+
311
+ <Card>
312
+ <CardHeader>
313
+ <CardTitle>{t('secretProvidersTitle')}</CardTitle>
314
+ <CardDescription>{t('secretProvidersDescription')}</CardDescription>
315
+ </CardHeader>
316
+ <CardContent className="space-y-3">
317
+ {providers.map((provider, index) => (
318
+ <div key={`provider-${index}`} className="rounded-xl border border-gray-200 p-3 space-y-3">
319
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
320
+ <Input
321
+ value={provider.alias}
322
+ onChange={(event) => updateProvider(index, { alias: event.target.value })}
323
+ placeholder={t('providerAlias')}
324
+ />
325
+ <Select value={provider.source} onValueChange={(value) => updateProvider(index, { source: value as SecretSourceView })}>
326
+ <SelectTrigger>
327
+ <SelectValue />
328
+ </SelectTrigger>
329
+ <SelectContent>
330
+ {SOURCE_OPTIONS.map((source) => (
331
+ <SelectItem key={source} value={source}>
332
+ {source}
333
+ </SelectItem>
334
+ ))}
335
+ </SelectContent>
336
+ </Select>
337
+ <Button type="button" variant="outline" onClick={() => setProviders((prev) => prev.filter((_, i) => i !== index))}>
338
+ <Trash2 className="h-4 w-4 mr-2" />
339
+ {t('removeProvider')}
340
+ </Button>
341
+ </div>
342
+
343
+ {provider.source === 'env' && (
344
+ <Input
345
+ value={provider.prefix}
346
+ onChange={(event) => updateProvider(index, { prefix: event.target.value })}
347
+ placeholder={t('envPrefix')}
348
+ />
349
+ )}
350
+
351
+ {provider.source === 'file' && (
352
+ <Input
353
+ value={provider.path}
354
+ onChange={(event) => updateProvider(index, { path: event.target.value })}
355
+ placeholder={t('secretFilePath')}
356
+ />
357
+ )}
358
+
359
+ {provider.source === 'exec' && (
360
+ <div className="space-y-2">
361
+ <Input
362
+ value={provider.command}
363
+ onChange={(event) => updateProvider(index, { command: event.target.value })}
364
+ placeholder={t('secretExecCommand')}
365
+ />
366
+ <textarea
367
+ className="min-h-[84px] w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-mono"
368
+ value={provider.argsText}
369
+ onChange={(event) => updateProvider(index, { argsText: event.target.value })}
370
+ placeholder={t('secretExecArgs')}
371
+ />
372
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
373
+ <Input
374
+ value={provider.cwd}
375
+ onChange={(event) => updateProvider(index, { cwd: event.target.value })}
376
+ placeholder={t('secretExecCwd')}
377
+ />
378
+ <Input
379
+ type="number"
380
+ min={1}
381
+ value={provider.timeoutMs}
382
+ onChange={(event) => updateProvider(index, { timeoutMs: Number.parseInt(event.target.value, 10) || 5000 })}
383
+ placeholder={t('secretExecTimeoutMs')}
384
+ />
385
+ </div>
386
+ </div>
387
+ )}
388
+ </div>
389
+ ))}
390
+
391
+ <Button type="button" variant="outline" onClick={() => setProviders((prev) => [...prev, createProviderRow()])}>
392
+ <Plus className="h-4 w-4 mr-2" />
393
+ {t('addSecretProvider')}
394
+ </Button>
395
+ </CardContent>
396
+ </Card>
397
+
398
+ <Card>
399
+ <CardHeader>
400
+ <CardTitle>{t('secretRefsTitle')}</CardTitle>
401
+ <CardDescription>{t('secretRefsDescription')}</CardDescription>
402
+ </CardHeader>
403
+ <CardContent className="space-y-3">
404
+ {refs.map((ref, index) => (
405
+ <div key={`ref-${index}`} className="rounded-xl border border-gray-200 p-3 space-y-3">
406
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
407
+ <Input
408
+ value={ref.path}
409
+ onChange={(event) => updateRef(index, { path: event.target.value })}
410
+ placeholder={t('secretConfigPath')}
411
+ />
412
+ <Input
413
+ value={ref.id}
414
+ onChange={(event) => updateRef(index, { id: event.target.value })}
415
+ placeholder={t('secretId')}
416
+ />
417
+ <Select value={ref.source} onValueChange={(value) => updateRef(index, { source: value as SecretSourceView })}>
418
+ <SelectTrigger>
419
+ <SelectValue />
420
+ </SelectTrigger>
421
+ <SelectContent>
422
+ {SOURCE_OPTIONS.map((source) => (
423
+ <SelectItem key={source} value={source}>
424
+ {source}
425
+ </SelectItem>
426
+ ))}
427
+ </SelectContent>
428
+ </Select>
429
+ <div className="grid grid-cols-[1fr_auto] gap-2">
430
+ <Select
431
+ value={ref.provider || '__none__'}
432
+ onValueChange={(value) => updateRef(index, { provider: value === '__none__' ? '' : value })}
433
+ >
434
+ <SelectTrigger>
435
+ <SelectValue placeholder={t('secretProviderAlias')} />
436
+ </SelectTrigger>
437
+ <SelectContent>
438
+ <SelectItem value="__none__">{t('noneOption')}</SelectItem>
439
+ {providerAliases.map((alias) => (
440
+ <SelectItem key={alias} value={alias}>
441
+ {alias}
442
+ </SelectItem>
443
+ ))}
444
+ </SelectContent>
445
+ </Select>
446
+ <Button type="button" variant="outline" onClick={() => setRefs((prev) => prev.filter((_, i) => i !== index))}>
447
+ <Trash2 className="h-4 w-4" />
448
+ </Button>
449
+ </div>
450
+ </div>
451
+ </div>
452
+ ))}
453
+
454
+ <Button type="button" variant="outline" onClick={() => setRefs((prev) => [...prev, createRefRow()])}>
455
+ <Plus className="h-4 w-4 mr-2" />
456
+ {t('addSecretRef')}
457
+ </Button>
458
+ </CardContent>
459
+ </Card>
460
+
461
+ <div className="flex justify-end">
462
+ <Button type="button" onClick={handleSave} disabled={updateSecrets.isPending}>
463
+ <Save className="h-4 w-4 mr-2" />
464
+ {updateSecrets.isPending ? t('saving') : t('save')}
465
+ </Button>
466
+ </div>
467
+ </PageLayout>
468
+ );
469
+ }
@@ -1,7 +1,7 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
3
3
  import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
4
- import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette } from 'lucide-react';
4
+ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette, KeyRound } from 'lucide-react';
5
5
  import { NavLink } from 'react-router-dom';
6
6
  import { useDocBrowser } from '@/components/doc-browser';
7
7
  import { useI18n } from '@/components/providers/I18nProvider';
@@ -66,6 +66,11 @@ export function Sidebar() {
66
66
  label: t('cron'),
67
67
  icon: AlarmClock,
68
68
  },
69
+ {
70
+ target: '/secrets',
71
+ label: t('secrets'),
72
+ icon: KeyRound,
73
+ },
69
74
  {
70
75
  target: '/marketplace/plugins',
71
76
  label: t('marketplaceFilterPlugins'),
@@ -7,6 +7,7 @@ import {
7
7
  updateProvider,
8
8
  updateChannel,
9
9
  updateRuntime,
10
+ updateSecrets,
10
11
  executeConfigAction,
11
12
  fetchSessions,
12
13
  fetchSessionHistory,
@@ -109,6 +110,22 @@ export function useUpdateRuntime() {
109
110
  });
110
111
  }
111
112
 
113
+ export function useUpdateSecrets() {
114
+ const queryClient = useQueryClient();
115
+
116
+ return useMutation({
117
+ mutationFn: ({ data }: { data: unknown }) =>
118
+ updateSecrets(data as Parameters<typeof updateSecrets>[0]),
119
+ onSuccess: () => {
120
+ queryClient.invalidateQueries({ queryKey: ['config'] });
121
+ toast.success(t('configSavedApplied'));
122
+ },
123
+ onError: (error: Error) => {
124
+ toast.error(t('configSaveFailed') + ': ' + error.message);
125
+ }
126
+ });
127
+ }
128
+
112
129
  export function useExecuteConfigAction() {
113
130
  return useMutation({
114
131
  mutationFn: ({ actionId, data }: { actionId: string; data: unknown }) =>
@@ -8,6 +8,7 @@ export type ToolCard = {
8
8
  detail?: string;
9
9
  text?: string;
10
10
  callId?: string;
11
+ hasResult?: boolean;
11
12
  };
12
13
 
13
14
  export type GroupedChatMessage = {
@@ -158,11 +159,15 @@ export function extractToolCards(message: SessionMessageView): ToolCard[] {
158
159
  const fn = isRecord(call.function) ? call.function : null;
159
160
  const name = toToolName(fn?.name ?? call.name);
160
161
  const args = fn?.arguments ?? call.arguments;
162
+ const resultText = typeof call.result_text === 'string' ? call.result_text.trim() : '';
163
+ const hasResult = call.has_result === true || typeof call.result_text === 'string';
161
164
  cards.push({
162
165
  kind: 'call',
163
166
  name,
164
167
  detail: summarizeToolArgs(args),
165
- callId: typeof call.id === 'string' ? call.id : undefined
168
+ callId: typeof call.id === 'string' ? call.id : undefined,
169
+ text: resultText,
170
+ hasResult
166
171
  });
167
172
  }
168
173
 
@@ -173,13 +178,86 @@ export function extractToolCards(message: SessionMessageView): ToolCard[] {
173
178
  kind: 'result',
174
179
  name: toToolName(message.name ?? cards[0]?.name),
175
180
  text,
176
- callId: typeof message.tool_call_id === 'string' ? message.tool_call_id : undefined
181
+ callId: typeof message.tool_call_id === 'string' ? message.tool_call_id : undefined,
182
+ hasResult: true
177
183
  });
178
184
  }
179
185
 
180
186
  return cards;
181
187
  }
182
188
 
189
+ type ToolResultBucket = {
190
+ name?: string;
191
+ texts: string[];
192
+ };
193
+
194
+ function cloneMessageForMerge(message: SessionMessageView): SessionMessageView {
195
+ return {
196
+ ...message,
197
+ tool_calls: Array.isArray(message.tool_calls)
198
+ ? message.tool_calls.map((call) => (isRecord(call) ? { ...call } : call))
199
+ : message.tool_calls
200
+ };
201
+ }
202
+
203
+ export function combineToolCallAndResults(messages: SessionMessageView[]): SessionMessageView[] {
204
+ const cloned = messages.map(cloneMessageForMerge);
205
+ const resultByCallId = new Map<string, ToolResultBucket>();
206
+
207
+ for (const message of cloned) {
208
+ if (normalizeChatRole(message) !== 'tool') {
209
+ continue;
210
+ }
211
+ if (typeof message.tool_call_id !== 'string' || !message.tool_call_id.trim()) {
212
+ continue;
213
+ }
214
+
215
+ const callId = message.tool_call_id.trim();
216
+ const text = extractMessageText(message.content).trim();
217
+ const existing = resultByCallId.get(callId) ?? { texts: [] };
218
+ if (typeof message.name === 'string' && message.name.trim()) {
219
+ existing.name = message.name.trim();
220
+ }
221
+ existing.texts.push(text);
222
+ resultByCallId.set(callId, existing);
223
+ }
224
+
225
+ const consumedCallIds = new Set<string>();
226
+
227
+ for (const message of cloned) {
228
+ if (normalizeChatRole(message) !== 'assistant' || !Array.isArray(message.tool_calls)) {
229
+ continue;
230
+ }
231
+
232
+ message.tool_calls = message.tool_calls.map((call) => {
233
+ if (!isRecord(call) || typeof call.id !== 'string') {
234
+ return call;
235
+ }
236
+ const result = resultByCallId.get(call.id);
237
+ if (!result) {
238
+ return call;
239
+ }
240
+ consumedCallIds.add(call.id);
241
+ return {
242
+ ...call,
243
+ result_text: result.texts.filter(Boolean).join('\n\n'),
244
+ has_result: true,
245
+ result_name: result.name
246
+ };
247
+ }) as Array<Record<string, unknown>>;
248
+ }
249
+
250
+ return cloned.filter((message) => {
251
+ if (normalizeChatRole(message) !== 'tool') {
252
+ return true;
253
+ }
254
+ if (typeof message.tool_call_id !== 'string' || !message.tool_call_id.trim()) {
255
+ return true;
256
+ }
257
+ return !consumedCallIds.has(message.tool_call_id.trim());
258
+ });
259
+ }
260
+
183
261
  export function groupChatMessages(messages: SessionMessageView[]): GroupedChatMessage[] {
184
262
  const groups: GroupedChatMessage[] = [];
185
263
  let lastTs = 0;