@geminilight/mindos 0.5.43 → 0.5.44

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.
@@ -74,17 +74,19 @@ export async function POST(req: NextRequest) {
74
74
  }
75
75
 
76
76
  export async function DELETE(req: NextRequest) {
77
- let body: { id?: string };
77
+ let body: { id?: string; ids?: string[] };
78
78
  try {
79
79
  body = await req.json();
80
80
  } catch {
81
81
  return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
82
82
  }
83
83
 
84
- const id = body.id;
85
- if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 });
84
+ // Support both single id and bulk ids
85
+ const idsToDelete = body.ids ?? (body.id ? [body.id] : []);
86
+ if (idsToDelete.length === 0) return NextResponse.json({ error: 'id or ids is required' }, { status: 400 });
86
87
 
87
- const sessions = readSessions().filter((s) => s.id !== id);
88
+ const deleteSet = new Set(idsToDelete);
89
+ const sessions = readSessions().filter((s) => !deleteSet.has(s.id));
88
90
  writeSessions(sessions);
89
91
  return NextResponse.json({ ok: true });
90
92
  }
@@ -0,0 +1,19 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import { readFileSync } from 'fs';
4
+ import { resolve } from 'path';
5
+ import { homedir } from 'os';
6
+
7
+ const STATUS_PATH = resolve(homedir(), '.mindos', 'update-status.json');
8
+
9
+ const IDLE_RESPONSE = { stage: 'idle', stages: [], error: null, version: null, startedAt: null };
10
+
11
+ export async function GET() {
12
+ try {
13
+ const raw = readFileSync(STATUS_PATH, 'utf-8');
14
+ const data = JSON.parse(raw);
15
+ return NextResponse.json(data);
16
+ } catch {
17
+ return NextResponse.json(IDLE_RESPONSE);
18
+ }
19
+ }
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
- import { Folder, Loader2, X } from 'lucide-react';
5
+ import { Folder, Loader2, X, Sparkles, AlertTriangle } from 'lucide-react';
6
6
  import { useRouter } from 'next/navigation';
7
7
  import { useLocale } from '@/lib/LocaleContext';
8
8
  import { encodePath } from '@/lib/utils';
9
9
  import { createSpaceAction } from '@/lib/actions';
10
+ import { apiFetch } from '@/lib/api';
10
11
  import DirPicker from './DirPicker';
11
12
 
12
13
  /* ── Create Space Modal ── */
@@ -20,6 +21,8 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
20
21
  const [loading, setLoading] = useState(false);
21
22
  const [error, setError] = useState('');
22
23
  const [nameHint, setNameHint] = useState('');
24
+ const [aiAvailable, setAiAvailable] = useState<boolean | null>(null); // null = loading
25
+ const [useAi, setUseAi] = useState(true);
23
26
  const inputRef = useRef<HTMLInputElement>(null);
24
27
 
25
28
  useEffect(() => {
@@ -33,6 +36,24 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
33
36
  return () => window.removeEventListener('mindos:create-space', handler);
34
37
  }, []);
35
38
 
39
+ // Check AI availability when modal opens
40
+ useEffect(() => {
41
+ if (!open || aiAvailable !== null) return;
42
+ apiFetch<{ ai?: { provider?: string; providers?: Record<string, { apiKey?: string }> } }>('/api/settings')
43
+ .then(data => {
44
+ const provider = data.ai?.provider ?? '';
45
+ const providers = data.ai?.providers ?? {};
46
+ const activeProvider = providers[provider as keyof typeof providers];
47
+ const hasKey = !!(activeProvider?.apiKey);
48
+ setAiAvailable(hasKey);
49
+ setUseAi(hasKey);
50
+ })
51
+ .catch(() => {
52
+ setAiAvailable(false);
53
+ setUseAi(false);
54
+ });
55
+ }, [open, aiAvailable]);
56
+
36
57
  const close = useCallback(() => {
37
58
  setOpen(false);
38
59
  setName('');
@@ -40,6 +61,7 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
40
61
  setParent('');
41
62
  setError('');
42
63
  setNameHint('');
64
+ setAiAvailable(null); // re-check on next open
43
65
  }, []);
44
66
 
45
67
  const validateName = useCallback((val: string) => {
@@ -64,9 +86,26 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
64
86
  setError('');
65
87
  const trimmed = name.trim();
66
88
  const result = await createSpaceAction(trimmed, description, parent);
67
- setLoading(false);
68
89
  if (result.success) {
69
90
  const createdPath = result.path ?? trimmed;
91
+
92
+ // If AI is enabled, trigger AI initialization in background
93
+ if (useAi && aiAvailable) {
94
+ const isZh = document.documentElement.lang === 'zh';
95
+ const prompt = isZh
96
+ ? `初始化新建的心智空间「${trimmed}」,路径为「${createdPath}/」。${description ? `描述:「${description}」。` : ''}请根据空间名称生成有意义的内容:\n1. README.md — 空间用途、结构概览、使用指南\n2. INSTRUCTION.md — AI Agent 在此空间的行为规则\n\n使用可用工具直接写入文件,内容简洁实用。`
97
+ : `Initialize this new Mind Space "${trimmed}" at "${createdPath}/". ${description ? `Description: "${description}". ` : ''}Generate meaningful content for:\n1. README.md — purpose, structure overview, usage guidelines\n2. INSTRUCTION.md — rules for AI agents operating in this space\n\nWrite directly to the files using available tools. Keep content concise and actionable.`;
98
+ // Fire and forget — don't block navigation
99
+ apiFetch('/api/ask', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ messages: [{ role: 'user', content: prompt }],
104
+ targetDir: createdPath,
105
+ }),
106
+ }).catch(() => { /* AI init is best-effort */ });
107
+ }
108
+
70
109
  close();
71
110
  router.refresh();
72
111
  router.push(`/view/${encodePath(createdPath + '/')}`);
@@ -78,7 +117,8 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
78
117
  setError(t.home.spaceCreateFailed ?? 'Failed to create space');
79
118
  }
80
119
  }
81
- }, [name, description, parent, loading, close, router, t, validateName]);
120
+ setLoading(false);
121
+ }, [name, description, parent, loading, close, router, t, validateName, useAi, aiAvailable]);
82
122
 
83
123
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
84
124
  if (e.key === 'Escape') close();
@@ -87,6 +127,8 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
87
127
 
88
128
  if (!open) return null;
89
129
 
130
+ const h = t.home;
131
+
90
132
  return createPortal(
91
133
  <div className="fixed inset-0 z-50 flex items-center justify-center" onKeyDown={handleKeyDown}>
92
134
  {/* Backdrop */}
@@ -95,31 +137,31 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
95
137
  <div
96
138
  role="dialog"
97
139
  aria-modal="true"
98
- aria-label={t.home.newSpace}
140
+ aria-label={h.newSpace}
99
141
  className="relative w-full max-w-md mx-4 rounded-2xl border border-border bg-card shadow-xl"
100
142
  >
101
143
  {/* Header */}
102
144
  <div className="flex items-center justify-between px-5 pt-5 pb-3">
103
- <h3 className="text-sm font-semibold font-display text-foreground">{t.home.newSpace}</h3>
145
+ <h3 className="text-sm font-semibold font-display text-foreground">{h.newSpace}</h3>
104
146
  <button onClick={close} className="p-1 rounded-md text-muted-foreground hover:bg-muted transition-colors">
105
147
  <X size={14} />
106
148
  </button>
107
149
  </div>
108
150
  {/* Body */}
109
151
  <div className="px-5 pb-5 flex flex-col gap-3">
110
- {/* Location — hierarchical browser */}
152
+ {/* Location */}
111
153
  <div className="space-y-1">
112
- <label className="text-xs font-medium text-muted-foreground">{t.home.spaceLocation ?? 'Location'}</label>
154
+ <label className="text-xs font-medium text-muted-foreground">{h.spaceLocation ?? 'Location'}</label>
113
155
  <DirPicker
114
156
  dirPaths={dirPaths}
115
157
  value={parent}
116
158
  onChange={setParent}
117
- rootLabel={t.home.rootLevel ?? 'Root'}
159
+ rootLabel={h.rootLevel ?? 'Root'}
118
160
  />
119
161
  </div>
120
162
  {/* Name */}
121
163
  <div className="space-y-1">
122
- <label className="text-xs font-medium text-muted-foreground">{t.home.spaceName}</label>
164
+ <label className="text-xs font-medium text-muted-foreground">{h.spaceName}</label>
123
165
  <input
124
166
  ref={inputRef}
125
167
  type="text"
@@ -138,17 +180,49 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
138
180
  {/* Description */}
139
181
  <div className="space-y-1">
140
182
  <label className="text-xs font-medium text-muted-foreground">
141
- {t.home.spaceDescription} <span className="opacity-50">({t.home.optional ?? 'optional'})</span>
183
+ {h.spaceDescription} <span className="opacity-50">({h.optional ?? 'optional'})</span>
142
184
  </label>
143
185
  <input
144
186
  type="text"
145
187
  value={description}
146
188
  onChange={e => setDescription(e.target.value)}
147
- placeholder={t.home.spaceDescPlaceholder ?? 'Describe the purpose of this space'}
189
+ placeholder={h.spaceDescPlaceholder ?? 'Describe the purpose of this space'}
148
190
  maxLength={200}
149
191
  className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-background text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring transition-colors"
150
192
  />
151
193
  </div>
194
+ {/* AI initialization toggle */}
195
+ <div className="flex items-start gap-2.5 px-1 py-1">
196
+ <button
197
+ type="button"
198
+ role="switch"
199
+ aria-checked={useAi}
200
+ disabled={!aiAvailable}
201
+ onClick={() => setUseAi(v => !v)}
202
+ className={`relative mt-0.5 inline-flex shrink-0 h-4 w-7 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${
203
+ useAi ? 'bg-amber-500' : 'bg-muted'
204
+ }`}
205
+ >
206
+ <span className={`pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform ${useAi ? 'translate-x-3' : 'translate-x-0'}`} />
207
+ </button>
208
+ <div className="flex-1 min-w-0">
209
+ <div className="flex items-center gap-1.5">
210
+ <Sparkles size={12} className="text-[var(--amber)] shrink-0" />
211
+ <span className="text-xs font-medium text-foreground">{h.aiInit ?? 'AI initialize content'}</span>
212
+ </div>
213
+ {aiAvailable === false && (
214
+ <p className="text-2xs text-muted-foreground mt-0.5 flex items-center gap-1">
215
+ <AlertTriangle size={10} className="text-amber-500 shrink-0" />
216
+ {h.aiInitNoKey ?? 'Configure an API key in Settings → AI to enable'}
217
+ </p>
218
+ )}
219
+ {aiAvailable && useAi && (
220
+ <p className="text-2xs text-muted-foreground mt-0.5">
221
+ {h.aiInitHint ?? 'AI will generate README and INSTRUCTION for this space'}
222
+ </p>
223
+ )}
224
+ </div>
225
+ </div>
152
226
  {/* Path preview */}
153
227
  {fullPathPreview && (
154
228
  <div className="flex items-center gap-1.5 text-xs text-muted-foreground font-mono px-1">
@@ -163,7 +237,7 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
163
237
  onClick={close}
164
238
  className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground transition-colors hover:bg-muted"
165
239
  >
166
- {t.home.cancelCreate}
240
+ {h.cancelCreate}
167
241
  </button>
168
242
  <button
169
243
  onClick={handleCreate}
@@ -171,7 +245,7 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
171
245
  className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-[var(--amber)] text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
172
246
  >
173
247
  {loading && <Loader2 size={14} className="animate-spin" />}
174
- {t.home.createSpace}
248
+ {h.createSpace}
175
249
  </button>
176
250
  </div>
177
251
  </div>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useMemo } from 'react';
4
4
  import { Folder, ChevronDown, ChevronRight } from 'lucide-react';
5
+ import { stripEmoji } from '@/lib/utils';
5
6
 
6
7
  interface DirPickerProps {
7
8
  /** Flat list of all directory paths (e.g. ['Notes', 'Notes/Daily', 'Projects']) */
@@ -48,7 +49,7 @@ export default function DirPicker({ dirPaths, value, onChange, rootLabel = 'Root
48
49
  };
49
50
 
50
51
  const displayLabel = value
51
- ? value.split('/').map(s => s.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || s).join(' / ')
52
+ ? value.split('/').map(s => stripEmoji(s)).join(' / ')
52
53
  : '/ ' + rootLabel;
53
54
 
54
55
  if (!expanded) {
@@ -5,22 +5,26 @@ import { X, Sparkles, FolderOpen, MessageCircle, RefreshCw, Check, ChevronRight
5
5
  import Link from 'next/link';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { openAskModal } from '@/hooks/useAskModal';
8
+ import { extractEmoji, stripEmoji } from '@/lib/utils';
8
9
  import { walkthroughSteps } from './walkthrough/steps';
9
10
  import type { GuideState } from '@/lib/settings';
11
+ import type { SpaceInfo } from '@/app/page';
10
12
 
11
- const DIR_ICONS: Record<string, string> = {
12
- profile: '👤', notes: '📝', connections: '🔗',
13
- workflows: '🔄', resources: '📚', projects: '🚀',
14
- };
15
-
16
- const EMPTY_FILES = ['INSTRUCTION.md', 'README.md', 'CONFIG.json'];
13
+ interface RecentFile {
14
+ path: string;
15
+ mtime: number;
16
+ }
17
17
 
18
18
  interface GuideCardProps {
19
19
  /** Called when user clicks a file/dir to open it in FileView */
20
20
  onNavigate?: (path: string) => void;
21
+ /** Existing spaces for dynamic directory listing */
22
+ spaces?: SpaceInfo[];
23
+ /** Recent files for empty-template fallback */
24
+ recentFiles?: RecentFile[];
21
25
  }
22
26
 
23
- export default function GuideCard({ onNavigate }: GuideCardProps) {
27
+ export default function GuideCard({ onNavigate, spaces = [], recentFiles = [] }: GuideCardProps) {
24
28
  const { t } = useLocale();
25
29
  const g = t.guide;
26
30
 
@@ -228,29 +232,37 @@ export default function GuideCard({ onNavigate }: GuideCardProps) {
228
232
 
229
233
  {isEmptyTemplate ? (
230
234
  <div className="flex flex-col gap-1.5">
231
- {EMPTY_FILES.map(file => (
232
- <button key={file} onClick={() => handleFileOpen(file)}
233
- className="text-left text-xs px-3 py-2 rounded-lg border border-border text-foreground transition-colors hover:border-amber-500/30 hover:bg-muted/50">
234
- 📄 {(g.kb.emptyFiles as Record<string, string>)[file.split('.')[0].toLowerCase()] || file}
235
- </button>
236
- ))}
237
- <p className="text-xs mt-2 text-muted-foreground opacity-70">
238
- {g.kb.emptyHint}
239
- </p>
235
+ {recentFiles.length > 0 ? (
236
+ recentFiles.map(file => {
237
+ const fileName = file.path.split('/').pop() || file.path;
238
+ return (
239
+ <button key={file.path} onClick={() => handleFileOpen(file.path)}
240
+ className="text-left text-xs px-3 py-2 rounded-lg border border-border text-foreground transition-colors hover:border-amber-500/30 hover:bg-muted/50 truncate">
241
+ 📄 {fileName}
242
+ </button>
243
+ );
244
+ })
245
+ ) : (
246
+ <p className="text-xs text-muted-foreground">{g.kb.emptyHint}</p>
247
+ )}
240
248
  </div>
241
249
  ) : (
242
250
  <>
243
251
  <div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
244
- {Object.entries(DIR_ICONS).map(([key, icon]) => (
245
- <button key={key} onClick={() => handleFileOpen(key.charAt(0).toUpperCase() + key.slice(1))}
246
- className="text-left text-xs px-3 py-2 rounded-lg border border-border text-foreground transition-colors hover:border-amber-500/30 hover:bg-muted/50">
247
- <span className="mr-1.5">{icon}</span>
248
- <span className="capitalize">{key}</span>
249
- <span className="block text-2xs mt-0.5 text-muted-foreground">
250
- {(g.kb.dirs as Record<string, string>)[key]}
251
- </span>
252
- </button>
253
- ))}
252
+ {spaces.slice(0, 6).map(s => {
253
+ const emoji = extractEmoji(s.name);
254
+ const label = stripEmoji(s.name);
255
+ return (
256
+ <button key={s.name} onClick={() => handleFileOpen(s.path)}
257
+ className="text-left text-xs px-3 py-2 rounded-lg border border-border text-foreground transition-colors hover:border-amber-500/30 hover:bg-muted/50">
258
+ <span className="mr-1.5">{emoji || '📁'}</span>
259
+ <span>{label}</span>
260
+ <span className="block text-2xs mt-0.5 text-muted-foreground">
261
+ {t.home.nFiles(s.fileCount)}
262
+ </span>
263
+ </button>
264
+ );
265
+ })}
254
266
  </div>
255
267
  <p className="text-xs mt-3 text-[var(--amber)]">
256
268
  💡 {g.kb.instructionHint}
@@ -4,7 +4,7 @@ import Link from 'next/link';
4
4
  import { FileText, Table, Clock, Sparkles, ArrowRight, FilePlus, Search, ChevronDown, Compass, Folder, Puzzle, Brain, Plus } from 'lucide-react';
5
5
  import { useState, useEffect, useMemo } from 'react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
- import { encodePath, relativeTime } from '@/lib/utils';
7
+ import { encodePath, relativeTime, extractEmoji, stripEmoji } from '@/lib/utils';
8
8
  import { getAllRenderers } from '@/lib/renderers/registry';
9
9
  import '@/lib/renderers/index'; // registers all renderers
10
10
  import OnboardingView from './OnboardingView';
@@ -65,17 +65,6 @@ function groupBySpace(recent: RecentFile[], spaces: SpaceInfo[]): { groups: Spac
65
65
  return { groups, rootFiles };
66
66
  }
67
67
 
68
- /** Extract leading emoji from a directory name, e.g. "📝 Notes" → "📝" */
69
- function extractEmoji(name: string): string {
70
- const match = name.match(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+/u);
71
- return match?.[0] ?? '';
72
- }
73
-
74
- /** Strip leading emoji+space from name for display, e.g. "📝 Notes" → "Notes" */
75
- function stripEmoji(name: string): string {
76
- return name.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || name;
77
- }
78
-
79
68
  /* ── Section Title component (shared across all three sections) ── */
80
69
  interface SectionTitleProps {
81
70
  icon: React.ReactNode;
@@ -146,7 +135,11 @@ export default function HomeContent({ recent, existingFiles, spaces, dirPaths }:
146
135
 
147
136
  return (
148
137
  <div className="content-width px-4 md:px-6 py-8 md:py-12">
149
- <GuideCard onNavigate={(path) => { window.location.href = `/view/${encodeURIComponent(path)}`; }} />
138
+ <GuideCard
139
+ onNavigate={(path) => { window.location.href = `/view/${encodeURIComponent(path)}`; }}
140
+ spaces={spaceList}
141
+ recentFiles={recent.slice(0, 5)}
142
+ />
150
143
 
151
144
  {/* ── Hero ── */}
152
145
  <div className="mb-10">
@@ -267,7 +267,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
267
267
  </span>
268
268
  </div>
269
269
  <div className="flex items-center gap-1">
270
- <button type="button" onClick={() => setShowHistory(v => !v)} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" title="Session history">
270
+ <button type="button" onClick={() => setShowHistory(v => !v)} aria-pressed={showHistory} className={`p-1 rounded transition-colors ${showHistory ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`} title="Session history">
271
271
  <History size={iconSize} />
272
272
  </button>
273
273
  <button type="button" onClick={handleResetSession} disabled={isLoading} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40" title="New session">
@@ -284,7 +284,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
284
284
  </button>
285
285
  )}
286
286
  {onClose && (
287
- <button onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" aria-label="Close">
287
+ <button type="button" onClick={onClose} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors" aria-label="Close">
288
288
  <X size={isPanel ? iconSize : 15} />
289
289
  </button>
290
290
  )}
@@ -297,6 +297,13 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
297
297
  activeSessionId={session.activeSessionId}
298
298
  onLoad={handleLoadSession}
299
299
  onDelete={session.deleteSession}
300
+ onClearAll={session.clearAllSessions}
301
+ labels={{
302
+ title: t.ask.sessionHistory ?? 'Session History',
303
+ clearAll: t.ask.clearAll ?? 'Clear all',
304
+ confirmClear: t.ask.confirmClear ?? 'Confirm clear?',
305
+ noSessions: t.ask.noSessions ?? 'No saved sessions.',
306
+ }}
300
307
  />
301
308
  )}
302
309
 
@@ -400,7 +407,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
400
407
  <StopCircle size={inputIconSize} />
401
408
  </button>
402
409
  ) : (
403
- <button type="submit" disabled={!input.trim()} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0" style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}>
410
+ <button type="submit" disabled={!input.trim() || mention.mentionQuery !== null} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0 bg-[var(--amber)] text-[var(--amber-foreground)]">
404
411
  <Send size={isPanel ? 13 : 14} />
405
412
  </button>
406
413
  )}
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { useState, useRef, useEffect } from 'react';
3
4
  import { Trash2 } from 'lucide-react';
4
5
  import type { ChatSession } from '@/lib/types';
5
6
  import { sessionTitle } from '@/hooks/useAskSession';
@@ -9,15 +10,50 @@ interface SessionHistoryProps {
9
10
  activeSessionId: string | null;
10
11
  onLoad: (id: string) => void;
11
12
  onDelete: (id: string) => void;
13
+ onClearAll: () => void;
14
+ labels: { title: string; clearAll: string; confirmClear: string; noSessions: string };
12
15
  }
13
16
 
14
- export default function SessionHistory({ sessions, activeSessionId, onLoad, onDelete }: SessionHistoryProps) {
17
+ export default function SessionHistory({ sessions, activeSessionId, onLoad, onDelete, onClearAll, labels }: SessionHistoryProps) {
18
+ const [confirmClearAll, setConfirmClearAll] = useState(false);
19
+ const clearTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
20
+
21
+ // Cleanup timer on unmount
22
+ useEffect(() => () => { if (clearTimerRef.current) clearTimeout(clearTimerRef.current); }, []);
23
+
24
+ const handleClearAll = () => {
25
+ if (!confirmClearAll) {
26
+ setConfirmClearAll(true);
27
+ if (clearTimerRef.current) clearTimeout(clearTimerRef.current);
28
+ clearTimerRef.current = setTimeout(() => setConfirmClearAll(false), 3000);
29
+ return;
30
+ }
31
+ if (clearTimerRef.current) clearTimeout(clearTimerRef.current);
32
+ onClearAll();
33
+ setConfirmClearAll(false);
34
+ };
35
+
15
36
  return (
16
37
  <div className="border-b border-border px-4 py-2.5 max-h-[190px] overflow-y-auto">
17
- <div className="text-xs text-muted-foreground mb-2">Session History</div>
38
+ <div className="flex items-center justify-between mb-2">
39
+ <span className="text-xs text-muted-foreground">{labels.title}</span>
40
+ {sessions.length > 1 && (
41
+ <button
42
+ type="button"
43
+ onClick={handleClearAll}
44
+ className={`text-2xs px-1.5 py-0.5 rounded transition-colors ${
45
+ confirmClearAll
46
+ ? 'bg-error/10 text-error font-medium'
47
+ : 'text-muted-foreground hover:text-error hover:bg-muted'
48
+ }`}
49
+ >
50
+ {confirmClearAll ? labels.confirmClear : labels.clearAll}
51
+ </button>
52
+ )}
53
+ </div>
18
54
  <div className="flex flex-col gap-1.5">
19
55
  {sessions.length === 0 && (
20
- <div className="text-xs text-muted-foreground/70">No saved sessions.</div>
56
+ <div className="text-xs text-muted-foreground/70">{labels.noSessions}</div>
21
57
  )}
22
58
  {sessions.map((s) => (
23
59
  <div key={s.id} className="flex items-center gap-1.5">
@@ -2,17 +2,20 @@
2
2
 
3
3
  import { useState } from 'react';
4
4
  import { useLocale } from '@/lib/LocaleContext';
5
- import { useCases, categories, type UseCaseCategory } from './use-cases';
5
+ import { useCases, categories, scenarios, type UseCaseCategory, type UseCaseScenario } from './use-cases';
6
6
  import UseCaseCard from './UseCaseCard';
7
7
 
8
8
  export default function ExploreContent() {
9
9
  const { t } = useLocale();
10
10
  const e = t.explore;
11
11
  const [activeCategory, setActiveCategory] = useState<UseCaseCategory | 'all'>('all');
12
+ const [activeScenario, setActiveScenario] = useState<UseCaseScenario | 'all'>('all');
12
13
 
13
- const filtered = activeCategory === 'all'
14
- ? useCases
15
- : useCases.filter(uc => uc.category === activeCategory);
14
+ const filtered = useCases.filter(uc => {
15
+ if (activeCategory !== 'all' && uc.category !== activeCategory) return false;
16
+ if (activeScenario !== 'all' && uc.scenario !== activeScenario) return false;
17
+ return true;
18
+ });
16
19
 
17
20
  /** Type-safe lookup for use case i18n data by id */
18
21
  const getUseCaseText = (id: string): { title: string; desc: string; prompt: string } | undefined => {
@@ -44,21 +47,42 @@ export default function ExploreContent() {
44
47
  </p>
45
48
  </div>
46
49
 
47
- {/* Category tabs */}
48
- <div className="flex flex-wrap gap-2 mb-6" style={{ paddingLeft: '1rem' }}>
49
- <CategoryChip
50
- label={e.all}
51
- active={activeCategory === 'all'}
52
- onClick={() => setActiveCategory('all')}
53
- />
54
- {categories.map(cat => (
55
- <CategoryChip
56
- key={cat}
57
- label={(e.categories as Record<string, string>)[cat]}
58
- active={activeCategory === cat}
59
- onClick={() => setActiveCategory(cat)}
50
+ {/* Dual-axis filter */}
51
+ <div className="space-y-3 mb-6" style={{ paddingLeft: '1rem' }}>
52
+ {/* Capability axis */}
53
+ <div className="flex flex-wrap items-center gap-2">
54
+ <span className="text-2xs text-muted-foreground uppercase tracking-wider font-medium w-16 shrink-0">{e.byCapability}</span>
55
+ <FilterChip
56
+ label={e.all}
57
+ active={activeCategory === 'all'}
58
+ onClick={() => setActiveCategory('all')}
60
59
  />
61
- ))}
60
+ {categories.map(cat => (
61
+ <FilterChip
62
+ key={cat}
63
+ label={(e.categories as Record<string, string>)[cat]}
64
+ active={activeCategory === cat}
65
+ onClick={() => setActiveCategory(cat)}
66
+ />
67
+ ))}
68
+ </div>
69
+ {/* Scenario axis */}
70
+ <div className="flex flex-wrap items-center gap-2">
71
+ <span className="text-2xs text-muted-foreground uppercase tracking-wider font-medium w-16 shrink-0">{e.byScenario}</span>
72
+ <FilterChip
73
+ label={e.all}
74
+ active={activeScenario === 'all'}
75
+ onClick={() => setActiveScenario('all')}
76
+ />
77
+ {scenarios.map(sc => (
78
+ <FilterChip
79
+ key={sc}
80
+ label={(e.scenarios as Record<string, string>)[sc]}
81
+ active={activeScenario === sc}
82
+ onClick={() => setActiveScenario(sc)}
83
+ />
84
+ ))}
85
+ </div>
62
86
  </div>
63
87
 
64
88
  {/* Card grid */}
@@ -78,11 +102,18 @@ export default function ExploreContent() {
78
102
  );
79
103
  })}
80
104
  </div>
105
+
106
+ {/* Empty state */}
107
+ {filtered.length === 0 && (
108
+ <p className="text-sm text-muted-foreground text-center py-12" style={{ paddingLeft: '1rem' }}>
109
+ No use cases match the current filters.
110
+ </p>
111
+ )}
81
112
  </div>
82
113
  );
83
114
  }
84
115
 
85
- function CategoryChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
116
+ function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
86
117
  return (
87
118
  <button
88
119
  onClick={onClick}