@geminilight/mindos 0.5.18 → 0.5.19

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.
@@ -57,11 +57,12 @@ function convertToModelMessages(messages: FrontendMessage[]): ModelMessage[] {
57
57
  type: 'tool-call',
58
58
  toolCallId: part.toolCallId,
59
59
  toolName: part.toolName,
60
- input: part.input,
60
+ input: part.input ?? {},
61
61
  });
62
- if (part.state === 'done' || part.state === 'error') {
63
- completedToolCalls.push(part);
64
- }
62
+ // Always emit a tool result for every tool call. Orphaned tool calls
63
+ // (running/pending from interrupted streams) get an empty result;
64
+ // without one the API rejects the request.
65
+ completedToolCalls.push(part);
65
66
  }
66
67
  // 'reasoning' parts are display-only; not sent back to model
67
68
  }
@@ -30,6 +30,7 @@ export async function GET() {
30
30
  openaiApiKey: maskApiKey(s.ai.providers.openai.apiKey),
31
31
  openaiModel: s.ai.providers.openai.model,
32
32
  openaiBaseUrl: s.ai.providers.openai.baseUrl ?? '',
33
+ guideState: s.guideState ?? null,
33
34
  });
34
35
  } catch (e) {
35
36
  return NextResponse.json(
@@ -95,9 +96,33 @@ export async function POST(req: NextRequest) {
95
96
  );
96
97
 
97
98
  // Build config
99
+ // Merge AI config: empty apiKey means "keep existing" — never overwrite a
100
+ // configured key with blank just because the user didn't re-enter it.
101
+ let mergedAi = current.ai;
102
+ if (ai) {
103
+ const inAnthropicKey = ai.providers?.anthropic?.apiKey;
104
+ const inOpenaiKey = ai.providers?.openai?.apiKey;
105
+ mergedAi = {
106
+ provider: ai.provider ?? current.ai.provider,
107
+ providers: {
108
+ anthropic: {
109
+ apiKey: inAnthropicKey || current.ai.providers.anthropic.apiKey,
110
+ model: ai.providers?.anthropic?.model || current.ai.providers.anthropic.model,
111
+ },
112
+ openai: {
113
+ apiKey: inOpenaiKey || current.ai.providers.openai.apiKey,
114
+ model: ai.providers?.openai?.model || current.ai.providers.openai.model,
115
+ baseUrl: ai.providers?.openai?.baseUrl ?? current.ai.providers.openai.baseUrl ?? '',
116
+ },
117
+ },
118
+ };
119
+ }
120
+
98
121
  const disabledSkills = body.template === 'zh' ? ['mindos'] : ['mindos-zh'];
122
+ // Determine guide template from setup template
123
+ const guideTemplate = body.template === 'zh' ? 'zh' : body.template === 'empty' ? 'empty' : 'en';
99
124
  const config: ServerSettings = {
100
- ai: ai ?? current.ai,
125
+ ai: mergedAi,
101
126
  mindRoot: resolvedRoot,
102
127
  port: webPort,
103
128
  mcpPort: mcpPortNum,
@@ -106,6 +131,14 @@ export async function POST(req: NextRequest) {
106
131
  startMode: current.startMode,
107
132
  setupPending: false, // clear the flag
108
133
  disabledSkills,
134
+ guideState: {
135
+ active: true,
136
+ dismissed: false,
137
+ template: guideTemplate as 'en' | 'zh' | 'empty',
138
+ step1Done: false,
139
+ askedAI: false,
140
+ nextStepIndex: 0,
141
+ },
109
142
  };
110
143
 
111
144
  writeSettings(config);
@@ -124,3 +157,33 @@ export async function POST(req: NextRequest) {
124
157
  );
125
158
  }
126
159
  }
160
+
161
+ export async function PATCH(req: NextRequest) {
162
+ try {
163
+ const body = await req.json();
164
+ const { guideState: patch } = body;
165
+ if (!patch || typeof patch !== 'object') {
166
+ return NextResponse.json({ error: 'guideState object required' }, { status: 400 });
167
+ }
168
+ const current = readSettings();
169
+ const existing = current.guideState ?? {
170
+ active: false, dismissed: false, template: 'en' as const,
171
+ step1Done: false, askedAI: false, nextStepIndex: 0,
172
+ };
173
+ // Merge only known fields
174
+ const updated = { ...existing };
175
+ if (typeof patch.dismissed === 'boolean') updated.dismissed = patch.dismissed;
176
+ if (typeof patch.step1Done === 'boolean') updated.step1Done = patch.step1Done;
177
+ if (typeof patch.askedAI === 'boolean') updated.askedAI = patch.askedAI;
178
+ if (typeof patch.nextStepIndex === 'number' && patch.nextStepIndex >= 0) updated.nextStepIndex = patch.nextStepIndex;
179
+ if (typeof patch.active === 'boolean') updated.active = patch.active;
180
+
181
+ writeSettings({ ...current, guideState: updated });
182
+ return NextResponse.json({ ok: true, guideState: updated });
183
+ } catch (e) {
184
+ return NextResponse.json(
185
+ { error: e instanceof Error ? e.message : String(e) },
186
+ { status: 500 },
187
+ );
188
+ }
189
+ }
@@ -288,6 +288,7 @@ body {
288
288
  /* Micro type scale: text-2xs = 10px (between nothing and text-xs 12px) */
289
289
  @layer utilities {
290
290
  .text-2xs { font-size: 10px; line-height: 1.4; }
291
+ .animate-spin-slow { animation: spin 3s linear infinite; }
291
292
  }
292
293
 
293
294
  /* Hide scrollbar but keep scroll functionality */
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ import { useState, useEffect, useCallback } from 'react';
4
4
  import { usePathname } from 'next/navigation';
5
5
  import { Sparkles } from 'lucide-react';
6
6
  import AskModal from './AskModal';
7
+ import { useAskModal } from '@/hooks/useAskModal';
7
8
 
8
9
  export default function AskFab() {
9
10
  const [open, setOpen] = useState(false);
@@ -12,10 +13,49 @@ export default function AskFab() {
12
13
  ? pathname.slice('/view/'.length).split('/').map(decodeURIComponent).join('/')
13
14
  : undefined;
14
15
 
16
+ // Listen to useAskModal store for cross-component open requests (e.g. from GuideCard)
17
+ const askModal = useAskModal();
18
+ const [initialMessage, setInitialMessage] = useState('');
19
+ const [openSource, setOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
20
+
21
+ useEffect(() => {
22
+ if (askModal.open) {
23
+ setInitialMessage(askModal.initialMessage);
24
+ setOpenSource(askModal.source);
25
+ setOpen(true);
26
+ askModal.close(); // Reset store state after consuming
27
+ }
28
+ }, [askModal.open, askModal.initialMessage, askModal.source, askModal.close]);
29
+
30
+ const handleClose = useCallback(() => {
31
+ setOpen(false);
32
+ setInitialMessage('');
33
+ setOpenSource('user');
34
+ }, []);
35
+
36
+ // Dispatch correct PATCH based on how the modal was opened
37
+ const handleFirstMessage = useCallback(() => {
38
+ const notifyGuide = () => window.dispatchEvent(new Event('guide-state-updated'));
39
+
40
+ if (openSource === 'guide') {
41
+ // Task ② completion: mark askedAI
42
+ fetch('/api/setup', {
43
+ method: 'PATCH',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ guideState: { askedAI: true } }),
46
+ }).then(notifyGuide).catch(() => {});
47
+ } else if (openSource === 'guide-next') {
48
+ // Next-step advancement: GuideCard already PATCHed nextStepIndex optimistically.
49
+ // Just notify GuideCard to re-fetch for consistency; no additional PATCH needed.
50
+ notifyGuide();
51
+ }
52
+ // For 'user' source: no guide action needed
53
+ }, [openSource]);
54
+
15
55
  return (
16
56
  <>
17
57
  <button
18
- onClick={() => setOpen(true)}
58
+ onClick={() => { setInitialMessage(''); setOpenSource('user'); setOpen(true); }}
19
59
  className="
20
60
  group
21
61
  fixed z-40
@@ -53,7 +93,13 @@ export default function AskFab() {
53
93
  </span>
54
94
  </button>
55
95
 
56
- <AskModal open={open} onClose={() => setOpen(false)} currentFile={currentFile} />
96
+ <AskModal
97
+ open={open}
98
+ onClose={handleClose}
99
+ currentFile={currentFile}
100
+ initialMessage={initialMessage}
101
+ onFirstMessage={handleFirstMessage}
102
+ />
57
103
  </>
58
104
  );
59
105
  }
@@ -17,11 +17,14 @@ interface AskModalProps {
17
17
  open: boolean;
18
18
  onClose: () => void;
19
19
  currentFile?: string;
20
+ initialMessage?: string;
21
+ onFirstMessage?: () => void;
20
22
  }
21
23
 
22
- export default function AskModal({ open, onClose, currentFile }: AskModalProps) {
24
+ export default function AskModal({ open, onClose, currentFile, initialMessage, onFirstMessage }: AskModalProps) {
23
25
  const inputRef = useRef<HTMLInputElement>(null);
24
26
  const abortRef = useRef<AbortController | null>(null);
27
+ const firstMessageFired = useRef(false);
25
28
  const { t } = useLocale();
26
29
 
27
30
  const [input, setInput] = useState('');
@@ -43,7 +46,8 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
43
46
  if (cancelled) return;
44
47
  await session.initSessions();
45
48
  })();
46
- setInput('');
49
+ setInput(initialMessage || '');
50
+ firstMessageFired.current = false;
47
51
  setAttachedFiles(currentFile ? [currentFile] : []);
48
52
  upload.clearAttachments();
49
53
  mention.resetMention();
@@ -119,6 +123,11 @@ export default function AskModal({ open, onClose, currentFile }: AskModalProps)
119
123
  const requestMessages = [...session.messages, userMsg];
120
124
  session.setMessages([...requestMessages, { role: 'assistant', content: '' }]);
121
125
  setInput('');
126
+ // Notify guide card on first user message (ref prevents duplicate fires during re-render)
127
+ if (onFirstMessage && !firstMessageFired.current) {
128
+ firstMessageFired.current = true;
129
+ onFirstMessage();
130
+ }
122
131
  setAttachedFiles(currentFile ? [currentFile] : []);
123
132
  setIsLoading(true);
124
133
  setLoadingPhase('connecting');
@@ -0,0 +1,361 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import { X, Sparkles, FolderOpen, MessageCircle, RefreshCw, Check, ChevronRight } from 'lucide-react';
5
+ import { useLocale } from '@/lib/LocaleContext';
6
+ import { openAskModal } from '@/hooks/useAskModal';
7
+ import type { GuideState } from '@/lib/settings';
8
+
9
+ const DIR_ICONS: Record<string, string> = {
10
+ profile: '👤', notes: '📝', connections: '🔗',
11
+ workflows: '🔄', resources: '📚', projects: '🚀',
12
+ };
13
+
14
+ const EMPTY_FILES = ['INSTRUCTION.md', 'README.md', 'CONFIG.json'];
15
+
16
+ interface GuideCardProps {
17
+ /** Called when user clicks a file/dir to open it in FileView */
18
+ onNavigate?: (path: string) => void;
19
+ }
20
+
21
+ export default function GuideCard({ onNavigate }: GuideCardProps) {
22
+ const { t } = useLocale();
23
+ const g = t.guide;
24
+
25
+ const [guideState, setGuideState] = useState<GuideState | null>(null);
26
+ const [expanded, setExpanded] = useState<'kb' | 'ai' | 'sync' | null>(null);
27
+ const [isFirstVisit, setIsFirstVisit] = useState(false);
28
+ const [browsedCount, setBrowsedCount] = useState(0);
29
+
30
+ // Fetch guide state from backend
31
+ const fetchGuideState = useCallback(() => {
32
+ fetch('/api/setup')
33
+ .then(r => r.json())
34
+ .then(data => {
35
+ const gs = data.guideState;
36
+ if (gs?.active && !gs.dismissed) {
37
+ setGuideState(gs);
38
+ if (gs.step1Done) setBrowsedCount(1);
39
+ } else {
40
+ // Guide inactive or dismissed — clear local state
41
+ setGuideState(null);
42
+ }
43
+ })
44
+ .catch(() => {});
45
+ }, []);
46
+
47
+ useEffect(() => {
48
+ fetchGuideState();
49
+
50
+ // ?welcome=1 → first visit, auto-expand explore task
51
+ const params = new URLSearchParams(window.location.search);
52
+ if (params.get('welcome') === '1') {
53
+ setIsFirstVisit(true);
54
+ const url = new URL(window.location.href);
55
+ url.searchParams.delete('welcome');
56
+ window.history.replaceState({}, '', url.pathname + (url.search || ''));
57
+ }
58
+
59
+ // Re-fetch when guide state is updated (e.g. after AskFab patches askedAI)
60
+ const handleGuideUpdate = () => fetchGuideState();
61
+ window.addEventListener('focus', handleGuideUpdate);
62
+ window.addEventListener('guide-state-updated', handleGuideUpdate);
63
+ return () => {
64
+ window.removeEventListener('focus', handleGuideUpdate);
65
+ window.removeEventListener('guide-state-updated', handleGuideUpdate);
66
+ };
67
+ }, [fetchGuideState]);
68
+
69
+ // Auto-expand KB task on first visit
70
+ useEffect(() => {
71
+ if (isFirstVisit && guideState && !guideState.step1Done) {
72
+ setExpanded('kb');
73
+ }
74
+ }, [isFirstVisit, guideState]);
75
+
76
+ // Patch guideState to backend
77
+ const patchGuide = useCallback((patch: Partial<GuideState>) => {
78
+ setGuideState(prev => prev ? { ...prev, ...patch } : prev);
79
+ fetch('/api/setup', {
80
+ method: 'PATCH',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ guideState: patch }),
83
+ }).catch(() => {});
84
+ }, []);
85
+
86
+ const handleDismiss = useCallback(() => {
87
+ patchGuide({ dismissed: true });
88
+ setGuideState(null);
89
+ }, [patchGuide]);
90
+
91
+ const handleFileOpen = useCallback((path: string) => {
92
+ onNavigate?.(path);
93
+ if (browsedCount === 0) {
94
+ setBrowsedCount(1);
95
+ patchGuide({ step1Done: true });
96
+ // Collapse after a beat
97
+ setTimeout(() => setExpanded(null), 300);
98
+ }
99
+ }, [browsedCount, patchGuide, onNavigate]);
100
+
101
+ const handleSkipKB = useCallback(() => {
102
+ setBrowsedCount(1);
103
+ patchGuide({ step1Done: true });
104
+ setExpanded(null);
105
+ }, [patchGuide]);
106
+
107
+ const handleStartAI = useCallback(() => {
108
+ const gs = guideState;
109
+ const isEmpty = gs?.template === 'empty';
110
+ const prompt = isEmpty ? g.ai.promptEmpty : g.ai.prompt;
111
+ openAskModal(prompt, 'guide');
112
+ // Don't optimistically set askedAI here — wait until user actually sends a message
113
+ // AskFab.onFirstMessage will PATCH askedAI:true
114
+ }, [guideState, g]);
115
+
116
+ const handleNextStepClick = useCallback(() => {
117
+ if (!guideState) return;
118
+ const idx = guideState.nextStepIndex;
119
+ const steps = g.done.steps;
120
+ if (idx < steps.length) {
121
+ openAskModal(steps[idx].prompt, 'guide-next');
122
+ // Optimistic local update — AskFab will persist to backend on first message
123
+ patchGuide({ nextStepIndex: idx + 1 });
124
+ }
125
+ }, [guideState, g, patchGuide]);
126
+
127
+ const handleSyncClick = useCallback(() => {
128
+ // Dispatch ⌘, to open Settings modal
129
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', metaKey: true, bubbles: true }));
130
+ }, []);
131
+
132
+ // Auto-dismiss final state after 8 seconds
133
+ const autoDismissRef = useRef<ReturnType<typeof setTimeout>>(null);
134
+ const step1Done_ = guideState?.step1Done;
135
+ const step2Done_ = guideState?.askedAI;
136
+ const nextIdx_ = guideState?.nextStepIndex ?? 0;
137
+ const allDone_ = step1Done_ && step2Done_;
138
+ const allNextDone_ = allDone_ && nextIdx_ >= g.done.steps.length;
139
+
140
+ useEffect(() => {
141
+ if (allNextDone_) {
142
+ autoDismissRef.current = setTimeout(() => handleDismiss(), 8000);
143
+ }
144
+ return () => { if (autoDismissRef.current) clearTimeout(autoDismissRef.current); };
145
+ }, [allNextDone_, handleDismiss]);
146
+
147
+ // Don't render if no active guide
148
+ if (!guideState) return null;
149
+
150
+ const step1Done = guideState.step1Done;
151
+ const step2Done = guideState.askedAI;
152
+ const allDone = step1Done && step2Done;
153
+ const nextIdx = guideState.nextStepIndex;
154
+ const nextSteps = g.done.steps;
155
+ const allNextDone = nextIdx >= nextSteps.length;
156
+ const isEmptyTemplate = guideState.template === 'empty';
157
+
158
+ // After all next-steps done → final state (auto-dismisses after 8s)
159
+ if (allDone && allNextDone) {
160
+ return (
161
+ <div className="mb-6 rounded-xl border px-5 py-4 flex items-center gap-3 animate-in fade-in duration-300"
162
+ style={{ background: 'var(--amber-subtle, rgba(200,135,30,0.08))', borderColor: 'var(--amber)' }}>
163
+ <Sparkles size={16} className="animate-spin-slow" style={{ color: 'var(--amber)' }} />
164
+ <span className="text-sm font-semibold flex-1" style={{ color: 'var(--foreground)' }}>
165
+ ✨ {g.done.titleFinal}
166
+ </span>
167
+ <button onClick={handleDismiss} className="p-1 rounded hover:bg-muted transition-colors"
168
+ style={{ color: 'var(--muted-foreground)' }}>
169
+ <X size={14} />
170
+ </button>
171
+ </div>
172
+ );
173
+ }
174
+
175
+ // Collapsed done state with next-step prompts
176
+ if (allDone) {
177
+ const step = nextSteps[nextIdx];
178
+ return (
179
+ <div className="mb-6 rounded-xl border px-5 py-4 animate-in fade-in duration-300"
180
+ style={{ background: 'var(--amber-subtle, rgba(200,135,30,0.08))', borderColor: 'var(--amber)' }}>
181
+ <div className="flex items-center gap-3">
182
+ <Sparkles size={16} style={{ color: 'var(--amber)' }} />
183
+ <span className="text-sm font-semibold flex-1" style={{ color: 'var(--foreground)' }}>
184
+ 🎉 {g.done.title}
185
+ </span>
186
+ <button onClick={handleDismiss} className="p-1 rounded hover:bg-muted transition-colors"
187
+ style={{ color: 'var(--muted-foreground)' }}>
188
+ <X size={14} />
189
+ </button>
190
+ </div>
191
+ {step && (
192
+ <button
193
+ onClick={handleNextStepClick}
194
+ className="mt-3 flex items-center gap-2 text-sm transition-colors hover:opacity-80 cursor-pointer animate-in fade-in slide-in-from-left-2 duration-300"
195
+ style={{ color: 'var(--amber)' }}
196
+ >
197
+ <ChevronRight size={14} />
198
+ <span>{step.hint}</span>
199
+ </button>
200
+ )}
201
+ </div>
202
+ );
203
+ }
204
+
205
+ // Main guide card with 3 tasks
206
+ return (
207
+ <div className="mb-6 rounded-xl border overflow-hidden"
208
+ style={{ background: 'var(--amber-subtle, rgba(200,135,30,0.08))', borderColor: 'var(--amber)' }}>
209
+
210
+ {/* Header */}
211
+ <div className="flex items-center gap-3 px-5 pt-4 pb-2">
212
+ <Sparkles size={16} style={{ color: 'var(--amber)' }} />
213
+ <span className="text-sm font-semibold flex-1 font-display" style={{ color: 'var(--foreground)' }}>
214
+ {g.title}
215
+ </span>
216
+ <button onClick={handleDismiss} className="p-1 rounded hover:bg-muted transition-colors"
217
+ style={{ color: 'var(--muted-foreground)' }}>
218
+ <X size={14} />
219
+ </button>
220
+ </div>
221
+
222
+ {/* Task cards */}
223
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-2 px-5 py-3">
224
+ {/* ① Explore KB */}
225
+ <TaskCard
226
+ icon={<FolderOpen size={16} />}
227
+ title={g.kb.title}
228
+ cta={g.kb.cta}
229
+ done={step1Done}
230
+ active={expanded === 'kb'}
231
+ onClick={() => step1Done ? null : setExpanded(expanded === 'kb' ? null : 'kb')}
232
+ />
233
+ {/* ② Chat with AI */}
234
+ <TaskCard
235
+ icon={<MessageCircle size={16} />}
236
+ title={g.ai.title}
237
+ cta={g.ai.cta}
238
+ done={step2Done}
239
+ active={expanded === 'ai'}
240
+ onClick={() => {
241
+ if (!step2Done) handleStartAI();
242
+ }}
243
+ />
244
+ {/* ③ Sync (optional) */}
245
+ <TaskCard
246
+ icon={<RefreshCw size={16} />}
247
+ title={g.sync.title}
248
+ cta={g.sync.cta}
249
+ done={false}
250
+ optional={g.sync.optional}
251
+ active={false}
252
+ onClick={handleSyncClick}
253
+ />
254
+ </div>
255
+
256
+ {/* Expanded content: Explore KB */}
257
+ {expanded === 'kb' && !step1Done && (
258
+ <div className="px-5 pb-4 animate-in slide-in-from-top-2 duration-200">
259
+ <div className="rounded-lg border p-4" style={{ background: 'var(--card)', borderColor: 'var(--border)' }}>
260
+ <p className="text-xs mb-3" style={{ color: 'var(--muted-foreground)' }}>
261
+ {isEmptyTemplate ? g.kb.emptyDesc : g.kb.fullDesc}
262
+ </p>
263
+
264
+ {isEmptyTemplate ? (
265
+ <div className="flex flex-col gap-1.5">
266
+ {EMPTY_FILES.map(file => (
267
+ <button key={file} onClick={() => handleFileOpen(file)}
268
+ className="text-left text-xs px-3 py-2 rounded-lg border transition-colors hover:border-amber-500/30 hover:bg-muted/50"
269
+ style={{ borderColor: 'var(--border)', color: 'var(--foreground)' }}>
270
+ 📄 {(g.kb.emptyFiles as Record<string, string>)[file.split('.')[0].toLowerCase()] || file}
271
+ </button>
272
+ ))}
273
+ <p className="text-xs mt-2" style={{ color: 'var(--muted-foreground)', opacity: 0.7 }}>
274
+ {g.kb.emptyHint}
275
+ </p>
276
+ </div>
277
+ ) : (
278
+ <>
279
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
280
+ {Object.entries(DIR_ICONS).map(([key, icon]) => (
281
+ <button key={key} onClick={() => handleFileOpen(key.charAt(0).toUpperCase() + key.slice(1))}
282
+ className="text-left text-xs px-3 py-2 rounded-lg border transition-colors hover:border-amber-500/30 hover:bg-muted/50"
283
+ style={{ borderColor: 'var(--border)', color: 'var(--foreground)' }}>
284
+ <span className="mr-1.5">{icon}</span>
285
+ <span className="capitalize">{key}</span>
286
+ <span className="block text-2xs mt-0.5" style={{ color: 'var(--muted-foreground)' }}>
287
+ {(g.kb.dirs as Record<string, string>)[key]}
288
+ </span>
289
+ </button>
290
+ ))}
291
+ </div>
292
+ <p className="text-xs mt-3" style={{ color: 'var(--amber)' }}>
293
+ 💡 {g.kb.instructionHint}
294
+ </p>
295
+ </>
296
+ )}
297
+
298
+ <div className="flex items-center justify-between mt-3 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
299
+ <span className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
300
+ {g.kb.progress(browsedCount)}
301
+ </span>
302
+ <button onClick={handleSkipKB}
303
+ className="text-xs px-3 py-1 rounded-lg transition-colors hover:bg-muted"
304
+ style={{ color: 'var(--muted-foreground)' }}>
305
+ {g.skip}
306
+ </button>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ )}
311
+ </div>
312
+ );
313
+ }
314
+
315
+ // Expose callback for AskModal integration
316
+ export { type GuideCardProps };
317
+
318
+ // Reusable sub-component
319
+ function TaskCard({ icon, title, cta, done, active, optional, onClick }: {
320
+ icon: React.ReactNode;
321
+ title: string;
322
+ cta: string;
323
+ done: boolean;
324
+ active: boolean;
325
+ optional?: string;
326
+ onClick: () => void;
327
+ }) {
328
+ return (
329
+ <button
330
+ onClick={onClick}
331
+ disabled={done}
332
+ className={`
333
+ flex flex-col items-center gap-1.5 px-3 py-3 rounded-lg border text-center
334
+ transition-all duration-150
335
+ ${done ? 'opacity-60' : 'hover:border-amber-500/30 hover:bg-muted/50 cursor-pointer'}
336
+ ${active ? 'border-amber-500/40 bg-muted/50' : ''}
337
+ `}
338
+ style={{ borderColor: done || active ? 'var(--amber)' : 'var(--border)' }}
339
+ >
340
+ <span
341
+ className={done ? 'animate-in zoom-in-50 duration-300' : ''}
342
+ style={{ color: done ? 'var(--success)' : 'var(--amber)' }}
343
+ >
344
+ {done ? <Check size={16} /> : icon}
345
+ </span>
346
+ <span className="text-xs font-medium" style={{ color: 'var(--foreground)' }}>
347
+ {title}
348
+ </span>
349
+ {optional && (
350
+ <span className="text-2xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
351
+ {optional}
352
+ </span>
353
+ )}
354
+ {!done && !optional && (
355
+ <span className="text-2xs" style={{ color: 'var(--amber)' }}>
356
+ {cta} →
357
+ </span>
358
+ )}
359
+ </button>
360
+ );
361
+ }
@@ -8,7 +8,7 @@ import { encodePath, relativeTime } 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';
11
- import WelcomeBanner from './WelcomeBanner';
11
+ import GuideCard from './GuideCard';
12
12
 
13
13
  interface RecentFile {
14
14
  path: string;
@@ -70,7 +70,7 @@ export default function HomeContent({ recent, existingFiles }: { recent: RecentF
70
70
 
71
71
  return (
72
72
  <div className="content-width px-4 md:px-6 py-8 md:py-12">
73
- <WelcomeBanner />
73
+ <GuideCard onNavigate={(path) => { window.location.href = `/view/${encodeURIComponent(path)}`; }} />
74
74
  {/* Hero */}
75
75
  <div className="mb-10">
76
76
  <div className="flex items-center gap-2 mb-3">
@@ -41,7 +41,8 @@ function formatInput(input: unknown): string {
41
41
  return parts.join(', ');
42
42
  }
43
43
 
44
- function truncateOutput(output: string, maxLen = 200): string {
44
+ function truncateOutput(output: string | undefined, maxLen = 200): string {
45
+ if (!output) return '';
45
46
  if (output.length <= maxLen) return output;
46
47
  return output.slice(0, maxLen) + '…';
47
48
  }