@geminilight/mindos 0.6.8 → 0.6.13

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 (79) hide show
  1. package/README.md +2 -0
  2. package/README_zh.md +2 -0
  3. package/app/app/api/mcp/install/route.ts +4 -1
  4. package/app/app/api/setup/check-path/route.ts +2 -7
  5. package/app/app/api/setup/ls/route.ts +3 -9
  6. package/app/app/api/setup/path-utils.ts +8 -0
  7. package/app/app/api/setup/route.ts +2 -7
  8. package/app/app/api/uninstall/route.ts +47 -0
  9. package/app/app/globals.css +11 -0
  10. package/app/components/ActivityBar.tsx +10 -3
  11. package/app/components/AskFab.tsx +7 -3
  12. package/app/components/CreateSpaceModal.tsx +1 -1
  13. package/app/components/DirView.tsx +1 -1
  14. package/app/components/FileTree.tsx +30 -23
  15. package/app/components/GuideCard.tsx +1 -1
  16. package/app/components/HomeContent.tsx +137 -109
  17. package/app/components/ImportModal.tsx +16 -477
  18. package/app/components/MarkdownView.tsx +3 -0
  19. package/app/components/OnboardingView.tsx +1 -1
  20. package/app/components/OrganizeToast.tsx +386 -0
  21. package/app/components/Panel.tsx +23 -2
  22. package/app/components/Sidebar.tsx +1 -1
  23. package/app/components/SidebarLayout.tsx +44 -1
  24. package/app/components/agents/AgentDetailContent.tsx +33 -12
  25. package/app/components/agents/AgentsMcpSection.tsx +1 -1
  26. package/app/components/agents/AgentsOverviewSection.tsx +3 -4
  27. package/app/components/agents/AgentsPrimitives.tsx +2 -2
  28. package/app/components/agents/AgentsSkillsSection.tsx +2 -2
  29. package/app/components/agents/SkillDetailPopover.tsx +24 -8
  30. package/app/components/ask/AskContent.tsx +124 -75
  31. package/app/components/ask/HighlightMatch.tsx +14 -0
  32. package/app/components/ask/MentionPopover.tsx +5 -3
  33. package/app/components/ask/MessageList.tsx +39 -11
  34. package/app/components/ask/SlashCommandPopover.tsx +4 -2
  35. package/app/components/changes/ChangesBanner.tsx +20 -2
  36. package/app/components/changes/ChangesContentPage.tsx +10 -2
  37. package/app/components/echo/EchoHero.tsx +1 -1
  38. package/app/components/echo/EchoInsightCollapsible.tsx +1 -1
  39. package/app/components/echo/EchoPageSections.tsx +1 -1
  40. package/app/components/explore/UseCaseCard.tsx +1 -1
  41. package/app/components/panels/DiscoverPanel.tsx +29 -25
  42. package/app/components/panels/ImportHistoryPanel.tsx +195 -0
  43. package/app/components/panels/PluginsPanel.tsx +2 -2
  44. package/app/components/settings/AiTab.tsx +24 -0
  45. package/app/components/settings/KnowledgeTab.tsx +1 -1
  46. package/app/components/settings/McpSkillCreateForm.tsx +1 -1
  47. package/app/components/settings/McpSkillRow.tsx +1 -1
  48. package/app/components/settings/McpSkillsSection.tsx +2 -2
  49. package/app/components/settings/McpTab.tsx +2 -2
  50. package/app/components/settings/PluginsTab.tsx +1 -1
  51. package/app/components/settings/Primitives.tsx +118 -6
  52. package/app/components/settings/SettingsContent.tsx +5 -2
  53. package/app/components/settings/UninstallTab.tsx +179 -0
  54. package/app/components/settings/UpdateTab.tsx +17 -5
  55. package/app/components/settings/types.ts +2 -1
  56. package/app/components/setup/StepDots.tsx +2 -2
  57. package/app/components/ui/dialog.tsx +1 -1
  58. package/app/hooks/useAiOrganize.ts +122 -10
  59. package/app/hooks/useMention.ts +21 -3
  60. package/app/hooks/useSlashCommand.ts +18 -4
  61. package/app/lib/agent/reconnect.ts +40 -0
  62. package/app/lib/core/backlinks.ts +2 -2
  63. package/app/lib/core/git.ts +14 -10
  64. package/app/lib/fs.ts +2 -1
  65. package/app/lib/i18n-en.ts +46 -2
  66. package/app/lib/i18n-zh.ts +46 -2
  67. package/app/lib/organize-history.ts +74 -0
  68. package/app/lib/settings.ts +2 -0
  69. package/app/lib/types.ts +2 -0
  70. package/app/next.config.ts +23 -5
  71. package/bin/cli.js +6 -9
  72. package/bin/lib/mcp-build.js +74 -0
  73. package/bin/lib/mcp-spawn.js +8 -5
  74. package/bin/lib/port.js +17 -2
  75. package/bin/lib/stop.js +12 -2
  76. package/mcp/dist/index.cjs +43 -43
  77. package/mcp/src/index.ts +58 -12
  78. package/package.json +1 -1
  79. package/scripts/setup.js +2 -2
@@ -158,7 +158,7 @@ export default function AgentsOverviewSection({
158
158
  riskOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
159
159
  )}
160
160
  >
161
- <div className="overflow-hidden">
161
+ <div className="overflow-hidden" {...(!riskOpen && { inert: true } as React.HTMLAttributes<HTMLDivElement>)}>
162
162
  <ul className="mt-3 space-y-2" role="list">
163
163
  {riskQueue.map((risk, i) => (
164
164
  <li
@@ -181,7 +181,7 @@ export default function AgentsOverviewSection({
181
181
  className={`text-2xs px-1.5 py-0.5 rounded font-medium shrink-0 select-none ${
182
182
  risk.severity === 'error'
183
183
  ? 'bg-destructive/10 text-destructive'
184
- : 'bg-[var(--amber-dim)] text-[var(--amber)]'
184
+ : 'bg-[var(--amber-dim)] text-[var(--amber-text)]'
185
185
  }`}
186
186
  >
187
187
  {risk.severity === 'error' ? copy.riskLevelError : copy.riskLevelWarn}
@@ -384,9 +384,8 @@ function AgentCard({
384
384
  className={`group rounded-xl border border-border bg-card p-3.5
385
385
  hover:border-[var(--amber)]/30 hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]
386
386
  active:scale-[0.98]
387
- transition-all duration-150 animate-in
387
+ transition-all duration-150
388
388
  focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
389
- style={{ animationDelay: `${Math.min(index * 30, 300)}ms` }}
390
389
  >
391
390
  {/* Top row: avatar + name + status */}
392
391
  <div className="flex items-center gap-2.5 mb-3">
@@ -13,7 +13,7 @@ export function PillButton({ active, label, onClick }: { active: boolean; label:
13
13
  aria-pressed={active}
14
14
  className={`relative px-2.5 min-h-[28px] rounded text-xs cursor-pointer transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
15
15
  active
16
- ? 'bg-[var(--amber-dim)] text-[var(--amber)] font-medium shadow-[0_1px_2px_rgba(200,135,58,0.08)]'
16
+ ? 'bg-[var(--amber-dim)] text-[var(--amber-text)] font-medium shadow-[0_1px_2px_rgba(200,135,58,0.08)]'
17
17
  : 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
18
18
  }`}
19
19
  >
@@ -261,7 +261,7 @@ export function ConfirmDialog({
261
261
 
262
262
  return (
263
263
  <div className="fixed inset-0 z-50 flex items-center justify-center" role="alertdialog" aria-modal="true" aria-label={title}>
264
- <div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" onClick={onCancel} aria-hidden="true" />
264
+ <div className="absolute inset-0 overlay-backdrop" onClick={onCancel} aria-hidden="true" />
265
265
  <div className="relative bg-card border border-border rounded-lg shadow-xl p-5 max-w-sm w-full mx-4 animate-in fade-in zoom-in-95 duration-200">
266
266
  <h3 className="text-sm font-medium text-foreground mb-1.5">{title}</h3>
267
267
  <p className="text-sm text-muted-foreground mb-4 leading-relaxed">{message}</p>
@@ -483,7 +483,7 @@ function BySkillView({
483
483
  ? 'bg-muted text-muted-foreground'
484
484
  : skill.source === 'builtin'
485
485
  ? 'bg-muted text-muted-foreground'
486
- : 'bg-[var(--amber-dim)] text-[var(--amber)]'
486
+ : 'bg-[var(--amber-dim)] text-[var(--amber-text)]'
487
487
  }`}>
488
488
  {skill.kind === 'native' ? copy.sourceNative : skill.source === 'builtin' ? copy.sourceBuiltin : copy.sourceUser}
489
489
  </span>
@@ -697,7 +697,7 @@ function AgentCard({
697
697
  {skillMode && (
698
698
  <span className={`text-2xs px-1.5 py-0.5 rounded shrink-0 ${
699
699
  skillMode === 'universal' ? 'bg-success/10 text-success'
700
- : skillMode === 'additional' ? 'bg-[var(--amber-dim)] text-[var(--amber)]'
700
+ : skillMode === 'additional' ? 'bg-[var(--amber-dim)] text-[var(--amber-text)]'
701
701
  : 'bg-muted text-muted-foreground'
702
702
  }`}>
703
703
  {skillMode}
@@ -14,6 +14,7 @@ import {
14
14
  ToggleLeft,
15
15
  Trash2,
16
16
  X,
17
+ ChevronDown,
17
18
  } from 'lucide-react';
18
19
  import { apiFetch } from '@/lib/api';
19
20
  import { copyToClipboard } from '@/lib/clipboard';
@@ -100,6 +101,7 @@ export default function SkillDetailPopover({
100
101
  const [deleting, setDeleting] = useState(false);
101
102
  const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
102
103
  const [toggleBusy, setToggleBusy] = useState(false);
104
+ const [descriptionExpanded, setDescriptionExpanded] = useState(false);
103
105
 
104
106
  const fetchContent = useCallback(async () => {
105
107
  if (!skillName) return;
@@ -204,7 +206,7 @@ export default function SkillDetailPopover({
204
206
  <>
205
207
  {/* Backdrop */}
206
208
  <div
207
- className="fixed inset-0 z-40 bg-black/30 backdrop-blur-[2px]"
209
+ className="fixed inset-0 z-40 overlay-backdrop"
208
210
  onClick={onClose}
209
211
  aria-hidden="true"
210
212
  />
@@ -229,7 +231,7 @@ export default function SkillDetailPopover({
229
231
  ? 'bg-muted text-muted-foreground'
230
232
  : skill?.source === 'builtin'
231
233
  ? 'bg-muted text-muted-foreground'
232
- : 'bg-[var(--amber-dim)] text-[var(--amber)]'
234
+ : 'bg-[var(--amber-dim)] text-[var(--amber-text)]'
233
235
  }`}>
234
236
  {sourceLabel}
235
237
  </span>
@@ -248,12 +250,26 @@ export default function SkillDetailPopover({
248
250
 
249
251
  {/* ─── Body (scrollable) ─── */}
250
252
  <div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
251
- {/* Description */}
252
- {description ? (
253
- <p className="text-sm text-foreground leading-relaxed">{description}</p>
254
- ) : !isNative ? (
255
- <p className="text-sm text-muted-foreground italic">{copy.noDescription}</p>
256
- ) : null}
253
+ {/* Description */}
254
+ {description ? (
255
+ <div className="space-y-2">
256
+ <p className={`text-sm text-foreground leading-relaxed whitespace-pre-wrap ${!descriptionExpanded ? 'line-clamp-3' : ''}`}>
257
+ {description}
258
+ </p>
259
+ {description.split('\n').length > 3 && (
260
+ <button
261
+ type="button"
262
+ onClick={() => setDescriptionExpanded(!descriptionExpanded)}
263
+ className="inline-flex items-center gap-1 text-2xs font-medium text-[var(--amber)] hover:opacity-80 transition-opacity cursor-pointer"
264
+ >
265
+ <ChevronDown size={12} className={`transition-transform duration-200 ${descriptionExpanded ? 'rotate-180' : ''}`} />
266
+ <span>{descriptionExpanded ? '收起' : '查看全部'}</span>
267
+ </button>
268
+ )}
269
+ </div>
270
+ ) : !isNative ? (
271
+ <p className="text-sm text-muted-foreground italic">{copy.noDescription}</p>
272
+ ) : null}
257
273
 
258
274
  {/* Quick meta */}
259
275
  <div className="grid grid-cols-2 gap-3">
@@ -15,6 +15,7 @@ import SlashCommandPopover from '@/components/ask/SlashCommandPopover';
15
15
  import SessionHistory from '@/components/ask/SessionHistory';
16
16
  import FileChip from '@/components/ask/FileChip';
17
17
  import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
18
+ import { isRetryableError, retryDelay, sleep } from '@/lib/agent/reconnect';
18
19
  import { cn } from '@/lib/utils';
19
20
  import { useComposerVerticalResize } from '@/hooks/useComposerVerticalResize';
20
21
 
@@ -27,7 +28,7 @@ const PANEL_COMPOSER_KEY_STEP = 24;
27
28
  /** 输入框随内容增高,超过此行数后在框内滚动(与常见 IM 一致) */
28
29
  const PANEL_TEXTAREA_MAX_VISIBLE_LINES = 8;
29
30
 
30
- function syncPanelTextareaToContent(el: HTMLTextAreaElement, maxVisibleLines: number, availableHeight?: number): void {
31
+ function syncTextareaToContent(el: HTMLTextAreaElement, maxVisibleLines: number, availableHeight?: number): void {
31
32
  const style = getComputedStyle(el);
32
33
  const parsedLh = parseFloat(style.lineHeight);
33
34
  const parsedFs = parseFloat(style.fontSize);
@@ -86,7 +87,7 @@ interface AskContentProps {
86
87
  export default function AskContent({ visible, currentFile, initialMessage, onFirstMessage, variant, onClose, maximized, onMaximize, askMode, onModeSwitch }: AskContentProps) {
87
88
  const isPanel = variant === 'panel';
88
89
 
89
- const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
90
+ const inputRef = useRef<HTMLTextAreaElement>(null);
90
91
  const abortRef = useRef<AbortController | null>(null);
91
92
  const firstMessageFired = useRef(false);
92
93
  const { t } = useLocale();
@@ -171,7 +172,9 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
171
172
 
172
173
  const [input, setInput] = useState('');
173
174
  const [isLoading, setIsLoading] = useState(false);
174
- const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming'>('connecting');
175
+ const [loadingPhase, setLoadingPhase] = useState<'connecting' | 'thinking' | 'streaming' | 'reconnecting'>('connecting');
176
+ const [reconnectAttempt, setReconnectAttempt] = useState(0);
177
+ const reconnectMaxRef = useRef(3);
175
178
  const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
176
179
  const [showHistory, setShowHistory] = useState(false);
177
180
  const [isDragOver, setIsDragOver] = useState(false);
@@ -220,13 +223,18 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
220
223
  // eslint-disable-next-line react-hooks/exhaustive-deps
221
224
  }, [visible, currentFile]);
222
225
 
223
- // Persist session on message changes
226
+ // Persist session on message changes (skip if last msg is empty assistant placeholder during loading)
224
227
  useEffect(() => {
225
228
  if (!visible || !session.activeSessionId) return;
226
- session.persistSession(session.messages, session.activeSessionId);
229
+ const msgs = session.messages;
230
+ if (isLoading && msgs.length > 0) {
231
+ const last = msgs[msgs.length - 1];
232
+ if (last.role === 'assistant' && !last.content.trim() && (!last.parts || last.parts.length === 0)) return;
233
+ }
234
+ session.persistSession(msgs, session.activeSessionId);
227
235
  return () => session.clearPersistTimer();
228
236
  // eslint-disable-next-line react-hooks/exhaustive-deps
229
- }, [visible, session.messages, session.activeSessionId]);
237
+ }, [visible, session.messages, session.activeSessionId, isLoading]);
230
238
 
231
239
  // Esc to close — modal only
232
240
  useEffect(() => {
@@ -252,12 +260,13 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
252
260
  const formRef = useRef<HTMLFormElement>(null);
253
261
 
254
262
  useLayoutEffect(() => {
255
- if (!isPanel || !visible) return;
263
+ if (!visible) return;
256
264
  const el = inputRef.current;
257
265
  if (!el || !(el instanceof HTMLTextAreaElement)) return;
258
266
  const form = formRef.current;
259
- const availableH = form ? form.clientHeight - 40 : undefined;
260
- syncPanelTextareaToContent(el, PANEL_TEXTAREA_MAX_VISIBLE_LINES, availableH);
267
+ const maxLines = isPanel ? PANEL_TEXTAREA_MAX_VISIBLE_LINES : 6;
268
+ const availableH = isPanel && form ? form.clientHeight - 40 : undefined;
269
+ syncTextareaToContent(el, maxLines, availableH);
261
270
  }, [input, isPanel, isLoading, visible, panelComposerHeight]);
262
271
 
263
272
  const mentionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -280,9 +289,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
280
289
 
281
290
  const selectMention = useCallback((filePath: string) => {
282
291
  const el = inputRef.current;
283
- const cursorPos = el
284
- ? (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement ? el.selectionStart ?? input.length : input.length)
285
- : input.length;
292
+ const cursorPos = el?.selectionStart ?? input.length;
286
293
  const before = input.slice(0, cursorPos);
287
294
  const atIdx = before.lastIndexOf('@');
288
295
  const newVal = input.slice(0, atIdx) + input.slice(cursorPos);
@@ -299,9 +306,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
299
306
 
300
307
  const selectSlashCommand = useCallback((item: SlashItem) => {
301
308
  const el = inputRef.current;
302
- const cursorPos = el
303
- ? (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement ? el.selectionStart ?? input.length : input.length)
304
- : input.length;
309
+ const cursorPos = el?.selectionStart ?? input.length;
305
310
  const before = input.slice(0, cursorPos);
306
311
  const slashIdx = before.lastIndexOf('/');
307
312
  const newVal = input.slice(0, slashIdx) + input.slice(cursorPos);
@@ -315,7 +320,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
315
320
  }, [input, slash]);
316
321
 
317
322
  const handleInputKeyDown = useCallback(
318
- (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
323
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
319
324
  if (mention.mentionQuery !== null) {
320
325
  if (e.key === 'Escape') {
321
326
  e.preventDefault();
@@ -329,7 +334,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
329
334
  e.preventDefault();
330
335
  mention.navigateMention('up');
331
336
  } else if (e.key === 'Enter' || e.key === 'Tab') {
332
- if (e.key === 'Enter' && e.shiftKey) return;
337
+ if (e.key === 'Enter' && (e.shiftKey || e.nativeEvent.isComposing)) return;
333
338
  if (mention.mentionResults.length > 0) {
334
339
  e.preventDefault();
335
340
  selectMention(mention.mentionResults[mention.mentionIndex]);
@@ -350,7 +355,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
350
355
  e.preventDefault();
351
356
  slash.navigateSlash('up');
352
357
  } else if (e.key === 'Enter' || e.key === 'Tab') {
353
- if (e.key === 'Enter' && e.shiftKey) return;
358
+ if (e.key === 'Enter' && (e.shiftKey || e.nativeEvent.isComposing)) return;
354
359
  if (slash.slashResults.length > 0) {
355
360
  e.preventDefault();
356
361
  selectSlashCommand(slash.slashResults[slash.slashIndex]);
@@ -358,12 +363,12 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
358
363
  }
359
364
  return;
360
365
  }
361
- if (variant === 'panel' && e.key === 'Enter' && !e.shiftKey && !isLoading && input.trim()) {
366
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && !isLoading && input.trim()) {
362
367
  e.preventDefault();
363
368
  (e.currentTarget as HTMLTextAreaElement).form?.requestSubmit();
364
369
  }
365
370
  },
366
- [mention, selectMention, slash, selectSlashCommand, variant, isLoading, input],
371
+ [mention, selectMention, slash, selectSlashCommand, isLoading, input],
367
372
  );
368
373
 
369
374
  const handleStop = useCallback(() => { abortRef.current?.abort(); }, []);
@@ -377,7 +382,12 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
377
382
  const content = selectedSkill
378
383
  ? `Use the skill ${selectedSkill.name}: ${text}`
379
384
  : text;
380
- const userMsg: Message = { role: 'user', content, timestamp: Date.now() };
385
+ const userMsg: Message = {
386
+ role: 'user',
387
+ content,
388
+ timestamp: Date.now(),
389
+ ...(selectedSkill && { skillName: selectedSkill.name }),
390
+ };
381
391
  const requestMessages = [...session.messages, userMsg];
382
392
  session.setMessages([...requestMessages, { role: 'assistant', content: '', timestamp: Date.now() }]);
383
393
  setInput('');
@@ -389,25 +399,35 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
389
399
  setAttachedFiles(currentFile ? [currentFile] : []);
390
400
  setIsLoading(true);
391
401
  setLoadingPhase('connecting');
402
+ setReconnectAttempt(0);
392
403
 
393
404
  const controller = new AbortController();
394
405
  abortRef.current = controller;
395
406
 
407
+ let maxRetries = 3;
396
408
  try {
409
+ const stored = localStorage.getItem('mindos-reconnect-retries');
410
+ if (stored !== null) { const n = parseInt(stored, 10); if (Number.isFinite(n)) maxRetries = Math.max(0, Math.min(10, n)); }
411
+ } catch { /* localStorage unavailable */ }
412
+ reconnectMaxRef.current = maxRetries;
413
+
414
+ const requestBody = JSON.stringify({
415
+ messages: requestMessages,
416
+ currentFile,
417
+ attachedFiles,
418
+ uploadedFiles: upload.localAttachments.map(f => ({
419
+ name: f.name,
420
+ content: f.content.length > 20_000
421
+ ? f.content.slice(0, 20_000) + '\n\n[...truncated to first ~20000 chars]'
422
+ : f.content,
423
+ })),
424
+ });
425
+
426
+ const doFetch = async (): Promise<{ finalMessage: Message }> => {
397
427
  const res = await fetch('/api/ask', {
398
428
  method: 'POST',
399
429
  headers: { 'Content-Type': 'application/json' },
400
- body: JSON.stringify({
401
- messages: requestMessages,
402
- currentFile,
403
- attachedFiles,
404
- uploadedFiles: upload.localAttachments.map(f => ({
405
- name: f.name,
406
- content: f.content.length > 20_000
407
- ? f.content.slice(0, 20_000) + '\n\n[...truncated to first ~20000 chars]'
408
- : f.content,
409
- })),
410
- }),
430
+ body: requestBody,
411
431
  signal: controller.signal,
412
432
  });
413
433
 
@@ -423,7 +443,9 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
423
443
  errorMsg = errBody.message;
424
444
  }
425
445
  } catch {}
426
- throw new Error(errorMsg);
446
+ const err = new Error(errorMsg);
447
+ (err as Error & { httpStatus?: number }).httpStatus = res.status;
448
+ throw err;
427
449
  }
428
450
 
429
451
  if (!res.body) throw new Error('No response body');
@@ -442,14 +464,45 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
442
464
  },
443
465
  controller.signal,
444
466
  );
467
+ return { finalMessage };
468
+ };
445
469
 
446
- if (!finalMessage.content.trim() && (!finalMessage.parts || finalMessage.parts.length === 0)) {
447
- session.setMessages(prev => {
448
- const updated = [...prev];
449
- updated[updated.length - 1] = { role: 'assistant', content: `__error__${t.ask.errorNoResponse}` };
450
- return updated;
451
- });
470
+ try {
471
+ let lastError: Error | null = null;
472
+
473
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
474
+ if (controller.signal.aborted) break;
475
+
476
+ if (attempt > 0) {
477
+ setReconnectAttempt(attempt);
478
+ setLoadingPhase('reconnecting');
479
+ session.setMessages(prev => {
480
+ const updated = [...prev];
481
+ updated[updated.length - 1] = { role: 'assistant', content: '', timestamp: Date.now() };
482
+ return updated;
483
+ });
484
+ await sleep(retryDelay(attempt - 1), controller.signal);
485
+ setLoadingPhase('connecting');
486
+ }
487
+
488
+ try {
489
+ const { finalMessage } = await doFetch();
490
+ if (!finalMessage.content.trim() && (!finalMessage.parts || finalMessage.parts.length === 0)) {
491
+ session.setMessages(prev => {
492
+ const updated = [...prev];
493
+ updated[updated.length - 1] = { role: 'assistant', content: `__error__${t.ask.errorNoResponse}` };
494
+ return updated;
495
+ });
496
+ }
497
+ return;
498
+ } catch (err) {
499
+ lastError = err instanceof Error ? err : new Error(String(err));
500
+ const httpStatus = (err as Error & { httpStatus?: number }).httpStatus;
501
+ if (!isRetryableError(err, httpStatus) || attempt >= maxRetries) break;
502
+ }
452
503
  }
504
+
505
+ if (lastError) throw lastError;
453
506
  } catch (err) {
454
507
  if ((err as Error).name === 'AbortError') {
455
508
  session.setMessages(prev => {
@@ -482,6 +535,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
482
535
  }
483
536
  } finally {
484
537
  setIsLoading(false);
538
+ setReconnectAttempt(0);
485
539
  abortRef.current = null;
486
540
  }
487
541
  }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, slash.slashQuery, selectedSkill, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
@@ -595,15 +649,21 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
595
649
  emptyPrompt={t.ask.emptyPrompt}
596
650
  suggestions={t.ask.suggestions}
597
651
  onSuggestionClick={setInput}
598
- labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
652
+ labels={{
653
+ connecting: t.ask.connecting,
654
+ thinking: t.ask.thinking,
655
+ generating: t.ask.generating,
656
+ reconnecting: reconnectAttempt > 0 ? t.ask.reconnecting(reconnectAttempt, reconnectMaxRef.current) : undefined,
657
+ }}
599
658
  />
600
659
 
601
- {/* Popovers — rendered outside overflow containers so they can extend freely */}
660
+ {/* Popovers — flex children so they stay within overflow boundary (absolute positioning would be clipped by RightAskPanel's overflow-hidden) */}
602
661
  {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
603
662
  <div className="shrink-0 px-2 pb-1">
604
663
  <MentionPopover
605
664
  results={mention.mentionResults}
606
665
  selectedIndex={mention.mentionIndex}
666
+ query={mention.mentionQuery ?? undefined}
607
667
  onSelect={selectMention}
608
668
  />
609
669
  </div>
@@ -614,6 +674,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
614
674
  <SlashCommandPopover
615
675
  results={slash.slashResults}
616
676
  selectedIndex={slash.slashIndex}
677
+ query={slash.slashQuery ?? undefined}
617
678
  onSelect={selectSlashCommand}
618
679
  />
619
680
  </div>
@@ -706,7 +767,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
706
767
  onSubmit={handleSubmit}
707
768
  className={cn(
708
769
  'flex',
709
- isPanel ? 'min-h-0 flex-1 items-end gap-1.5 px-2 py-2' : 'items-center gap-2 px-3 py-3',
770
+ isPanel ? 'min-h-0 flex-1 items-end gap-1.5 px-2 py-2' : 'items-end gap-2 px-3 py-3',
710
771
  )}
711
772
  >
712
773
  <button type="button" onClick={() => upload.uploadInputRef.current?.click()} className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0" title="Attach local file">
@@ -726,34 +787,24 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
726
787
  }}
727
788
  />
728
789
 
729
- {isPanel ? (
730
- <textarea
731
- ref={(el) => {
732
- inputRef.current = el;
733
- }}
734
- value={input}
735
- onChange={e => handleInputChange(e.target.value, e.target.selectionStart ?? undefined)}
736
- onKeyDown={handleInputKeyDown}
737
- placeholder={t.ask.placeholder}
738
- rows={1}
739
- className="min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-2 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none"
740
- />
741
- ) : (
742
- <input
743
- ref={(el) => {
744
- inputRef.current = el;
745
- }}
746
- value={input}
747
- onChange={e => handleInputChange(e.target.value, e.target.selectionStart ?? undefined)}
748
- onKeyDown={handleInputKeyDown}
749
- placeholder={t.ask.placeholder}
750
- className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
751
- />
752
- )}
790
+ <textarea
791
+ ref={(el) => {
792
+ inputRef.current = el;
793
+ }}
794
+ value={input}
795
+ onChange={e => handleInputChange(e.target.value, e.target.selectionStart ?? undefined)}
796
+ onKeyDown={handleInputKeyDown}
797
+ placeholder={t.ask.placeholder}
798
+ rows={1}
799
+ className={cn(
800
+ 'min-w-0 flex-1 resize-none overflow-y-auto bg-transparent text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none focus-visible:ring-0',
801
+ isPanel ? 'py-2' : 'py-1.5',
802
+ )}
803
+ />
753
804
 
754
805
  {isLoading ? (
755
- <button type="button" onClick={handleStop} className="p-1.5 rounded-md transition-colors shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted" title={t.ask.stopTitle}>
756
- <StopCircle size={inputIconSize} />
806
+ <button type="button" onClick={handleStop} className="p-1.5 rounded-md transition-colors shrink-0 text-muted-foreground hover:text-foreground hover:bg-muted" title={loadingPhase === 'reconnecting' ? t.ask.cancelReconnect : t.ask.stopTitle}>
807
+ {loadingPhase === 'reconnecting' ? <X size={inputIconSize} /> : <StopCircle size={inputIconSize} />}
757
808
  </button>
758
809
  ) : (
759
810
  <button type="submit" disabled={!input.trim() || mention.mentionQuery !== null || slash.slashQuery !== 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)]">
@@ -770,18 +821,16 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
770
821
  'flex shrink-0 items-center',
771
822
  isPanel
772
823
  ? 'flex-wrap gap-x-2 gap-y-1 px-3 pb-1.5 text-[10px] text-muted-foreground/40'
773
- : 'hidden gap-3 px-4 pb-2 text-xs text-muted-foreground/50 md:flex',
824
+ : 'flex-wrap gap-x-3 gap-y-1 px-4 pb-2 text-[10px] md:text-xs text-muted-foreground/50',
774
825
  )}
775
826
  >
776
827
  <span suppressHydrationWarning>
777
828
  <kbd className="font-mono">↵</kbd> {t.ask.send}
778
829
  </span>
779
- {isPanel ? (
780
- <span suppressHydrationWarning>
781
- <kbd className="font-mono">⇧</kbd>
782
- <kbd className="font-mono ml-0.5">↵</kbd> {t.ask.newlineHint}
783
- </span>
784
- ) : null}
830
+ <span suppressHydrationWarning>
831
+ <kbd className="font-mono">⇧</kbd>
832
+ <kbd className="font-mono ml-0.5">↵</kbd> {t.ask.newlineHint}
833
+ </span>
785
834
  <span suppressHydrationWarning>
786
835
  <kbd className="font-mono">@</kbd> {t.ask.attachFile}
787
836
  </span>
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+
3
+ export default function HighlightMatch({ text, query }: { text: string; query?: string }) {
4
+ if (!query) return <>{text}</>;
5
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
6
+ if (idx === -1) return <>{text}</>;
7
+ return (
8
+ <>
9
+ {text.slice(0, idx)}
10
+ <span className="text-[var(--amber)] font-semibold">{text.slice(idx, idx + query.length)}</span>
11
+ {text.slice(idx + query.length)}
12
+ </>
13
+ );
14
+ }
@@ -2,14 +2,16 @@
2
2
 
3
3
  import { useEffect, useRef } from 'react';
4
4
  import { FileText, Table, FolderOpen } from 'lucide-react';
5
+ import HighlightMatch from './HighlightMatch';
5
6
 
6
7
  interface MentionPopoverProps {
7
8
  results: string[];
8
9
  selectedIndex: number;
10
+ query?: string;
9
11
  onSelect: (filePath: string) => void;
10
12
  }
11
13
 
12
- export default function MentionPopover({ results, selectedIndex, onSelect }: MentionPopoverProps) {
14
+ export default function MentionPopover({ results, selectedIndex, query, onSelect }: MentionPopoverProps) {
13
15
  const listRef = useRef<HTMLDivElement>(null);
14
16
 
15
17
  useEffect(() => {
@@ -52,10 +54,10 @@ export default function MentionPopover({ results, selectedIndex, onSelect }: Men
52
54
  ) : (
53
55
  <FileText size={13} className="text-muted-foreground shrink-0" />
54
56
  )}
55
- <span className="truncate font-medium flex-1">{name}</span>
57
+ <span className="truncate font-medium flex-1"><HighlightMatch text={name} query={query} /></span>
56
58
  {dir && (
57
59
  <span className="text-2xs text-muted-foreground/40 truncate max-w-[140px] shrink-0">
58
- {dir}
60
+ <HighlightMatch text={dir} query={query} />
59
61
  </span>
60
62
  )}
61
63
  </button>