@geminilight/mindos 0.6.29 → 0.6.31

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 (110) hide show
  1. package/README.md +10 -4
  2. package/README_zh.md +10 -4
  3. package/app/app/api/acp/config/route.ts +82 -0
  4. package/app/app/api/acp/detect/route.ts +71 -48
  5. package/app/app/api/acp/install/route.ts +51 -0
  6. package/app/app/api/acp/session/route.ts +141 -11
  7. package/app/app/api/ask/route.ts +126 -18
  8. package/app/app/api/export/route.ts +105 -0
  9. package/app/app/api/workflows/route.ts +156 -0
  10. package/app/app/globals.css +2 -2
  11. package/app/app/page.tsx +7 -2
  12. package/app/app/trash/page.tsx +7 -0
  13. package/app/app/view/[...path]/ViewPageClient.tsx +234 -2
  14. package/app/components/ActivityBar.tsx +12 -4
  15. package/app/components/AskModal.tsx +4 -1
  16. package/app/components/ExportModal.tsx +220 -0
  17. package/app/components/FileTree.tsx +42 -11
  18. package/app/components/HomeContent.tsx +92 -20
  19. package/app/components/MarkdownView.tsx +45 -10
  20. package/app/components/Panel.tsx +1 -0
  21. package/app/components/RightAskPanel.tsx +5 -1
  22. package/app/components/Sidebar.tsx +10 -1
  23. package/app/components/SidebarLayout.tsx +6 -0
  24. package/app/components/TrashPageClient.tsx +263 -0
  25. package/app/components/agents/AgentDetailContent.tsx +263 -47
  26. package/app/components/agents/AgentsContentPage.tsx +11 -0
  27. package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
  28. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  29. package/app/components/agents/agents-content-model.ts +2 -2
  30. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  31. package/app/components/ask/AskContent.tsx +197 -239
  32. package/app/components/ask/FileChip.tsx +82 -17
  33. package/app/components/ask/MentionPopover.tsx +21 -3
  34. package/app/components/ask/MessageList.tsx +30 -9
  35. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  36. package/app/components/ask/ToolCallBlock.tsx +102 -18
  37. package/app/components/changes/ChangesContentPage.tsx +58 -14
  38. package/app/components/explore/ExploreContent.tsx +4 -7
  39. package/app/components/explore/UseCaseCard.tsx +18 -1
  40. package/app/components/explore/use-cases.generated.ts +76 -0
  41. package/app/components/explore/use-cases.yaml +185 -0
  42. package/app/components/panels/AgentsPanel.tsx +1 -0
  43. package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
  44. package/app/components/panels/DiscoverPanel.tsx +1 -1
  45. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  46. package/app/components/renderers/workflow-yaml/StepEditor.tsx +164 -0
  47. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +211 -0
  48. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +269 -0
  49. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  50. package/app/components/renderers/workflow-yaml/execution.ts +229 -0
  51. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  52. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  53. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  54. package/app/components/renderers/workflow-yaml/selectors.tsx +574 -0
  55. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  56. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  57. package/app/components/settings/AiTab.tsx +191 -174
  58. package/app/components/settings/AppearanceTab.tsx +168 -77
  59. package/app/components/settings/KnowledgeTab.tsx +131 -136
  60. package/app/components/settings/McpTab.tsx +11 -11
  61. package/app/components/settings/Primitives.tsx +60 -0
  62. package/app/components/settings/SettingsContent.tsx +15 -8
  63. package/app/components/settings/SyncTab.tsx +12 -12
  64. package/app/components/settings/UninstallTab.tsx +8 -18
  65. package/app/components/settings/UpdateTab.tsx +82 -82
  66. package/app/components/settings/types.ts +17 -8
  67. package/app/hooks/useAcpConfig.ts +96 -0
  68. package/app/hooks/useAcpDetection.ts +69 -14
  69. package/app/hooks/useAcpRegistry.ts +46 -11
  70. package/app/hooks/useAskModal.ts +12 -5
  71. package/app/hooks/useAskPanel.ts +8 -5
  72. package/app/hooks/useAskSession.ts +19 -2
  73. package/app/hooks/useImageUpload.ts +152 -0
  74. package/app/lib/acp/acp-tools.ts +3 -1
  75. package/app/lib/acp/agent-descriptors.ts +274 -0
  76. package/app/lib/acp/bridge.ts +6 -0
  77. package/app/lib/acp/index.ts +20 -4
  78. package/app/lib/acp/registry.ts +74 -7
  79. package/app/lib/acp/session.ts +490 -28
  80. package/app/lib/acp/subprocess.ts +307 -21
  81. package/app/lib/acp/types.ts +158 -20
  82. package/app/lib/actions.ts +57 -3
  83. package/app/lib/agent/model.ts +18 -3
  84. package/app/lib/agent/stream-consumer.ts +18 -0
  85. package/app/lib/agent/to-agent-messages.ts +25 -2
  86. package/app/lib/agent/tools.ts +56 -9
  87. package/app/lib/core/export.ts +116 -0
  88. package/app/lib/core/trash.ts +241 -0
  89. package/app/lib/fs.ts +47 -0
  90. package/app/lib/hooks/usePinnedFiles.ts +90 -0
  91. package/app/lib/i18n/generated/explore-i18n.generated.ts +138 -0
  92. package/app/lib/i18n/index.ts +3 -0
  93. package/app/lib/i18n/modules/knowledge.ts +124 -6
  94. package/app/lib/i18n/modules/navigation.ts +2 -0
  95. package/app/lib/i18n/modules/onboarding.ts +2 -134
  96. package/app/lib/i18n/modules/panels.ts +146 -2
  97. package/app/lib/i18n/modules/settings.ts +12 -0
  98. package/app/lib/pi-integration/skills.ts +21 -6
  99. package/app/lib/renderers/index.ts +2 -2
  100. package/app/lib/settings.ts +10 -0
  101. package/app/lib/types.ts +12 -1
  102. package/app/next-env.d.ts +1 -1
  103. package/app/package.json +11 -3
  104. package/app/scripts/generate-explore.ts +145 -0
  105. package/package.json +1 -1
  106. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  107. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  108. package/app/components/explore/use-cases.ts +0 -58
  109. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  110. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
4
- import { Sparkles, Send, Paperclip, StopCircle, SquarePen, History, X, Zap, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
4
+ import { Sparkles, Send, StopCircle, SquarePen, History, X, Maximize2, Minimize2, PanelRight, AppWindow, Plus } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
- import type { Message } from '@/lib/types';
6
+ import type { Message, ImagePart } from '@/lib/types';
7
7
  import { useAskSession } from '@/hooks/useAskSession';
8
8
  import { useFileUpload } from '@/hooks/useFileUpload';
9
+ import { useImageUpload } from '@/hooks/useImageUpload';
9
10
  import { useMention } from '@/hooks/useMention';
10
11
  import { useSlashCommand } from '@/hooks/useSlashCommand';
11
12
  import type { SlashItem } from '@/hooks/useSlashCommand';
@@ -15,21 +16,18 @@ import SlashCommandPopover from '@/components/ask/SlashCommandPopover';
15
16
  import SessionHistory from '@/components/ask/SessionHistory';
16
17
  import SessionTabBar from '@/components/ask/SessionTabBar';
17
18
  import FileChip from '@/components/ask/FileChip';
19
+ import AgentSelectorCapsule from '@/components/ask/AgentSelectorCapsule';
18
20
  import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
19
21
  import { isRetryableError, retryDelay, sleep } from '@/lib/agent/reconnect';
20
22
  import { cn } from '@/lib/utils';
21
- import { useComposerVerticalResize } from '@/hooks/useComposerVerticalResize';
22
-
23
- const PANEL_COMPOSER_STORAGE = 'mindos-agent-panel-composer-height';
24
- const PANEL_COMPOSER_DEFAULT = 104;
25
- const PANEL_COMPOSER_MIN = 84;
26
- const PANEL_COMPOSER_MAX_ABS = 440;
27
- const PANEL_COMPOSER_MAX_VIEW = 0.48;
28
- const PANEL_COMPOSER_KEY_STEP = 24;
29
- /** 输入框随内容增高,超过此行数后在框内滚动(与常见 IM 一致) */
30
- const PANEL_TEXTAREA_MAX_VISIBLE_LINES = 8;
31
-
32
- function syncTextareaToContent(el: HTMLTextAreaElement, maxVisibleLines: number, availableHeight?: number): void {
23
+ import { useAcpDetection } from '@/hooks/useAcpDetection';
24
+ import type { AcpAgentSelection } from '@/hooks/useAskModal';
25
+
26
+ /** Textarea auto-grows with content up to this many visible lines, then scrolls */
27
+ const TEXTAREA_MAX_VISIBLE_LINES = 8;
28
+
29
+ /** Auto-size textarea height to fit content, capped at maxVisibleLines */
30
+ function syncTextareaToContent(el: HTMLTextAreaElement, maxVisibleLines: number): void {
33
31
  const style = getComputedStyle(el);
34
32
  const parsedLh = parseFloat(style.lineHeight);
35
33
  const parsedFs = parseFloat(style.fontSize);
@@ -38,33 +36,13 @@ function syncTextareaToContent(el: HTMLTextAreaElement, maxVisibleLines: number,
38
36
  const pad =
39
37
  (Number.isFinite(parseFloat(style.paddingTop)) ? parseFloat(style.paddingTop) : 0) +
40
38
  (Number.isFinite(parseFloat(style.paddingBottom)) ? parseFloat(style.paddingBottom) : 0);
41
- let maxH = lineHeight * maxVisibleLines + pad;
42
- if (availableHeight && Number.isFinite(availableHeight) && availableHeight > 0) {
43
- maxH = Math.min(maxH, availableHeight);
44
- }
39
+ const maxH = lineHeight * maxVisibleLines + pad;
45
40
  if (!Number.isFinite(maxH) || maxH <= 0) return;
46
41
  el.style.height = '0px';
47
42
  const next = Math.min(el.scrollHeight, maxH);
48
43
  el.style.height = `${Number.isFinite(next) ? next : maxH}px`;
49
- }
50
-
51
- function panelComposerMaxForViewport(): number {
52
- if (typeof window === 'undefined') return PANEL_COMPOSER_MAX_ABS;
53
- return Math.min(PANEL_COMPOSER_MAX_ABS, Math.floor(window.innerHeight * PANEL_COMPOSER_MAX_VIEW));
54
- }
55
-
56
- function readStoredPanelComposerHeight(): number {
57
- if (typeof window === 'undefined') return PANEL_COMPOSER_DEFAULT;
58
- try {
59
- const s = localStorage.getItem(PANEL_COMPOSER_STORAGE);
60
- if (s) {
61
- const n = parseInt(s, 10);
62
- if (Number.isFinite(n) && n >= PANEL_COMPOSER_MIN && n <= PANEL_COMPOSER_MAX_ABS) return n;
63
- }
64
- } catch {
65
- /* ignore */
66
- }
67
- return PANEL_COMPOSER_DEFAULT;
44
+ // Only show scrollbar when content exceeds max height
45
+ el.style.overflowY = el.scrollHeight > maxH ? 'auto' : 'hidden';
68
46
  }
69
47
 
70
48
  interface AskContentProps {
@@ -72,6 +50,8 @@ interface AskContentProps {
72
50
  visible: boolean;
73
51
  currentFile?: string;
74
52
  initialMessage?: string;
53
+ /** ACP agent pre-selected via "Use" button from A2A tab */
54
+ initialAcpAgent?: AcpAgentSelection | null;
75
55
  onFirstMessage?: () => void;
76
56
  /** 'modal' renders close button + ESC handler; 'panel' renders compact header */
77
57
  variant: 'modal' | 'panel';
@@ -85,91 +65,16 @@ interface AskContentProps {
85
65
  onModeSwitch?: () => void;
86
66
  }
87
67
 
88
- export default function AskContent({ visible, currentFile, initialMessage, onFirstMessage, variant, onClose, maximized, onMaximize, askMode, onModeSwitch }: AskContentProps) {
68
+ export default function AskContent({ visible, currentFile, initialMessage, initialAcpAgent, onFirstMessage, variant, onClose, maximized, onMaximize, askMode, onModeSwitch }: AskContentProps) {
89
69
  const isPanel = variant === 'panel';
90
70
 
91
71
  const inputRef = useRef<HTMLTextAreaElement>(null);
72
+ const imageInputRef = useRef<HTMLInputElement>(null);
92
73
  const abortRef = useRef<AbortController | null>(null);
93
74
  const firstMessageFired = useRef(false);
94
75
  const { t } = useLocale();
95
-
96
- const [panelComposerHeight, setPanelComposerHeight] = useState(PANEL_COMPOSER_DEFAULT);
97
- const panelComposerHRef = useRef(panelComposerHeight);
98
- panelComposerHRef.current = panelComposerHeight;
99
-
100
- useEffect(() => {
101
- const stored = readStoredPanelComposerHeight();
102
- if (stored !== PANEL_COMPOSER_DEFAULT) {
103
- setPanelComposerHeight(stored);
104
- panelComposerHRef.current = stored;
105
- }
106
- }, []);
107
-
108
- const getPanelComposerHeight = useCallback(() => panelComposerHRef.current, []);
109
- const persistPanelComposerHeight = useCallback((h: number) => {
110
- try {
111
- localStorage.setItem(PANEL_COMPOSER_STORAGE, String(h));
112
- } catch {
113
- /* ignore */
114
- }
115
- }, []);
116
-
117
- const onPanelComposerResizePointerDown = useComposerVerticalResize({
118
- minHeight: PANEL_COMPOSER_MIN,
119
- maxHeightAbs: PANEL_COMPOSER_MAX_ABS,
120
- maxHeightViewportRatio: PANEL_COMPOSER_MAX_VIEW,
121
- getHeight: getPanelComposerHeight,
122
- setHeight: setPanelComposerHeight,
123
- persist: persistPanelComposerHeight,
124
- });
125
-
126
- const [panelComposerViewportMax, setPanelComposerViewportMax] = useState(PANEL_COMPOSER_MAX_ABS);
127
-
128
- useEffect(() => {
129
- setPanelComposerViewportMax(panelComposerMaxForViewport());
130
- }, []);
131
-
132
- const applyPanelComposerClampAndPersist = useCallback(() => {
133
- const maxH = panelComposerMaxForViewport();
134
- setPanelComposerViewportMax(maxH);
135
- const h = panelComposerHRef.current;
136
- if (h > maxH) {
137
- setPanelComposerHeight(maxH);
138
- panelComposerHRef.current = maxH;
139
- persistPanelComposerHeight(maxH);
140
- }
141
- }, [persistPanelComposerHeight]);
142
-
143
- const handlePanelComposerSeparatorKeyDown = useCallback(
144
- (e: React.KeyboardEvent<HTMLElement>) => {
145
- if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) return;
146
- e.preventDefault();
147
- const maxH = panelComposerMaxForViewport();
148
- setPanelComposerViewportMax(maxH);
149
- const h = panelComposerHRef.current;
150
- let next = h;
151
- if (e.key === 'ArrowUp') next = h + PANEL_COMPOSER_KEY_STEP;
152
- else if (e.key === 'ArrowDown') next = h - PANEL_COMPOSER_KEY_STEP;
153
- else if (e.key === 'Home') next = PANEL_COMPOSER_MIN;
154
- else if (e.key === 'End') next = maxH;
155
- const clamped = Math.round(Math.max(PANEL_COMPOSER_MIN, Math.min(maxH, next)));
156
- setPanelComposerHeight(clamped);
157
- panelComposerHRef.current = clamped;
158
- persistPanelComposerHeight(clamped);
159
- },
160
- [persistPanelComposerHeight],
161
- );
162
-
163
- const resetPanelComposerHeight = useCallback(
164
- (e: React.MouseEvent) => {
165
- e.preventDefault();
166
- e.stopPropagation();
167
- setPanelComposerHeight(PANEL_COMPOSER_DEFAULT);
168
- panelComposerHRef.current = PANEL_COMPOSER_DEFAULT;
169
- persistPanelComposerHeight(PANEL_COMPOSER_DEFAULT);
170
- },
171
- [persistPanelComposerHeight],
172
- );
76
+ const [mounted, setMounted] = useState(false);
77
+ useEffect(() => setMounted(true), []);
173
78
 
174
79
  const [input, setInput] = useState('');
175
80
  const [isLoading, setIsLoading] = useState(false);
@@ -179,13 +84,17 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
179
84
  const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
180
85
  const [showHistory, setShowHistory] = useState(false);
181
86
  const [isDragOver, setIsDragOver] = useState(false);
87
+ const [showAttachMenu, setShowAttachMenu] = useState(false);
182
88
 
183
89
  const [selectedSkill, setSelectedSkill] = useState<SlashItem | null>(null);
90
+ const [selectedAcpAgent, setSelectedAcpAgent] = useState<AcpAgentSelection | null>(null);
184
91
 
185
92
  const session = useAskSession(currentFile);
186
93
  const upload = useFileUpload();
94
+ const imageUpload = useImageUpload();
187
95
  const mention = useMention();
188
96
  const slash = useSlashCommand();
97
+ const acpDetection = useAcpDetection();
189
98
 
190
99
  useEffect(() => {
191
100
  const handler = (e: Event) => {
@@ -215,11 +124,13 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
215
124
  setInput(initialMessage || '');
216
125
  firstMessageFired.current = false;
217
126
  setAttachedFiles(currentFile ? [currentFile] : []);
218
- upload.clearAttachments();
219
- mention.resetMention();
220
- slash.resetSlash();
221
- setSelectedSkill(null);
222
- setShowHistory(false);
127
+ upload.clearAttachments();
128
+ imageUpload.clearImages();
129
+ mention.resetMention();
130
+ slash.resetSlash();
131
+ setSelectedSkill(null);
132
+ setSelectedAcpAgent(initialAcpAgent ?? null);
133
+ setShowHistory(false);
223
134
  } else if (fileChanged) {
224
135
  // Update attached file context to match new file (don't reset session/messages)
225
136
  setAttachedFiles(currentFile ? [currentFile] : []);
@@ -259,12 +170,14 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
259
170
  return () => window.removeEventListener('keydown', handler);
260
171
  }, [variant, visible, onClose, mention, slash]);
261
172
 
173
+ // Close attach menu on any outside click
262
174
  useEffect(() => {
263
- if (!isPanel) return;
264
- applyPanelComposerClampAndPersist();
265
- window.addEventListener('resize', applyPanelComposerClampAndPersist);
266
- return () => window.removeEventListener('resize', applyPanelComposerClampAndPersist);
267
- }, [isPanel, applyPanelComposerClampAndPersist]);
175
+ if (!showAttachMenu) return;
176
+ const handler = () => setShowAttachMenu(false);
177
+ // Delay to avoid closing immediately from the click that opened it
178
+ const id = setTimeout(() => document.addEventListener('click', handler), 0);
179
+ return () => { clearTimeout(id); document.removeEventListener('click', handler); };
180
+ }, [showAttachMenu]);
268
181
 
269
182
  const formRef = useRef<HTMLFormElement>(null);
270
183
 
@@ -272,11 +185,8 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
272
185
  if (!visible) return;
273
186
  const el = inputRef.current;
274
187
  if (!el || !(el instanceof HTMLTextAreaElement)) return;
275
- const form = formRef.current;
276
- const maxLines = isPanel ? PANEL_TEXTAREA_MAX_VISIBLE_LINES : 6;
277
- const availableH = isPanel && form ? form.clientHeight - 40 : undefined;
278
- syncTextareaToContent(el, maxLines, availableH);
279
- }, [input, isPanel, isLoading, visible, panelComposerHeight]);
188
+ syncTextareaToContent(el, TEXTAREA_MAX_VISIBLE_LINES);
189
+ }, [input, isLoading, visible]);
280
190
 
281
191
  const mentionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
282
192
  const slashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -372,12 +282,12 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
372
282
  }
373
283
  return;
374
284
  }
375
- if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && !isLoading && input.trim()) {
285
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && !isLoading && (input.trim() || imageUpload.images.length > 0)) {
376
286
  e.preventDefault();
377
287
  (e.currentTarget as HTMLTextAreaElement).form?.requestSubmit();
378
288
  }
379
289
  },
380
- [mention, selectMention, slash, selectSlashCommand, isLoading, input],
290
+ [mention, selectMention, slash, selectSlashCommand, isLoading, input, imageUpload.images],
381
291
  );
382
292
 
383
293
  const handleStop = useCallback(() => { abortRef.current?.abort(); }, []);
@@ -386,21 +296,22 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
386
296
  e.preventDefault();
387
297
  if (mention.mentionQuery !== null || slash.slashQuery !== null) return;
388
298
  const text = input.trim();
389
- if (!text || isLoading) return;
299
+ if ((!text && imageUpload.images.length === 0) || isLoading) return;
390
300
 
391
- const content = selectedSkill
392
- ? `Use the skill ${selectedSkill.name}: ${text}`
393
- : text;
301
+ const pendingImages = imageUpload.images.length > 0 ? [...imageUpload.images] : undefined;
394
302
  const userMsg: Message = {
395
303
  role: 'user',
396
- content,
304
+ content: text, // No [ACP:] prefix — pass clean text
397
305
  timestamp: Date.now(),
398
306
  ...(selectedSkill && { skillName: selectedSkill.name }),
307
+ ...(pendingImages && { images: pendingImages }),
399
308
  };
309
+ imageUpload.clearImages();
400
310
  const requestMessages = [...session.messages, userMsg];
401
311
  session.setMessages([...requestMessages, { role: 'assistant', content: '', timestamp: Date.now() }]);
402
312
  setInput('');
403
313
  setSelectedSkill(null);
314
+ setSelectedAcpAgent(null);
404
315
  if (onFirstMessage && !firstMessageFired.current) {
405
316
  firstMessageFired.current = true;
406
317
  onFirstMessage();
@@ -430,6 +341,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
430
341
  ? f.content.slice(0, 20_000) + '\n\n[...truncated to first ~20000 chars]'
431
342
  : f.content,
432
343
  })),
344
+ selectedAcpAgent, // Send structured field instead of text prefix
433
345
  });
434
346
 
435
347
  const doFetch = async (): Promise<{ finalMessage: Message }> => {
@@ -547,7 +459,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
547
459
  setReconnectAttempt(0);
548
460
  abortRef.current = null;
549
461
  }
550
- }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, slash.slashQuery, selectedSkill, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
462
+ }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, imageUpload.images, imageUpload.clearImages, mention.mentionQuery, slash.slashQuery, selectedSkill, selectedAcpAgent, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
551
463
 
552
464
  const handleResetSession = useCallback(() => {
553
465
  if (isLoading) return;
@@ -555,15 +467,18 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
555
467
  setInput('');
556
468
  setAttachedFiles(currentFile ? [currentFile] : []);
557
469
  upload.clearAttachments();
470
+ imageUpload.clearImages();
558
471
  mention.resetMention();
559
472
  slash.resetSlash();
560
473
  setSelectedSkill(null);
474
+ setSelectedAcpAgent(null);
561
475
  setShowHistory(false);
562
476
  setTimeout(() => inputRef.current?.focus(), 0);
563
- }, [isLoading, currentFile, session, upload, mention, slash]);
477
+ }, [isLoading, currentFile, session, upload, imageUpload, mention, slash]);
564
478
 
565
479
  const handleDragOver = useCallback((e: React.DragEvent) => {
566
- if (e.dataTransfer.types.includes('text/mindos-path')) {
480
+ // Accept mindos file paths and image drops
481
+ if (e.dataTransfer.types.includes('text/mindos-path') || e.dataTransfer.types.includes('Files')) {
567
482
  e.preventDefault();
568
483
  e.dataTransfer.dropEffect = 'copy';
569
484
  setIsDragOver(true);
@@ -572,14 +487,32 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
572
487
 
573
488
  const handleDragLeave = useCallback(() => setIsDragOver(false), []);
574
489
 
575
- const handleDrop = useCallback((e: React.DragEvent) => {
490
+ const handleDrop = useCallback(async (e: React.DragEvent) => {
576
491
  e.preventDefault();
577
492
  setIsDragOver(false);
493
+ // Try mindos file path first
578
494
  const filePath = e.dataTransfer.getData('text/mindos-path');
579
495
  if (filePath && !attachedFiles.includes(filePath)) {
580
496
  setAttachedFiles(prev => [...prev, filePath]);
497
+ return;
498
+ }
499
+ // Try image drop
500
+ await imageUpload.handleDrop(e);
501
+ }, [attachedFiles, imageUpload]);
502
+
503
+ /** Handle paste — intercept images before normal text paste */
504
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
505
+ const items = e.clipboardData?.items;
506
+ if (!items) return;
507
+ // Check synchronously for image items — must preventDefault before awaiting
508
+ const hasImageItem = Array.from(items).some(
509
+ item => item.kind === 'file' && item.type.startsWith('image/')
510
+ );
511
+ if (hasImageItem) {
512
+ e.preventDefault();
513
+ void imageUpload.handlePaste(e);
581
514
  }
582
- }, [attachedFiles]);
515
+ }, [imageUpload]);
583
516
 
584
517
  const handleLoadSession = useCallback((id: string) => {
585
518
  session.loadSession(id);
@@ -587,14 +520,16 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
587
520
  setInput('');
588
521
  setAttachedFiles(currentFile ? [currentFile] : []);
589
522
  upload.clearAttachments();
523
+ imageUpload.clearImages();
590
524
  mention.resetMention();
591
525
  slash.resetSlash();
592
526
  setSelectedSkill(null);
527
+ setSelectedAcpAgent(null);
593
528
  setTimeout(() => inputRef.current?.focus(), 0);
594
- }, [session, currentFile, upload, mention, slash]);
529
+ }, [session, currentFile, upload, imageUpload, mention, slash]);
595
530
 
596
531
  const iconSize = isPanel ? 13 : 14;
597
- const inputIconSize = isPanel ? 14 : 15;
532
+ const inputIconSize = 15;
598
533
 
599
534
  return (
600
535
  <>
@@ -679,7 +614,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
679
614
 
680
615
  {/* Popovers — flex children so they stay within overflow boundary (absolute positioning would be clipped by RightAskPanel's overflow-hidden) */}
681
616
  {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
682
- <div className="shrink-0 px-2 pb-1">
617
+ <div className="shrink-0 px-3 pb-1">
683
618
  <MentionPopover
684
619
  results={mention.mentionResults}
685
620
  selectedIndex={mention.mentionIndex}
@@ -690,7 +625,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
690
625
  )}
691
626
 
692
627
  {slash.slashQuery !== null && slash.slashResults.length > 0 && (
693
- <div className="shrink-0 px-2 pb-1">
628
+ <div className="shrink-0 px-3 pb-1">
694
629
  <SlashCommandPopover
695
630
  results={slash.slashResults}
696
631
  selectedIndex={slash.slashIndex}
@@ -700,99 +635,111 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
700
635
  </div>
701
636
  )}
702
637
 
703
- {/* Input area — panel: fixed-height shell + top drag handle (persisted); modal: simple block */}
638
+ {/* Input area — auto-height composer, no manual resize */}
704
639
  <div
705
640
  className={cn(
706
641
  'shrink-0 border-t border-border',
707
- isPanel && 'flex flex-col overflow-hidden bg-card',
708
642
  isDragOver && 'ring-2 ring-[var(--amber)] ring-inset bg-[var(--amber-dim)]',
709
643
  )}
710
- style={isPanel ? { height: panelComposerHeight } : undefined}
711
644
  onDragOver={handleDragOver}
712
645
  onDragLeave={handleDragLeave}
713
646
  onDrop={handleDrop}
714
647
  >
715
- {isPanel ? (
716
- <div
717
- role="separator"
718
- tabIndex={0}
719
- aria-orientation="horizontal"
720
- aria-label={`${t.ask.panelComposerResize}. ${t.ask.panelComposerResetHint}. ${t.ask.panelComposerKeyboard}`}
721
- aria-valuemin={PANEL_COMPOSER_MIN}
722
- aria-valuemax={panelComposerViewportMax}
723
- aria-valuenow={panelComposerHeight}
724
- title={`${t.ask.panelComposerResize} · ${t.ask.panelComposerResetHint} · ${t.ask.panelComposerKeyboard}`}
725
- onPointerDown={onPanelComposerResizePointerDown}
726
- onKeyDown={handlePanelComposerSeparatorKeyDown}
727
- onDoubleClick={resetPanelComposerHeight}
728
- className="group flex h-3 shrink-0 cursor-ns-resize items-center justify-center border-b border-border/50 bg-muted/[0.06] transition-colors hover:bg-muted/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
729
- >
730
- <span
731
- className="pointer-events-none h-1 w-10 rounded-full bg-border transition-colors group-hover:bg-[var(--amber)]/45 group-active:bg-[var(--amber)]/60"
732
- aria-hidden
733
- />
734
- </div>
735
- ) : null}
736
648
 
737
- <div className={cn(isPanel && 'flex min-h-0 flex-1 flex-col overflow-y-auto')}>
738
- {attachedFiles.length > 0 && (
739
- <div className={cn('shrink-0', isPanel ? 'px-3 pt-2 pb-1' : 'px-4 pt-2.5 pb-1')}>
740
- <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
741
- {t.ask.attachFile}
742
- </div>
743
- <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
744
- {attachedFiles.map(f => (
745
- <FileChip key={f} path={f} onRemove={() => setAttachedFiles(prev => prev.filter(x => x !== f))} />
746
- ))}
747
- </div>
649
+ {/* Unified context chip flow */}
650
+ {(attachedFiles.length > 0 || upload.localAttachments.length > 0 || imageUpload.images.length > 0 || selectedSkill || selectedAcpAgent || upload.uploadError || imageUpload.imageError) && (
651
+ <div className={cn('shrink-0 px-3 pt-2 pb-1', isPanel ? 'max-h-24 overflow-y-auto' : 'max-h-28 overflow-y-auto')}>
652
+ <div className="flex flex-wrap gap-1.5">
653
+ {/* KB files (@ attached) */}
654
+ {attachedFiles.map(f => (
655
+ <FileChip key={f} path={f} variant="kb" onRemove={() => setAttachedFiles(prev => prev.filter(x => x !== f))} />
656
+ ))}
657
+ {/* Uploaded files */}
658
+ {upload.localAttachments.map((f, idx) => (
659
+ <FileChip key={`up-${f.name}-${idx}`} path={f.name} variant="upload" onRemove={() => upload.removeAttachment(idx)} />
660
+ ))}
661
+ {/* Images (name chip + hover preview) */}
662
+ {imageUpload.images.map((img, idx) => (
663
+ <FileChip
664
+ key={`img-${idx}`}
665
+ path={`Image ${idx + 1}`}
666
+ variant="image"
667
+ imageData={img.data}
668
+ imageMime={img.mimeType}
669
+ onRemove={() => imageUpload.removeImage(idx)}
670
+ />
671
+ ))}
672
+ {/* Skill */}
673
+ {selectedSkill && (
674
+ <FileChip
675
+ path={selectedSkill.name}
676
+ variant="skill"
677
+ onRemove={() => { setSelectedSkill(null); inputRef.current?.focus(); }}
678
+ />
679
+ )}
680
+ {/* Agent */}
681
+ {selectedAcpAgent && (
682
+ <FileChip
683
+ path={selectedAcpAgent.name}
684
+ variant="agent"
685
+ onRemove={() => { setSelectedAcpAgent(null); inputRef.current?.focus(); }}
686
+ />
687
+ )}
748
688
  </div>
749
- )}
689
+ {/* Errors (merged) */}
690
+ {(upload.uploadError || imageUpload.imageError) && (
691
+ <div className="mt-1 text-xs text-error">{upload.uploadError || imageUpload.imageError}</div>
692
+ )}
693
+ </div>
694
+ )}
750
695
 
751
- {upload.localAttachments.length > 0 && (
752
- <div className={cn('shrink-0', isPanel ? 'px-3 pb-1' : 'px-4 pb-1')}>
753
- <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
754
- {t.ask.uploadedFiles}
755
- </div>
756
- <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
757
- {upload.localAttachments.map((f, idx) => (
758
- <FileChip key={`${f.name}-${idx}`} path={f.name} variant="upload" onRemove={() => upload.removeAttachment(idx)} />
759
- ))}
760
- </div>
761
- </div>
762
- )}
696
+ {/* Agent selector only when no agent selected but agents available (mounted guard for hydration) */}
697
+ {mounted && !selectedAcpAgent && acpDetection.installedAgents.length > 0 && (
698
+ <div className="px-3 pt-1 pb-0.5">
699
+ <AgentSelectorCapsule
700
+ selectedAgent={null}
701
+ onSelect={setSelectedAcpAgent}
702
+ installedAgents={acpDetection.installedAgents}
703
+ loading={acpDetection.loading}
704
+ />
705
+ </div>
706
+ )}
763
707
 
764
- {selectedSkill && (
765
- <div className={cn('shrink-0', isPanel ? 'px-3 pt-1.5 pb-1' : 'px-4 pt-2 pb-1')}>
766
- <span className="inline-flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-md text-xs bg-[var(--amber)]/10 border border-[var(--amber)]/25 text-foreground">
767
- <Zap size={11} className="text-[var(--amber)] shrink-0" />
768
- <span className="font-medium">{selectedSkill.name}</span>
708
+ {/* Input form */}
709
+ <form
710
+ ref={formRef}
711
+ onSubmit={handleSubmit}
712
+ className="flex items-end gap-1.5 px-3 py-2"
713
+ >
714
+ {/* + attach button with mini menu */}
715
+ <div className="relative shrink-0">
716
+ <button
717
+ type="button"
718
+ onClick={() => setShowAttachMenu(v => !v)}
719
+ className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
720
+ title={t.hints.attachFile}
721
+ >
722
+ <Plus size={inputIconSize} />
723
+ </button>
724
+ {showAttachMenu && (
725
+ <div className="absolute bottom-full left-0 mb-1 py-1 rounded-lg border border-border bg-card shadow-lg z-50 min-w-[140px]">
769
726
  <button
770
727
  type="button"
771
- onClick={() => { setSelectedSkill(null); inputRef.current?.focus(); }}
772
- className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors shrink-0"
773
- aria-label={`Remove skill ${selectedSkill.name}`}
728
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted transition-colors text-left"
729
+ onClick={() => { setShowAttachMenu(false); upload.uploadInputRef.current?.click(); }}
774
730
  >
775
- <X size={10} />
731
+ File
776
732
  </button>
777
- </span>
778
- </div>
779
- )}
780
-
781
- {upload.uploadError && (
782
- <div className={cn('shrink-0 pb-1 text-xs text-error', isPanel ? 'px-3' : 'px-4')}>{upload.uploadError}</div>
783
- )}
784
-
785
- <form
786
- ref={formRef}
787
- onSubmit={handleSubmit}
788
- className={cn(
789
- 'flex',
790
- isPanel ? 'min-h-0 flex-1 items-end gap-1.5 px-2 py-2' : 'items-end gap-2 px-3 py-3',
733
+ <button
734
+ type="button"
735
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted transition-colors text-left"
736
+ onClick={() => { setShowAttachMenu(false); imageInputRef.current?.click(); }}
737
+ >
738
+ Image
739
+ </button>
740
+ </div>
791
741
  )}
792
- >
793
- <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={t.hints.attachFile}>
794
- <Paperclip size={inputIconSize} />
795
- </button>
742
+ </div>
796
743
 
797
744
  <input
798
745
  ref={upload.uploadInputRef}
@@ -806,6 +753,18 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
806
753
  inputEl.value = '';
807
754
  }}
808
755
  />
756
+ <input
757
+ ref={imageInputRef}
758
+ type="file"
759
+ className="hidden"
760
+ multiple
761
+ accept="image/png,image/jpeg,image/gif,image/webp"
762
+ onChange={async (e) => {
763
+ const inputEl = e.currentTarget;
764
+ await imageUpload.handleFileSelect(inputEl.files);
765
+ inputEl.value = '';
766
+ }}
767
+ />
809
768
 
810
769
  <textarea
811
770
  ref={(el) => {
@@ -814,12 +773,10 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
814
773
  value={input}
815
774
  onChange={e => handleInputChange(e.target.value, e.target.selectionStart ?? undefined)}
816
775
  onKeyDown={handleInputKeyDown}
776
+ onPaste={handlePaste}
817
777
  placeholder={t.ask.placeholder}
818
778
  rows={1}
819
- className={cn(
820
- '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',
821
- isPanel ? 'py-2' : 'py-1.5',
822
- )}
779
+ className="min-w-0 flex-1 resize-none overflow-y-hidden bg-transparent py-1.5 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none focus-visible:ring-0"
823
780
  />
824
781
 
825
782
  {isLoading ? (
@@ -827,30 +784,31 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
827
784
  {loadingPhase === 'reconnecting' ? <X size={inputIconSize} /> : <StopCircle size={inputIconSize} />}
828
785
  </button>
829
786
  ) : (
830
- <button type="submit" disabled={!input.trim() || mention.mentionQuery !== null || slash.slashQuery !== null} title={!input.trim() ? t.hints.typeMessage : mention.mentionQuery !== null || slash.slashQuery !== null ? t.hints.mentionInProgress : undefined} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0 bg-[var(--amber)] text-[var(--amber-foreground)]">
831
- <Send size={isPanel ? 13 : 14} />
787
+ <button type="submit" disabled={!input.trim() && imageUpload.images.length === 0} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0 bg-[var(--amber)] text-[var(--amber-foreground)]">
788
+ <Send size={14} />
832
789
  </button>
833
790
  )}
834
- </form>
835
- </div>
791
+ </form>
836
792
  </div>
837
793
 
838
- {/* Footer hints — use full class strings so Tailwind JIT includes utilities */}
794
+ {/* Footer hints — panel: compact 3 items; modal: full set */}
839
795
  <div
840
796
  className={cn(
841
- 'flex shrink-0 items-center',
797
+ 'flex shrink-0 items-center flex-wrap px-3 pb-1.5',
842
798
  isPanel
843
- ? 'flex-wrap gap-x-2 gap-y-1 px-3 pb-1.5 text-[10px] text-muted-foreground/40'
844
- : 'flex-wrap gap-x-3 gap-y-1 px-4 pb-2 text-[10px] md:text-xs text-muted-foreground/50',
799
+ ? 'gap-x-3 gap-y-1 text-[10px] text-muted-foreground/40'
800
+ : 'gap-x-3 gap-y-1 text-[10px] md:text-xs text-muted-foreground/50',
845
801
  )}
846
802
  >
847
803
  <span suppressHydrationWarning>
848
804
  <kbd className="font-mono">↵</kbd> {t.ask.send}
849
805
  </span>
850
- <span suppressHydrationWarning>
851
- <kbd className="font-mono">⇧</kbd>
852
- <kbd className="font-mono ml-0.5">↵</kbd> {t.ask.newlineHint}
853
- </span>
806
+ {!isPanel && (
807
+ <span suppressHydrationWarning>
808
+ <kbd className="font-mono">⇧</kbd>
809
+ <kbd className="font-mono ml-0.5">↵</kbd> {t.ask.newlineHint}
810
+ </span>
811
+ )}
854
812
  <span suppressHydrationWarning>
855
813
  <kbd className="font-mono">@</kbd> {t.ask.attachFile}
856
814
  </span>
@@ -863,7 +821,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
863
821
  </span>
864
822
  )}
865
823
  {isLoading && input.trim() && (
866
- <span className={isPanel ? 'text-[10px] text-[var(--amber)]/80' : 'text-xs text-[var(--amber)]/80'}>
824
+ <span className="text-[10px] text-[var(--amber)]/80">
867
825
  {t.ask.draftingHint}
868
826
  </span>
869
827
  )}