@kirosnn/mosaic 0.0.91 → 0.71.0

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 (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -2
  3. package/package.json +52 -47
  4. package/src/agent/prompts/systemPrompt.ts +198 -68
  5. package/src/agent/prompts/toolsPrompt.ts +217 -135
  6. package/src/agent/provider/anthropic.ts +19 -15
  7. package/src/agent/provider/google.ts +21 -17
  8. package/src/agent/provider/ollama.ts +80 -41
  9. package/src/agent/provider/openai.ts +107 -67
  10. package/src/agent/provider/reasoning.ts +29 -0
  11. package/src/agent/provider/xai.ts +19 -15
  12. package/src/agent/tools/definitions.ts +9 -5
  13. package/src/agent/tools/executor.ts +655 -46
  14. package/src/agent/tools/exploreExecutor.ts +12 -12
  15. package/src/agent/tools/fetch.ts +58 -0
  16. package/src/agent/tools/glob.ts +20 -4
  17. package/src/agent/tools/grep.ts +62 -8
  18. package/src/agent/tools/plan.ts +27 -0
  19. package/src/agent/tools/read.ts +2 -0
  20. package/src/agent/types.ts +6 -6
  21. package/src/components/App.tsx +67 -25
  22. package/src/components/CustomInput.tsx +274 -68
  23. package/src/components/Main.tsx +323 -168
  24. package/src/components/ShortcutsModal.tsx +11 -8
  25. package/src/components/main/ChatPage.tsx +217 -58
  26. package/src/components/main/HomePage.tsx +5 -1
  27. package/src/components/main/ThinkingIndicator.tsx +11 -1
  28. package/src/components/main/types.ts +11 -10
  29. package/src/index.tsx +3 -5
  30. package/src/utils/approvalBridge.ts +29 -8
  31. package/src/utils/approvalModeBridge.ts +17 -0
  32. package/src/utils/commands/approvals.ts +48 -0
  33. package/src/utils/commands/image.ts +109 -0
  34. package/src/utils/commands/index.ts +5 -1
  35. package/src/utils/diffRendering.tsx +13 -14
  36. package/src/utils/history.ts +82 -40
  37. package/src/utils/imageBridge.ts +28 -0
  38. package/src/utils/images.ts +31 -0
  39. package/src/utils/models.ts +0 -7
  40. package/src/utils/notificationBridge.ts +23 -0
  41. package/src/utils/toolFormatting.ts +162 -43
  42. package/src/web/app.tsx +94 -34
  43. package/src/web/assets/css/ChatPage.css +102 -30
  44. package/src/web/assets/css/MessageItem.css +26 -29
  45. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  46. package/src/web/assets/css/ToolMessage.css +36 -14
  47. package/src/web/components/ChatPage.tsx +228 -105
  48. package/src/web/components/HomePage.tsx +6 -6
  49. package/src/web/components/MessageItem.tsx +88 -89
  50. package/src/web/components/Setup.tsx +1 -1
  51. package/src/web/components/Sidebar.tsx +1 -1
  52. package/src/web/components/ThinkingIndicator.tsx +40 -21
  53. package/src/web/router.ts +1 -1
  54. package/src/web/server.tsx +187 -39
  55. package/src/web/storage.ts +23 -1
  56. package/src/web/types.ts +7 -6
@@ -59,12 +59,31 @@
59
59
  background: var(--status-error-dark);
60
60
  }
61
61
 
62
- .message-content {
63
- color: var(--text-primary);
64
- white-space: pre-wrap;
65
- word-break: break-word;
66
- flex: 1;
67
- }
62
+ .message-content {
63
+ color: var(--text-primary);
64
+ white-space: pre-wrap;
65
+ word-break: break-word;
66
+ flex: 1;
67
+ }
68
+
69
+ .message-images {
70
+ display: flex;
71
+ gap: 0.5rem;
72
+ flex-wrap: wrap;
73
+ margin-bottom: 0.4rem;
74
+ }
75
+
76
+ .message-images img {
77
+ width: 140px;
78
+ height: auto;
79
+ border-radius: 8px;
80
+ border: 1px solid var(--border-code);
81
+ background: var(--bg-code);
82
+ }
83
+
84
+ .message-text {
85
+ white-space: pre-wrap;
86
+ }
68
87
 
69
88
  .thinking-section {
70
89
  margin-bottom: 0.35rem;
@@ -95,7 +114,6 @@
95
114
  font-size: 0.8rem;
96
115
  color: var(--text-muted);
97
116
  overflow-x: auto;
98
- line-height: 1.4;
99
117
  }
100
118
 
101
119
  .assistant-text {
@@ -136,25 +154,4 @@
136
154
  40% {
137
155
  transform: scale(1);
138
156
  }
139
- }
140
-
141
- .blend-indicator {
142
- display: flex;
143
- align-items: center;
144
- gap: 1rem;
145
- padding: 1rem 1.5rem;
146
- margin: 0.75rem 0;
147
- }
148
-
149
- .blend-icon {
150
- width: 48px;
151
- height: 48px;
152
- fill: var(--text-muted);
153
- flex-shrink: 0;
154
- }
155
-
156
- .blend-text {
157
- font-size: 1.5rem;
158
- color: var(--text-muted);
159
- font-weight: 400;
160
- }
157
+ }
@@ -1,8 +1,14 @@
1
+ .thinking-block {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 0.15rem;
5
+ }
6
+
1
7
  .thinking-indicator {
2
8
  display: flex;
3
9
  align-items: center;
4
10
  gap: 0;
5
- padding: 0.85rem 1.15rem;
11
+ padding: 0.65rem 1.15rem 0.35rem 1.15rem;
6
12
  font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
7
13
  font-size: 0.95rem;
8
14
  }
@@ -31,17 +37,49 @@
31
37
  .thinking-elapsed {
32
38
  color: var(--text-secondary);
33
39
  opacity: 0.7;
34
- margin-left: 0.25rem;
35
40
  }
36
41
 
37
42
  .thinking-hint {
38
43
  color: var(--text-secondary);
39
- opacity: 0.5;
40
- margin-left: 0.25rem;
44
+ opacity: 0.55;
41
45
  }
42
46
 
43
47
  .thinking-tokens {
44
48
  color: var(--text-secondary);
45
49
  opacity: 0.7;
46
- margin-left: 0.25rem;
47
- }
50
+ }
51
+
52
+ .thinking-plan {
53
+ color: var(--text-secondary);
54
+ opacity: 0.7;
55
+ }
56
+
57
+ .thinking-sep {
58
+ color: var(--text-secondary);
59
+ opacity: 0.6;
60
+ }
61
+
62
+ .thinking-next-line {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 0.35rem;
66
+ padding: 0 1.15rem 0.6rem 1.15rem;
67
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
68
+ font-size: 0.9rem;
69
+ color: var(--text-secondary);
70
+ }
71
+
72
+ .thinking-next-icon {
73
+ color: #ffca38;
74
+ font-weight: 700;
75
+ }
76
+
77
+ .thinking-next-label {
78
+ font-weight: 700;
79
+ color: #ffca38;
80
+ }
81
+
82
+ .thinking-next-step {
83
+ color: var(--text-secondary);
84
+ opacity: 0.75;
85
+ }
@@ -12,14 +12,24 @@
12
12
  border: 1px solid var(--border-tool-running);
13
13
  }
14
14
 
15
- .message.tool.error {
16
- background: var(--bg-tool-error);
17
- border: 1px solid var(--border-tool-error);
18
- }
19
-
20
- .message.tool.success .message-bar {
21
- background: var(--status-success);
22
- }
15
+ .message.tool.error {
16
+ background: var(--bg-tool-error);
17
+ border: 1px solid var(--border-tool-error);
18
+ }
19
+
20
+ .message.tool.plan-tool,
21
+ .message.tool.plan-tool.running,
22
+ .message.tool.plan-tool.error,
23
+ .message.tool.plan-tool.success {
24
+ background: transparent;
25
+ border: none;
26
+ backdrop-filter: none;
27
+ -webkit-backdrop-filter: none;
28
+ }
29
+
30
+ .message.tool.success .message-bar {
31
+ background: var(--status-success);
32
+ }
23
33
 
24
34
  .message.tool.running .message-bar {
25
35
  background: var(--status-running);
@@ -107,11 +117,23 @@
107
117
  line-height: 1.4;
108
118
  }
109
119
 
110
- .tool-line {
111
- padding: 0.1rem 0.5rem;
112
- white-space: pre-wrap;
113
- word-break: break-all;
114
- }
120
+ .tool-line {
121
+ padding: 0.1rem 0.5rem;
122
+ white-space: pre-wrap;
123
+ word-break: break-all;
124
+ }
125
+
126
+ .tool-line.plan-line {
127
+ padding-left: 0.9rem;
128
+ }
129
+
130
+ .plan-prefix {
131
+ color: #ffca38;
132
+ }
133
+
134
+ .plan-bracket.active {
135
+ color: #ffca38;
136
+ }
115
137
 
116
138
  .tool-line.diff-line {
117
139
  display: flex;
@@ -145,4 +167,4 @@
145
167
  .diff-content {
146
168
  flex: 1;
147
169
  padding: 0.1rem 0.3rem;
148
- }
170
+ }
@@ -1,29 +1,33 @@
1
1
  /** @jsxImportSource react */
2
2
  import React, { useState, useEffect, useRef } from 'react';
3
- import { Message } from '../types';
4
- import { MessageItem } from './MessageItem';
5
- import { Sidebar, SidebarProps } from './Sidebar';
6
- import { QuestionRequest } from '../../utils/questionBridge';
7
- import { ApprovalRequest } from '../../utils/approvalBridge';
8
- import { QuestionPanel } from './QuestionPanel';
9
- import { ApprovalPanel } from './ApprovalPanel';
10
- import { ThinkingIndicator } from './ThinkingIndicator';
11
- import { findModelsDevModelById, modelAcceptsImages } from '../../utils/models';
12
- import '../assets/css/global.css'
13
-
14
- interface ChatPageProps {
15
- messages: Message[];
16
- isProcessing: boolean;
17
- processingStartTime?: number;
18
- currentTokens?: number;
19
- onSendMessage: (message: string) => void;
20
- onStopAgent?: () => void;
21
- sidebarProps: SidebarProps;
22
- currentTitle?: string | null;
23
- workspace?: string | null;
24
- questionRequest?: QuestionRequest | null;
25
- approvalRequest?: ApprovalRequest | null;
26
- }
3
+ import { Message } from '../types';
4
+ import { MessageItem } from './MessageItem';
5
+ import { Sidebar, SidebarProps } from './Sidebar';
6
+ import { QuestionRequest } from '../../utils/questionBridge';
7
+ import { ApprovalRequest } from '../../utils/approvalBridge';
8
+ import { QuestionPanel } from './QuestionPanel';
9
+ import { ApprovalPanel } from './ApprovalPanel';
10
+ import { ThinkingIndicator } from './ThinkingIndicator';
11
+ import { findModelsDevModelById, modelAcceptsImages } from '../../utils/models';
12
+ import type { ImageAttachment } from '../../utils/images';
13
+ import { guessImageMimeType, toDataUrl } from '../../utils/images';
14
+ import '../assets/css/global.css'
15
+
16
+ interface ChatPageProps {
17
+ messages: Message[];
18
+ isProcessing: boolean;
19
+ processingStartTime?: number;
20
+ currentTokens?: number;
21
+ onSendMessage: (message: string, images?: ImageAttachment[]) => void;
22
+ onStopAgent?: () => void;
23
+ sidebarProps: SidebarProps;
24
+ currentTitle?: string | null;
25
+ workspace?: string | null;
26
+ questionRequest?: QuestionRequest | null;
27
+ approvalRequest?: ApprovalRequest | null;
28
+ requireApprovals: boolean;
29
+ onToggleApprovals: () => void;
30
+ }
27
31
 
28
32
  function formatWorkspace(path: string | null | undefined): string {
29
33
  if (!path) return '';
@@ -55,11 +59,54 @@ function formatWorkspace(path: string | null | undefined): string {
55
59
  return normalized;
56
60
  }
57
61
 
58
- export function ChatPage({ messages, isProcessing, processingStartTime, currentTokens, onSendMessage, onStopAgent, sidebarProps, currentTitle, workspace, questionRequest, approvalRequest }: ChatPageProps) {
59
- const [inputValue, setInputValue] = useState('');
60
- const [showAttachButton, setShowAttachButton] = useState(false);
61
- const messagesEndRef = useRef<HTMLDivElement>(null);
62
- const inputRef = useRef<HTMLTextAreaElement>(null);
62
+ function getPlanProgress(messages: Message[]): { inProgressStep?: string; nextStep?: string } {
63
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
64
+ const message = messages[i];
65
+ if (!message || message.role !== 'tool' || message.toolName !== 'plan') continue;
66
+ const result = message.toolResult;
67
+ if (!result || typeof result !== 'object') continue;
68
+ const obj = result as Record<string, unknown>;
69
+ const planItems = Array.isArray(obj.plan) ? obj.plan : [];
70
+ const normalized = planItems
71
+ .map((item) => {
72
+ if (!item || typeof item !== 'object') return null;
73
+ const entry = item as Record<string, unknown>;
74
+ const step = typeof entry.step === 'string' ? entry.step.trim() : '';
75
+ const status = typeof entry.status === 'string' ? entry.status : 'pending';
76
+ if (!step) return null;
77
+ return { step, status };
78
+ })
79
+ .filter((item): item is { step: string; status: string } => !!item);
80
+
81
+ if (normalized.length === 0) return {};
82
+
83
+ const inProgressIndex = normalized.findIndex(item => item.status === 'in_progress');
84
+ const inProgressStep = inProgressIndex >= 0 ? normalized[inProgressIndex]?.step : undefined;
85
+ let nextStep: string | undefined;
86
+
87
+ if (inProgressIndex >= 0) {
88
+ const after = normalized.slice(inProgressIndex + 1).find(item => item.status === 'pending');
89
+ nextStep = after?.step;
90
+ }
91
+
92
+ if (!nextStep) {
93
+ nextStep = normalized.find(item => item.status === 'pending')?.step;
94
+ }
95
+
96
+ return { inProgressStep, nextStep };
97
+ }
98
+
99
+ return {};
100
+ }
101
+
102
+ export function ChatPage({ messages, isProcessing, processingStartTime, currentTokens, onSendMessage, onStopAgent, sidebarProps, currentTitle, workspace, questionRequest, approvalRequest, requireApprovals, onToggleApprovals }: ChatPageProps) {
103
+ const [inputValue, setInputValue] = useState('');
104
+ const [showAttachButton, setShowAttachButton] = useState(false);
105
+ const [pendingImages, setPendingImages] = useState<ImageAttachment[]>([]);
106
+ const messagesEndRef = useRef<HTMLDivElement>(null);
107
+ const inputRef = useRef<HTMLTextAreaElement>(null);
108
+ const fileInputRef = useRef<HTMLInputElement>(null);
109
+ const planProgress = getPlanProgress(messages);
63
110
 
64
111
  useEffect(() => {
65
112
  if (messagesEndRef.current) {
@@ -77,17 +124,23 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
77
124
  if (inputRef.current) inputRef.current.focus();
78
125
  }, []);
79
126
 
80
- useEffect(() => {
81
- const handleKeyDown = (e: KeyboardEvent) => {
82
- if (e.key === 'Escape' && isProcessing && onStopAgent) {
83
- e.preventDefault();
84
- onStopAgent();
85
- }
86
- };
127
+ useEffect(() => {
128
+ const handleKeyDown = (e: KeyboardEvent) => {
129
+ if (e.key === 'Escape' && isProcessing && onStopAgent) {
130
+ e.preventDefault();
131
+ onStopAgent();
132
+ }
133
+ };
87
134
 
88
135
  document.addEventListener('keydown', handleKeyDown);
89
- return () => document.removeEventListener('keydown', handleKeyDown);
90
- }, [isProcessing, onStopAgent]);
136
+ return () => document.removeEventListener('keydown', handleKeyDown);
137
+ }, [isProcessing, onStopAgent]);
138
+
139
+ useEffect(() => {
140
+ if (!showAttachButton) {
141
+ setPendingImages([]);
142
+ }
143
+ }, [showAttachButton]);
91
144
 
92
145
  useEffect(() => {
93
146
  const checkModelSupport = async () => {
@@ -98,44 +151,82 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
98
151
  const { model } = await configRes.json();
99
152
 
100
153
  if (model) {
101
- // Try to find the model using the enhanced fuzzy search
102
154
  const result = await findModelsDevModelById(model);
103
155
 
104
- if (result && result.model) {
105
- setShowAttachButton(modelAcceptsImages(result.model));
106
- } else {
107
- // Very basic fallback if even fuzzy search fails
108
- const lowerId = model.toLowerCase();
109
- const likelySupportsImages =
110
- lowerId.includes('gpt-4') ||
111
- lowerId.includes('gpt-5') ||
112
- lowerId.includes('claude-3') ||
113
- lowerId.includes('gemini') ||
114
- lowerId.includes('vision');
115
- setShowAttachButton(likelySupportsImages);
116
- }
117
- }
118
- } catch (err) {
119
- console.error('Failed to check model support:', err);
120
- }
121
- };
156
+ if (result && result.model) {
157
+ setShowAttachButton(modelAcceptsImages(result.model));
158
+ } else {
159
+ setShowAttachButton(false);
160
+ }
161
+ }
162
+ } catch (err) {
163
+ console.error('Failed to check model support:', err);
164
+ }
165
+ };
122
166
 
123
167
  checkModelSupport();
124
168
  }, []);
125
169
 
126
- const handleSubmit = (e?: React.FormEvent) => {
127
- if (e) e.preventDefault();
128
- if (!inputValue.trim() || isProcessing) return;
129
- onSendMessage(inputValue);
130
- setInputValue('');
131
- };
170
+ const handleSubmit = (e?: React.FormEvent) => {
171
+ if (e) e.preventDefault();
172
+ if ((!inputValue.trim() && pendingImages.length === 0) || isProcessing) return;
173
+ onSendMessage(inputValue, showAttachButton ? pendingImages : []);
174
+ setInputValue('');
175
+ setPendingImages([]);
176
+ };
132
177
 
133
- const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
134
- if (e.key === 'Enter' && !e.shiftKey) {
135
- e.preventDefault();
136
- handleSubmit();
137
- }
138
- };
178
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
179
+ if (e.key === 'Enter' && !e.shiftKey) {
180
+ e.preventDefault();
181
+ handleSubmit();
182
+ }
183
+ };
184
+
185
+ const toBase64 = async (file: File): Promise<string> => {
186
+ const buffer = await file.arrayBuffer();
187
+ const bytes = new Uint8Array(buffer);
188
+ let binary = '';
189
+ const chunkSize = 0x8000;
190
+ for (let i = 0; i < bytes.length; i += chunkSize) {
191
+ const chunk = bytes.subarray(i, i + chunkSize);
192
+ binary += String.fromCharCode(...Array.from(chunk));
193
+ }
194
+ return btoa(binary);
195
+ };
196
+
197
+ const handleAttachClick = () => {
198
+ fileInputRef.current?.click();
199
+ };
200
+
201
+ const handleFilesSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
202
+ if (!showAttachButton) {
203
+ e.target.value = '';
204
+ return;
205
+ }
206
+ const files = Array.from(e.target.files || []);
207
+ if (files.length === 0) return;
208
+ const attachments: ImageAttachment[] = [];
209
+ for (const file of files) {
210
+ const mimeType = file.type || guessImageMimeType(file.name);
211
+ if (!mimeType.startsWith('image/')) continue;
212
+ const data = await toBase64(file);
213
+ attachments.push({
214
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
215
+ name: file.name,
216
+ mimeType,
217
+ data,
218
+ size: file.size
219
+ });
220
+ }
221
+ if (attachments.length > 0) {
222
+ setPendingImages((prev) => [...prev, ...attachments]);
223
+ }
224
+ e.target.value = '';
225
+ };
226
+
227
+ const handleRemovePendingImage = (id: string) => {
228
+ setPendingImages((prev) => prev.filter((img) => img.id !== id));
229
+ };
139
230
 
140
231
  const handleQuestionAnswer = async (index: number, customText?: string) => {
141
232
  try {
@@ -169,16 +260,24 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
169
260
 
170
261
  <div className="main-content" style={{ padding: 0 }}>
171
262
  <div className="chat-page">
172
- {(currentTitle || workspace) && (
173
- <div className="chat-title-bar">
174
- <span className="chat-title">{currentTitle || ''}</span>
175
- {formattedWorkspace && (
176
- <span className="chat-workspace" title={workspace || ''}>
177
- {formattedWorkspace}
178
- </span>
179
- )}
180
- </div>
181
- )}
263
+ <div className="chat-title-bar">
264
+ <span className="chat-title">{currentTitle || ''}</span>
265
+ <div className="chat-title-actions">
266
+ {formattedWorkspace && (
267
+ <span className="chat-workspace" title={workspace || ''}>
268
+ {formattedWorkspace}
269
+ </span>
270
+ )}
271
+ <button
272
+ type="button"
273
+ className={`approval-toggle ${requireApprovals ? '' : 'active'}`}
274
+ onClick={onToggleApprovals}
275
+ title={requireApprovals ? 'Enable auto-approve' : 'Disable auto-approve'}
276
+ >
277
+ {requireApprovals ? 'Approvals on' : 'Auto-approve'}
278
+ </button>
279
+ </div>
280
+ </div>
182
281
  <div className="chat-container">
183
282
  <div className="messages">
184
283
  {messages.map((msg) => (
@@ -187,7 +286,12 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
187
286
  {isProcessing && !questionRequest && !approvalRequest && (
188
287
  <div className="message assistant">
189
288
  <div className="message-content">
190
- <ThinkingIndicator startTime={processingStartTime} tokens={currentTokens} />
289
+ <ThinkingIndicator
290
+ startTime={processingStartTime}
291
+ tokens={currentTokens}
292
+ inProgressStep={planProgress.inProgressStep}
293
+ nextStep={planProgress.nextStep}
294
+ />
191
295
  </div>
192
296
  </div>
193
297
  )}
@@ -217,25 +321,44 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
217
321
  <div ref={messagesEndRef} />
218
322
  </div>
219
323
 
220
- <form onSubmit={handleSubmit} className="input-area">
221
- <textarea
222
- ref={inputRef}
223
- value={inputValue}
224
- onChange={(e) => setInputValue(e.target.value)}
225
- onKeyDown={handleKeyDown}
226
- placeholder="Type your message..."
227
- rows={2}
228
- disabled={isProcessing || !!questionRequest || !!approvalRequest}
229
- />
230
- <div className="input-actions">
231
- <div className="input-actions-left">
232
- {showAttachButton && (
233
- <button type="button" className="send-btn" disabled={isProcessing} title="Attach file">
234
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: 'rotate(-45deg)' }}>
235
- <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
236
- </svg>
237
- </button>
238
- )}
324
+ <form onSubmit={handleSubmit} className="input-area">
325
+ <input
326
+ ref={fileInputRef}
327
+ type="file"
328
+ accept="image/*"
329
+ multiple
330
+ style={{ display: 'none' }}
331
+ onChange={handleFilesSelected}
332
+ />
333
+ {pendingImages.length > 0 && (
334
+ <div className="attachment-strip">
335
+ {pendingImages.map((img) => (
336
+ <div key={img.id} className="attachment-item">
337
+ <img src={toDataUrl(img)} alt={img.name} />
338
+ <button type="button" onClick={() => handleRemovePendingImage(img.id)} title="Remove">
339
+ x
340
+ </button>
341
+ </div>
342
+ ))}
343
+ </div>
344
+ )}
345
+ <textarea
346
+ ref={inputRef}
347
+ value={inputValue}
348
+ onChange={(e) => setInputValue(e.target.value)}
349
+ onKeyDown={handleKeyDown}
350
+ placeholder="Type your message..."
351
+ rows={2}
352
+ />
353
+ <div className="input-actions">
354
+ <div className="input-actions-left">
355
+ {showAttachButton && (
356
+ <button type="button" className="send-btn" title="Attach image" onClick={handleAttachClick}>
357
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: 'rotate(-45deg)' }}>
358
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
359
+ </svg>
360
+ </button>
361
+ )}
239
362
  </div>
240
363
  <div className="input-actions-right">
241
364
  {isProcessing ? (
@@ -250,12 +373,12 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
250
373
  </svg>
251
374
  </button>
252
375
  ) : (
253
- <button
254
- type="submit"
255
- className="send-btn"
256
- disabled={!inputValue.trim() || !!questionRequest || !!approvalRequest}
257
- title="Send"
258
- >
376
+ <button
377
+ type="submit"
378
+ className="send-btn"
379
+ disabled={(!inputValue.trim() && pendingImages.length === 0) || !!questionRequest || !!approvalRequest}
380
+ title="Send"
381
+ >
259
382
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
260
383
  <line x1="12" y1="19" x2="12" y2="5"></line>
261
384
  <polyline points="5 12 12 5 19 12"></polyline>
@@ -11,11 +11,11 @@ interface RecentProject {
11
11
  lastOpened: number;
12
12
  }
13
13
 
14
- interface HomePageProps {
15
- onStartChat: (message: string) => void;
16
- onOpenProject: (path: string) => void;
17
- sidebarProps: SidebarProps;
18
- }
14
+ interface HomePageProps {
15
+ onStartChat: (message: string, images?: import("../../utils/images").ImageAttachment[]) => void;
16
+ onOpenProject: (path: string) => void;
17
+ sidebarProps: SidebarProps;
18
+ }
19
19
 
20
20
  function formatRelativeTime(timestamp: number): string {
21
21
  const now = Date.now();
@@ -118,4 +118,4 @@ export function HomePage({ onStartChat, onOpenProject, sidebarProps }: HomePageP
118
118
  </Modal>
119
119
  </div>
120
120
  );
121
- }
121
+ }