@geminilight/mindos 0.6.30 → 0.6.32

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 (52) hide show
  1. package/README_zh.md +10 -4
  2. package/app/app/api/ask/route.ts +12 -7
  3. package/app/app/api/export/route.ts +105 -0
  4. package/app/app/globals.css +2 -2
  5. package/app/app/trash/page.tsx +7 -0
  6. package/app/app/view/[...path]/ViewPageClient.tsx +234 -2
  7. package/app/components/ExportModal.tsx +220 -0
  8. package/app/components/FileTree.tsx +22 -2
  9. package/app/components/HomeContent.tsx +91 -20
  10. package/app/components/MarkdownView.tsx +45 -10
  11. package/app/components/Sidebar.tsx +10 -1
  12. package/app/components/TrashPageClient.tsx +263 -0
  13. package/app/components/ask/ToolCallBlock.tsx +102 -18
  14. package/app/components/changes/ChangesContentPage.tsx +58 -14
  15. package/app/components/explore/ExploreContent.tsx +4 -7
  16. package/app/components/explore/UseCaseCard.tsx +18 -1
  17. package/app/components/explore/use-cases.generated.ts +76 -0
  18. package/app/components/explore/use-cases.yaml +185 -0
  19. package/app/components/panels/DiscoverPanel.tsx +1 -1
  20. package/app/components/renderers/workflow-yaml/StepEditor.tsx +98 -91
  21. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +72 -72
  22. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +175 -119
  23. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +61 -61
  24. package/app/components/renderers/workflow-yaml/execution.ts +64 -12
  25. package/app/components/renderers/workflow-yaml/selectors.tsx +65 -13
  26. package/app/components/settings/AiTab.tsx +191 -174
  27. package/app/components/settings/AppearanceTab.tsx +168 -77
  28. package/app/components/settings/KnowledgeTab.tsx +131 -136
  29. package/app/components/settings/McpTab.tsx +11 -11
  30. package/app/components/settings/Primitives.tsx +60 -0
  31. package/app/components/settings/SettingsContent.tsx +15 -8
  32. package/app/components/settings/SyncTab.tsx +12 -12
  33. package/app/components/settings/UninstallTab.tsx +8 -18
  34. package/app/components/settings/UpdateTab.tsx +82 -82
  35. package/app/components/settings/types.ts +17 -8
  36. package/app/lib/acp/session.ts +12 -3
  37. package/app/lib/actions.ts +57 -3
  38. package/app/lib/agent/stream-consumer.ts +18 -0
  39. package/app/lib/agent/tools.ts +56 -9
  40. package/app/lib/core/export.ts +116 -0
  41. package/app/lib/core/trash.ts +241 -0
  42. package/app/lib/fs.ts +47 -0
  43. package/app/lib/hooks/usePinnedFiles.ts +90 -0
  44. package/app/lib/i18n/generated/explore-i18n.generated.ts +138 -0
  45. package/app/lib/i18n/index.ts +3 -0
  46. package/app/lib/i18n/modules/knowledge.ts +120 -6
  47. package/app/lib/i18n/modules/onboarding.ts +2 -134
  48. package/app/lib/i18n/modules/settings.ts +12 -0
  49. package/app/package.json +8 -2
  50. package/app/scripts/generate-explore.ts +145 -0
  51. package/package.json +1 -1
  52. package/app/components/explore/use-cases.ts +0 -58
@@ -0,0 +1,263 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback, useTransition } from 'react';
4
+ import { Trash2, RotateCcw, X, Folder, FileText, Table, AlertTriangle, Loader2 } from 'lucide-react';
5
+ import { useRouter } from 'next/navigation';
6
+ import { useLocale } from '@/lib/LocaleContext';
7
+ import { restoreFromTrashAction, permanentlyDeleteAction, emptyTrashAction } from '@/lib/actions';
8
+ import { ConfirmDialog } from '@/components/agents/AgentsPrimitives';
9
+ import { toast } from '@/lib/toast';
10
+ import type { TrashMeta } from '@/lib/core/trash';
11
+
12
+ function relativeTimeShort(iso: string): string {
13
+ const delta = Date.now() - new Date(iso).getTime();
14
+ const mins = Math.floor(delta / 60000);
15
+ if (mins < 1) return 'just now';
16
+ if (mins < 60) return `${mins}m ago`;
17
+ const hours = Math.floor(mins / 60);
18
+ if (hours < 24) return `${hours}h ago`;
19
+ const days = Math.floor(hours / 24);
20
+ return `${days}d ago`;
21
+ }
22
+
23
+ function daysUntil(iso: string): number {
24
+ return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / 86400000));
25
+ }
26
+
27
+ export default function TrashPageClient({ initialItems }: { initialItems: TrashMeta[] }) {
28
+ const { t } = useLocale();
29
+ const router = useRouter();
30
+ const [items, setItems] = useState(initialItems);
31
+ const [isPending, startTransition] = useTransition();
32
+ const [confirmEmpty, setConfirmEmpty] = useState(false);
33
+ const [confirmDelete, setConfirmDelete] = useState<TrashMeta | null>(null);
34
+ const [conflictItem, setConflictItem] = useState<TrashMeta | null>(null);
35
+ const [busyId, setBusyId] = useState<string | null>(null);
36
+
37
+ const handleRestore = useCallback(async (item: TrashMeta) => {
38
+ setBusyId(item.id);
39
+ try {
40
+ const result = await restoreFromTrashAction(item.id, 'restore');
41
+ if (result.success) {
42
+ setItems(prev => prev.filter(i => i.id !== item.id));
43
+ toast.success(t.trash.restored);
44
+ router.refresh();
45
+ } else if (result.conflict) {
46
+ setConflictItem(item);
47
+ } else {
48
+ toast.error(result.error ?? 'Failed to restore');
49
+ }
50
+ } finally {
51
+ setBusyId(null);
52
+ }
53
+ }, [t, router]);
54
+
55
+ const handleConflictResolve = useCallback(async (mode: 'overwrite' | 'copy') => {
56
+ if (!conflictItem) return;
57
+ const result = await restoreFromTrashAction(conflictItem.id, mode);
58
+ if (result.success) {
59
+ setItems(prev => prev.filter(i => i.id !== conflictItem.id));
60
+ toast.success(t.trash.restored);
61
+ setConflictItem(null);
62
+ router.refresh();
63
+ } else {
64
+ toast.error(result.error ?? 'Failed to restore');
65
+ }
66
+ }, [conflictItem, t, router]);
67
+
68
+ const handlePermanentDelete = useCallback(async () => {
69
+ if (!confirmDelete) return;
70
+ const result = await permanentlyDeleteAction(confirmDelete.id);
71
+ if (result.success) {
72
+ setItems(prev => prev.filter(i => i.id !== confirmDelete.id));
73
+ toast.success(t.trash.deleted);
74
+ } else {
75
+ toast.error(result.error ?? 'Failed to delete');
76
+ }
77
+ setConfirmDelete(null);
78
+ }, [confirmDelete, t]);
79
+
80
+ const handleEmptyTrash = useCallback(async () => {
81
+ startTransition(async () => {
82
+ const result = await emptyTrashAction();
83
+ if (result.success) {
84
+ setItems([]);
85
+ toast.success(t.trash.emptied(result.count ?? 0));
86
+ } else {
87
+ toast.error(result.error ?? 'Failed to empty trash');
88
+ }
89
+ setConfirmEmpty(false);
90
+ });
91
+ }, [t]);
92
+
93
+ return (
94
+ <div className="min-h-screen">
95
+ <div className="px-4 md:px-6 pt-6 md:pt-8">
96
+ <div className="content-width xl:mr-[220px] rounded-xl border border-border bg-card px-4 py-3 md:px-5 md:py-4">
97
+ <div className="flex flex-wrap items-start justify-between gap-3">
98
+ <div className="min-w-0">
99
+ <div className="flex items-center gap-2 text-sm font-medium text-foreground font-display">
100
+ <Trash2 size={15} />
101
+ {t.trash.title}
102
+ </div>
103
+ <p className="mt-1 text-xs text-muted-foreground">
104
+ {t.trash.subtitle}
105
+ </p>
106
+ {items.length > 0 && (
107
+ <div className="mt-2 text-xs text-muted-foreground">
108
+ {t.trash.itemCount(items.length)}
109
+ </div>
110
+ )}
111
+ </div>
112
+ {items.length > 0 && (
113
+ <button
114
+ type="button"
115
+ onClick={() => setConfirmEmpty(true)}
116
+ disabled={isPending}
117
+ className="px-2.5 py-1.5 rounded-md text-xs font-medium bg-error/10 text-error hover:bg-error/20 transition-colors disabled:opacity-50"
118
+ >
119
+ {t.trash.emptyTrash}
120
+ </button>
121
+ )}
122
+ </div>
123
+ </div>
124
+ </div>
125
+
126
+ <div className="px-4 md:px-6 py-4 md:py-6">
127
+ <div className="content-width xl:mr-[220px]">
128
+ {items.length === 0 ? (
129
+ <div className="rounded-xl border border-dashed border-border bg-card p-12 text-center">
130
+ <Trash2 size={32} className="mx-auto text-muted-foreground/30 mb-3" />
131
+ <p className="text-sm text-muted-foreground font-display">{t.trash.empty}</p>
132
+ <p className="text-xs text-muted-foreground/60 mt-1">{t.trash.emptySubtext}</p>
133
+ </div>
134
+ ) : (
135
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
136
+ {items.map(item => {
137
+ const days = daysUntil(item.expiresAt);
138
+ const isExpiring = days <= 3;
139
+ return (
140
+ <div
141
+ key={item.id}
142
+ className="rounded-xl border border-border bg-card p-4 flex flex-col gap-3 hover:border-border/80 transition-colors"
143
+ >
144
+ <div className="flex items-start gap-3 min-w-0">
145
+ <div className="w-9 h-9 rounded-lg bg-muted/50 flex items-center justify-center shrink-0">
146
+ {item.isDirectory
147
+ ? <Folder size={16} className="text-muted-foreground" />
148
+ : item.fileName.endsWith('.csv')
149
+ ? <Table size={16} className="text-success" />
150
+ : <FileText size={16} className="text-muted-foreground" />
151
+ }
152
+ </div>
153
+ <div className="min-w-0 flex-1">
154
+ <p className="text-sm font-medium text-foreground truncate" title={item.fileName}>
155
+ {item.fileName}
156
+ </p>
157
+ <p className="text-xs text-muted-foreground truncate mt-0.5" title={item.originalPath}>
158
+ {t.trash.from}: {item.originalPath.split('/').slice(0, -1).join('/') || '/'}
159
+ </p>
160
+ </div>
161
+ </div>
162
+ <div className="flex items-center justify-between">
163
+ <div className="flex flex-col gap-0.5">
164
+ <span className="text-2xs text-muted-foreground">
165
+ {t.trash.deletedAgo(relativeTimeShort(item.deletedAt))}
166
+ </span>
167
+ <span className={`text-2xs ${isExpiring ? 'text-error' : 'text-muted-foreground/60'}`}>
168
+ {isExpiring && <AlertTriangle size={9} className="inline mr-0.5" />}
169
+ {t.trash.expiresIn(days)}
170
+ </span>
171
+ </div>
172
+ <div className="flex items-center gap-1.5">
173
+ <button
174
+ type="button"
175
+ onClick={() => handleRestore(item)}
176
+ disabled={busyId === item.id}
177
+ className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-success/10 text-success hover:bg-success/20 transition-colors disabled:opacity-50"
178
+ >
179
+ {busyId === item.id ? <Loader2 size={11} className="animate-spin" /> : <RotateCcw size={11} />}
180
+ {t.trash.restore}
181
+ </button>
182
+ <button
183
+ type="button"
184
+ onClick={() => setConfirmDelete(item)}
185
+ className="p-1 rounded-md text-muted-foreground hover:text-error hover:bg-error/10 transition-colors"
186
+ title={t.trash.deletePermanently}
187
+ >
188
+ <X size={13} />
189
+ </button>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ })}
195
+ </div>
196
+ )}
197
+ </div>
198
+ </div>
199
+
200
+ {/* Empty Trash Confirmation */}
201
+ <ConfirmDialog
202
+ open={confirmEmpty}
203
+ title={t.trash.emptyTrash}
204
+ message={t.trash.emptyTrashConfirm}
205
+ confirmLabel={t.trash.emptyTrash}
206
+ cancelLabel={t.export?.cancel ?? 'Cancel'}
207
+ onConfirm={() => void handleEmptyTrash()}
208
+ onCancel={() => setConfirmEmpty(false)}
209
+ variant="destructive"
210
+ />
211
+
212
+ {/* Permanent Delete Confirmation */}
213
+ <ConfirmDialog
214
+ open={!!confirmDelete}
215
+ title={t.trash.deletePermanently}
216
+ message={confirmDelete ? t.trash.deletePermanentlyConfirm(confirmDelete.fileName) : ''}
217
+ confirmLabel={t.trash.deletePermanently}
218
+ cancelLabel={t.export?.cancel ?? 'Cancel'}
219
+ onConfirm={() => void handlePermanentDelete()}
220
+ onCancel={() => setConfirmDelete(null)}
221
+ variant="destructive"
222
+ />
223
+
224
+ {/* Restore Conflict Dialog */}
225
+ {conflictItem && (
226
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
227
+ <div className="bg-card border border-border rounded-xl shadow-xl max-w-sm w-full mx-4 p-5">
228
+ <div className="flex items-center gap-2 mb-3">
229
+ <AlertTriangle size={16} className="text-[var(--amber)]" />
230
+ <h3 className="text-sm font-semibold font-display">{t.trash.restoreConflict}</h3>
231
+ </div>
232
+ <p className="text-xs text-muted-foreground mb-4">
233
+ &quot;{conflictItem.fileName}&quot; — {conflictItem.originalPath}
234
+ </p>
235
+ <div className="flex items-center gap-2 justify-end">
236
+ <button
237
+ type="button"
238
+ onClick={() => setConflictItem(null)}
239
+ className="px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
240
+ >
241
+ {t.export?.cancel ?? 'Cancel'}
242
+ </button>
243
+ <button
244
+ type="button"
245
+ onClick={() => void handleConflictResolve('copy')}
246
+ className="px-3 py-1.5 rounded-md text-xs font-medium bg-muted text-foreground hover:bg-muted/80 transition-colors"
247
+ >
248
+ {t.trash.saveAsCopy}
249
+ </button>
250
+ <button
251
+ type="button"
252
+ onClick={() => void handleConflictResolve('overwrite')}
253
+ className="px-3 py-1.5 rounded-md text-xs font-medium bg-[var(--amber-dim)] text-[var(--amber-text)] hover:opacity-80 transition-colors"
254
+ >
255
+ {t.trash.overwrite}
256
+ </button>
257
+ </div>
258
+ </div>
259
+ </div>
260
+ )}
261
+ </div>
262
+ );
263
+ }
@@ -1,11 +1,17 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ import { useState, useMemo } from 'react';
4
4
  import { ChevronRight, ChevronDown, Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react';
5
5
  import type { ToolCallPart } from '@/lib/types';
6
6
 
7
7
  const DESTRUCTIVE_TOOLS = new Set(['delete_file', 'move_file', 'rename_file', 'write_file']);
8
8
 
9
+ /** Tools that produce diff output — auto-expand when done */
10
+ const DIFF_TOOLS = new Set([
11
+ 'write_file', 'create_file', 'update_section',
12
+ 'insert_after_heading', 'edit_lines', 'append_to_file',
13
+ ]);
14
+
9
15
  const TOOL_ICONS: Record<string, string> = {
10
16
  search: '🔍',
11
17
  list_files: '📂',
@@ -41,18 +47,55 @@ function formatInput(input: unknown): string {
41
47
  return parts.join(', ');
42
48
  }
43
49
 
44
- function truncateOutput(output: string | undefined, maxLen = 200): string {
45
- if (!output) return '';
46
- if (output.length <= maxLen) return output;
47
- return output.slice(0, maxLen) + '…';
50
+ const CHANGES_SEPARATOR = '--- changes ---';
51
+
52
+ /** Parse tool output: extract header line (before separator) and diff lines (after separator) */
53
+ function parseToolOutput(output: string | undefined): { header: string; stats: string; diffLines: { prefix: string; text: string }[] } {
54
+ if (!output) return { header: '', stats: '', diffLines: [] };
55
+ const sepIdx = output.indexOf(CHANGES_SEPARATOR);
56
+ if (sepIdx === -1) return { header: output, stats: '', diffLines: [] };
57
+
58
+ const header = output.slice(0, sepIdx).trim();
59
+ const diffText = output.slice(sepIdx + CHANGES_SEPARATOR.length).trim();
60
+
61
+ // Extract stats from header, e.g. "File written: foo.md (+3 −1)"
62
+ const statsMatch = header.match(/\((\+\d+\s*−\d+)\)/);
63
+ const stats = statsMatch ? statsMatch[1] : '';
64
+
65
+ const diffLines = diffText.split('\n').map(line => {
66
+ if (line.startsWith('+ ')) return { prefix: '+', text: line.slice(2) };
67
+ if (line.startsWith('- ')) return { prefix: '-', text: line.slice(2) };
68
+ if (line.startsWith(' ')) return { prefix: ' ', text: line.slice(2) };
69
+ // gap or other
70
+ return { prefix: ' ', text: line };
71
+ });
72
+
73
+ return { header, stats, diffLines };
48
74
  }
49
75
 
50
76
  export default function ToolCallBlock({ part }: { part: ToolCallPart }) {
51
- const [expanded, setExpanded] = useState(false);
77
+ const hasDiff = DIFF_TOOLS.has(part.toolName);
78
+ const isDone = part.state === 'done';
79
+ // Auto-expand diff tools when completed
80
+ const [manualToggle, setManualToggle] = useState<boolean | null>(null);
81
+ const expanded = manualToggle ?? (hasDiff && isDone);
82
+
52
83
  const icon = TOOL_ICONS[part.toolName] ?? '🔧';
53
- const inputSummary = formatInput(part.input);
54
84
  const isDestructive = DESTRUCTIVE_TOOLS.has(part.toolName);
55
85
 
86
+ const parsed = useMemo(() => parseToolOutput(part.output), [part.output]);
87
+
88
+ // For collapsed header: show file path from input + stats
89
+ const filePath = useMemo(() => {
90
+ if (!part.input || typeof part.input !== 'object') return '';
91
+ const obj = part.input as Record<string, unknown>;
92
+ return (obj.path as string) ?? '';
93
+ }, [part.input]);
94
+
95
+ const headerLabel = filePath
96
+ ? `${filePath.split('/').pop() ?? filePath}${parsed.stats ? ` (${parsed.stats})` : ''}`
97
+ : formatInput(part.input);
98
+
56
99
  return (
57
100
  <div className={`my-1 rounded-md border text-xs font-mono ${
58
101
  isDestructive
@@ -61,14 +104,14 @@ export default function ToolCallBlock({ part }: { part: ToolCallPart }) {
61
104
  }`}>
62
105
  <button
63
106
  type="button"
64
- onClick={() => setExpanded(v => !v)}
107
+ onClick={() => setManualToggle(v => v === null ? !expanded : !v)}
65
108
  className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/50 transition-colors rounded-md"
66
109
  >
67
110
  {expanded ? <ChevronDown size={12} className="shrink-0 text-muted-foreground" /> : <ChevronRight size={12} className="shrink-0 text-muted-foreground" />}
68
111
  {isDestructive && <AlertTriangle size={11} className="shrink-0 text-[var(--amber)]" />}
69
112
  <span>{icon}</span>
70
113
  <span className={`font-medium ${isDestructive ? 'text-[var(--amber)]' : 'text-foreground'}`}>{part.toolName}</span>
71
- <span className="text-muted-foreground truncate flex-1">({inputSummary})</span>
114
+ <span className="text-muted-foreground truncate flex-1">{headerLabel}</span>
72
115
  <span className="shrink-0 ml-auto">
73
116
  {part.state === 'pending' || part.state === 'running' ? (
74
117
  <Loader2 size={12} className="animate-spin text-[var(--amber)]" />
@@ -80,15 +123,56 @@ export default function ToolCallBlock({ part }: { part: ToolCallPart }) {
80
123
  </span>
81
124
  </button>
82
125
  {expanded && (
83
- <div className="px-2 pb-2 pt-0.5 border-t border-border/30 space-y-1">
84
- <div className="text-muted-foreground">
85
- <span className="font-semibold">Input: </span>
86
- <span className="break-all whitespace-pre-wrap">{JSON.stringify(part.input, null, 2)}</span>
87
- </div>
88
- {part.output !== undefined && (
89
- <div className="text-muted-foreground">
90
- <span className="font-semibold">Output: </span>
91
- <span className="break-all whitespace-pre-wrap">{truncateOutput(part.output)}</span>
126
+ <div className="border-t border-border/30">
127
+ {/* Diff view for file-mutating tools — only when done and has diff */}
128
+ {hasDiff && isDone && parsed.diffLines.length > 0 ? (
129
+ <div className="max-h-64 overflow-y-auto">
130
+ {parsed.diffLines.map((line, idx) => (
131
+ <div
132
+ key={idx}
133
+ className={`px-2 py-px flex items-start gap-1.5 ${
134
+ line.prefix === '+'
135
+ ? 'bg-success/8'
136
+ : line.prefix === '-'
137
+ ? 'bg-error/8'
138
+ : ''
139
+ }`}
140
+ >
141
+ <span
142
+ className={`select-none w-3 shrink-0 text-right ${
143
+ line.prefix === '+' ? 'text-success' : line.prefix === '-' ? 'text-error' : 'text-muted-foreground/50'
144
+ }`}
145
+ >
146
+ {line.prefix}
147
+ </span>
148
+ <span
149
+ className={`whitespace-pre-wrap break-all flex-1 ${
150
+ line.prefix === '+' ? 'text-success' : line.prefix === '-' ? 'text-error' : 'text-muted-foreground'
151
+ }`}
152
+ >
153
+ {line.text || '\u00A0'}
154
+ </span>
155
+ </div>
156
+ ))}
157
+ </div>
158
+ ) : (
159
+ /* Fallback: show input (always), output when available */
160
+ <div className="px-2 pb-2 pt-1 space-y-1">
161
+ {part.state === 'running' && (
162
+ <div className="text-muted-foreground/60 text-2xs flex items-center gap-1">
163
+ <Loader2 size={10} className="animate-spin" /> Running...
164
+ </div>
165
+ )}
166
+ <div className="text-muted-foreground">
167
+ <span className="font-semibold">Input: </span>
168
+ <span className="break-all whitespace-pre-wrap">{JSON.stringify(part.input, null, 2)}</span>
169
+ </div>
170
+ {part.output !== undefined && part.output !== '' && (
171
+ <div className="text-muted-foreground">
172
+ <span className="font-semibold">Output: </span>
173
+ <span className="break-all whitespace-pre-wrap">{part.output.length > 500 ? part.output.slice(0, 500) + '…' : part.output}</span>
174
+ </div>
175
+ )}
92
176
  </div>
93
177
  )}
94
178
  </div>
@@ -221,7 +221,10 @@ export default function ChangesContentPage({ initialPath = '' }: { initialPath?:
221
221
 
222
222
  {!loading && !error && events.map((event) => {
223
223
  const open = !!expanded[event.id];
224
- const rows = collapseDiffContext(buildLineDiff(event.before ?? '', event.after ?? ''));
224
+ const rawDiff = buildLineDiff(event.before ?? '', event.after ?? '');
225
+ const rows = collapseDiffContext(rawDiff);
226
+ const inserts = rawDiff.filter(r => r.type === 'insert').length;
227
+ const deletes = rawDiff.filter(r => r.type === 'delete').length;
225
228
  return (
226
229
  <div key={event.id} className="rounded-xl border border-border bg-card overflow-hidden">
227
230
  <button
@@ -232,7 +235,16 @@ export default function ChangesContentPage({ initialPath = '' }: { initialPath?:
232
235
  <div className="flex items-start gap-2">
233
236
  <span className="pt-0.5 text-muted-foreground">{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}</span>
234
237
  <div className="min-w-0 flex-1">
235
- <div className="text-sm font-medium text-foreground font-display">{event.summary}</div>
238
+ <div className="flex items-center gap-2">
239
+ <span className="text-sm font-medium text-foreground font-display">{event.summary}</span>
240
+ {(inserts > 0 || deletes > 0) && (
241
+ <span className="text-xs font-mono text-muted-foreground">
242
+ {inserts > 0 && <span className="text-success">+{inserts}</span>}
243
+ {inserts > 0 && deletes > 0 && ' '}
244
+ {deletes > 0 && <span className="text-error">-{deletes}</span>}
245
+ </span>
246
+ )}
247
+ </div>
236
248
  <div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
237
249
  <span
238
250
  className="rounded-md px-2 py-0.5 font-medium"
@@ -261,27 +273,59 @@ export default function ChangesContentPage({ initialPath = '' }: { initialPath?:
261
273
  </div>
262
274
  </button>
263
275
 
264
- {open && (
265
- <div className="border-t border-border bg-background/70">
276
+ {open && (() => {
277
+ let oln = 1;
278
+ let nln = 1;
279
+ return (
280
+ <div className="border-t border-border bg-background/70 max-h-80 overflow-y-auto">
266
281
  {rows.map((row, idx) => {
267
282
  if (row.type === 'gap') {
268
- return <div key={`${event.id}-gap-${idx}`} className="px-3 py-1 text-2xs text-muted-foreground">{t.changes.unchangedLines(row.count)}</div>;
283
+ oln += row.count;
284
+ nln += row.count;
285
+ return (
286
+ <div key={`${event.id}-gap-${idx}`} className="px-3 py-1 text-2xs text-muted-foreground/60 border-y border-border/30 bg-muted/20 text-center">
287
+ {t.changes.unchangedLines(row.count)}
288
+ </div>
289
+ );
269
290
  }
270
291
  const prefix = row.type === 'insert' ? '+' : row.type === 'delete' ? '-' : ' ';
271
- const color = row.type === 'insert'
272
- ? 'var(--success)'
273
- : row.type === 'delete'
274
- ? 'var(--error)'
275
- : 'var(--muted-foreground)';
292
+ const showOld = row.type !== 'insert' ? oln : '';
293
+ const showNew = row.type !== 'delete' ? nln : '';
294
+ if (row.type !== 'insert') oln++;
295
+ if (row.type !== 'delete') nln++;
276
296
  return (
277
- <div key={`${event.id}-${idx}`} className="px-3 py-0.5 text-xs font-mono flex items-start gap-2">
278
- <span style={{ color }} className="select-none w-3">{prefix}</span>
279
- <span style={{ color }} className="whitespace-pre-wrap break-all flex-1">{row.text || ' '}</span>
297
+ <div
298
+ key={`${event.id}-${idx}`}
299
+ className={`flex items-start text-xs font-mono ${
300
+ row.type === 'insert'
301
+ ? 'bg-success/8'
302
+ : row.type === 'delete'
303
+ ? 'bg-error/8'
304
+ : ''
305
+ }`}
306
+ >
307
+ <span className="w-8 shrink-0 text-right pr-1 select-none text-muted-foreground/40 text-2xs leading-5">{showOld}</span>
308
+ <span className="w-8 shrink-0 text-right pr-1 select-none text-muted-foreground/40 text-2xs leading-5">{showNew}</span>
309
+ <span
310
+ className={`w-3 shrink-0 text-center select-none leading-5 ${
311
+ row.type === 'insert' ? 'text-success' : row.type === 'delete' ? 'text-error' : 'text-muted-foreground/30'
312
+ }`}
313
+ >
314
+ {prefix}
315
+ </span>
316
+ <span
317
+ className={`px-1 py-0.5 whitespace-pre-wrap break-all flex-1 ${
318
+ row.type === 'insert' ? 'text-success' : row.type === 'delete' ? 'text-error' : 'text-muted-foreground'
319
+ }`}
320
+ >
321
+ {row.text || '\u00A0'}
322
+ </span>
280
323
  </div>
281
324
  );
282
325
  })}
283
326
  </div>
284
- )}
327
+ );
328
+ })()}
285
329
  </div>
286
330
  );
287
331
  })}
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState } from 'react';
4
4
  import { useLocale } from '@/lib/LocaleContext';
5
- import { useCases, categories, scenarios, type UseCaseCategory, type UseCaseScenario } from './use-cases';
5
+ import { useCases, categories, scenarios, type UseCaseCategory, type UseCaseScenario } from './use-cases.generated';
6
6
  import UseCaseCard from './UseCaseCard';
7
7
 
8
8
  export default function ExploreContent() {
@@ -17,13 +17,9 @@ export default function ExploreContent() {
17
17
  return true;
18
18
  });
19
19
 
20
- /** Type-safe lookup for use case i18n data by id */
20
+ /** Dynamic lookup for use case i18n data by id (works for any number of cases) */
21
21
  const getUseCaseText = (id: string): { title: string; desc: string; prompt: string } | undefined => {
22
- const map: Record<string, { title: string; desc: string; prompt: string }> = {
23
- c1: e.c1, c2: e.c2, c3: e.c3, c4: e.c4, c5: e.c5,
24
- c6: e.c6, c7: e.c7, c8: e.c8, c9: e.c9,
25
- };
26
- return map[id];
22
+ return (e as Record<string, any>)[id] as { title: string; desc: string; prompt: string } | undefined;
27
23
  };
28
24
 
29
25
  return (
@@ -90,6 +86,7 @@ export default function ExploreContent() {
90
86
  <UseCaseCard
91
87
  key={uc.id}
92
88
  icon={uc.icon}
89
+ image={uc.image}
93
90
  title={data.title}
94
91
  description={data.desc}
95
92
  prompt={data.prompt}
@@ -1,20 +1,37 @@
1
1
  'use client';
2
2
 
3
+ import { useState } from 'react';
3
4
  import { openAskModal } from '@/hooks/useAskModal';
4
5
 
5
6
  interface UseCaseCardProps {
6
7
  icon: string;
8
+ image?: string;
7
9
  title: string;
8
10
  description: string;
9
11
  prompt: string;
10
12
  tryItLabel: string;
11
13
  }
12
14
 
13
- export default function UseCaseCard({ icon, title, description, prompt, tryItLabel }: UseCaseCardProps) {
15
+ export default function UseCaseCard({ icon, image, title, description, prompt, tryItLabel }: UseCaseCardProps) {
16
+ const [imgError, setImgError] = useState(false);
17
+
14
18
  return (
15
19
  <div
16
20
  className="group flex flex-col gap-3 p-4 rounded-xl border border-border bg-card transition-all duration-150 hover:border-[var(--amber)]/30 hover:bg-muted/50"
17
21
  >
22
+ {/* Image or emoji fallback */}
23
+ {image && !imgError ? (
24
+ <div className="w-full aspect-[16/9] rounded-lg overflow-hidden bg-muted">
25
+ <img
26
+ src={image}
27
+ alt={title}
28
+ className="w-full h-full object-cover"
29
+ loading="lazy"
30
+ onError={() => setImgError(true)}
31
+ />
32
+ </div>
33
+ ) : null}
34
+
18
35
  <div className="flex items-start gap-3">
19
36
  <span className="text-xl leading-none shrink-0 mt-0.5" suppressHydrationWarning>
20
37
  {icon}