@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.
- package/app/app/api/ask-sessions/route.ts +6 -4
- package/app/app/api/update-status/route.ts +19 -0
- package/app/components/CreateSpaceModal.tsx +87 -13
- package/app/components/DirPicker.tsx +2 -1
- package/app/components/GuideCard.tsx +38 -26
- package/app/components/HomeContent.tsx +6 -13
- package/app/components/ask/AskContent.tsx +10 -3
- package/app/components/ask/SessionHistory.tsx +39 -3
- package/app/components/explore/ExploreContent.tsx +50 -19
- package/app/components/explore/use-cases.ts +41 -13
- package/app/components/panels/DiscoverPanel.tsx +89 -99
- package/app/components/settings/SettingsContent.tsx +1 -1
- package/app/components/settings/UpdateTab.tsx +145 -28
- package/app/hooks/useAskSession.ts +24 -0
- package/app/lib/i18n-en.ts +25 -4
- package/app/lib/i18n-zh.ts +25 -4
- package/app/lib/utils.ts +11 -0
- package/bin/cli.js +87 -15
- package/bin/lib/startup.js +4 -0
- package/bin/lib/update-status.js +115 -0
- package/package.json +1 -1
- package/scripts/fix-postcss-deps.cjs +4 -2
|
@@ -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
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
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">{
|
|
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
|
|
152
|
+
{/* Location */}
|
|
111
153
|
<div className="space-y-1">
|
|
112
|
-
<label className="text-xs font-medium text-muted-foreground">{
|
|
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={
|
|
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">{
|
|
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
|
-
{
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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 =>
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
{
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
{
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
{
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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=
|
|
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
|
|
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="
|
|
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">
|
|
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 =
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
{/*
|
|
48
|
-
<div className="
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
116
|
+
function FilterChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
|
86
117
|
return (
|
|
87
118
|
<button
|
|
88
119
|
onClick={onClick}
|