@kirosnn/mosaic 0.0.9 → 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 +83 -19
  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
@@ -9,12 +9,13 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
9
9
  write: 'Write',
10
10
  edit: 'Edit',
11
11
  list: 'List',
12
- create_directory: 'Mkdir',
13
12
  glob: 'Glob',
14
13
  grep: 'Grep',
15
14
  bash: 'Command',
16
15
  question: 'Question',
17
16
  explore: 'Explore',
17
+ fetch: 'Fetch',
18
+ plan: 'Plan',
18
19
  };
19
20
 
20
21
  function getToolDisplayName(toolName: string): string {
@@ -48,11 +49,15 @@ function formatKnownToolArgs(toolName: string, args: Record<string, unknown>): s
48
49
  case 'write':
49
50
  case 'edit':
50
51
  case 'list':
51
- case 'create_directory':
52
52
  case 'glob':
53
53
  case 'grep':
54
54
  case 'bash':
55
- case 'explore': {
55
+ case 'explore':
56
+ case 'fetch': {
57
+ return null;
58
+ }
59
+
60
+ case 'plan': {
56
61
  return null;
57
62
  }
58
63
 
@@ -89,37 +94,83 @@ export function isToolSuccess(result: unknown): boolean {
89
94
 
90
95
  function formatToolHeader(toolName: string, args: Record<string, unknown>): string {
91
96
  const displayName = getToolDisplayName(toolName);
92
- const path = typeof args.path === 'string' ? args.path : '';
93
97
 
94
98
  switch (toolName) {
95
99
  case 'read':
100
+ const readPath = typeof args.path === 'string' ? args.path : '';
101
+ return readPath ? `${displayName} (${readPath})` : displayName;
96
102
  case 'write':
103
+ const writePath = typeof args.path === 'string' ? args.path : '';
104
+ return writePath ? `${displayName} (${writePath})` : displayName;
97
105
  case 'edit':
106
+ const editPath = typeof args.path === 'string' ? args.path : '';
107
+ return editPath ? `${displayName} (${editPath})` : displayName;
98
108
  case 'list':
99
- case 'create_directory':
100
- return path ? `${displayName} (${path})` : displayName;
109
+ const listPath = typeof args.path === 'string' ? args.path : '';
110
+ return listPath ? `${displayName} (${listPath})` : displayName;
101
111
  case 'glob': {
102
112
  const pattern = typeof args.pattern === 'string' ? args.pattern : '';
103
113
  return pattern ? `${displayName} (${pattern})` : displayName;
104
114
  }
105
115
  case 'grep': {
106
- const pattern = typeof args.file_pattern === 'string' ? args.file_pattern : '';
107
- return pattern ? `${displayName} (pattern: ${pattern})` : displayName;
116
+ const query = typeof args.query === 'string' ? args.query : '';
117
+ const fileType = typeof args.file_type === 'string' ? args.file_type : '';
118
+ const pattern = typeof args.pattern === 'string' ? args.pattern : '';
119
+ const info = fileType ? `*.${fileType}` : pattern;
120
+ const cleanQuery = query.replace(/[\r\n]+/g, ' ').trim();
121
+ const queryShort = cleanQuery.length > 30 ? cleanQuery.slice(0, 30) + '...' : cleanQuery;
122
+ return info ? `${displayName} ("${queryShort}" in ${info})` : `${displayName} ("${queryShort}")`;
108
123
  }
109
124
  case 'bash': {
110
125
  const command = typeof args.command === 'string' ? args.command : '';
111
- const cleanCommand = command.replace(/\s+--timeout\s+\d+$/, '');
126
+ const cleanCommand = command.replace(/[\r\n]+/g, ' ').trim().replace(/\s+--timeout\s+\d+$/, '');
112
127
  return cleanCommand ? `${displayName} (${cleanCommand})` : displayName;
113
128
  }
114
129
  case 'explore': {
115
130
  const purpose = typeof args.purpose === 'string' ? args.purpose : '';
116
- return purpose ? `${displayName} (${purpose})` : displayName;
131
+ const cleanPurpose = purpose.replace(/[\r\n]+/g, ' ').trim();
132
+ return cleanPurpose ? `${displayName} (${cleanPurpose})` : displayName;
117
133
  }
134
+ case 'fetch': {
135
+ const url = typeof args.url === 'string' ? args.url : '';
136
+ try {
137
+ const urlObj = new URL(url);
138
+ const shortUrl = urlObj.hostname + (urlObj.pathname !== '/' ? urlObj.pathname : '');
139
+ return shortUrl ? `${displayName} (${shortUrl})` : displayName;
140
+ } catch {
141
+ return url ? `${displayName} (${url})` : displayName;
142
+ }
143
+ }
144
+ case 'plan':
145
+ return displayName;
118
146
  default:
119
147
  return displayName;
120
148
  }
121
149
  }
122
150
 
151
+ function formatPlanHeader(result: unknown): string {
152
+ const displayName = getToolDisplayName('plan');
153
+ if (!result || typeof result !== 'object') return displayName;
154
+ const obj = result as Record<string, unknown>;
155
+ const planItems = Array.isArray(obj.plan) ? obj.plan : [];
156
+ const total = planItems.length;
157
+ if (total === 0) return displayName;
158
+
159
+ let completed = 0;
160
+ let inProgress = 0;
161
+
162
+ for (const item of planItems) {
163
+ if (!item || typeof item !== 'object') continue;
164
+ const status = typeof (item as Record<string, unknown>).status === 'string'
165
+ ? (item as Record<string, unknown>).status
166
+ : 'pending';
167
+ if (status === 'completed') completed += 1;
168
+ if (status === 'in_progress') inProgress += 1;
169
+ }
170
+
171
+ return `${displayName} (${completed}/${total} done, ${inProgress} in progress)`;
172
+ }
173
+
123
174
  export function parseToolHeader(toolName: string, args: Record<string, unknown>): { name: string; info: string | null } {
124
175
  const displayName = getToolDisplayName(toolName);
125
176
  const path = typeof args.path === 'string' ? args.path : '';
@@ -136,18 +187,36 @@ export function parseToolHeader(toolName: string, args: Record<string, unknown>)
136
187
  return { name: displayName, info: pattern || null };
137
188
  }
138
189
  case 'grep': {
139
- const pattern = typeof args.file_pattern === 'string' ? args.file_pattern : '';
140
- return { name: displayName, info: pattern ? `pattern: ${pattern}` : null };
190
+ const query = typeof args.query === 'string' ? args.query : '';
191
+ const fileType = typeof args.file_type === 'string' ? args.file_type : '';
192
+ const pattern = typeof args.pattern === 'string' ? args.pattern : '';
193
+ const fileInfo = fileType ? `*.${fileType}` : pattern;
194
+ const cleanQuery = query.replace(/[\r\n]+/g, ' ').trim();
195
+ const queryShort = cleanQuery.length > 30 ? cleanQuery.slice(0, 30) + '...' : cleanQuery;
196
+ const info = fileInfo ? `"${queryShort}" in ${fileInfo}` : `"${queryShort}"`;
197
+ return { name: displayName, info };
141
198
  }
142
199
  case 'bash': {
143
200
  const command = typeof args.command === 'string' ? args.command : '';
144
- const cleanCommand = command.replace(/\s+--timeout\s+\d+$/, '');
201
+ const cleanCommand = command.replace(/[\r\n]+/g, ' ').trim().replace(/\s+--timeout\s+\d+$/, '');
145
202
  return { name: displayName, info: cleanCommand || null };
146
203
  }
147
204
  case 'explore': {
148
205
  const purpose = typeof args.purpose === 'string' ? args.purpose : '';
149
206
  return { name: displayName, info: purpose || null };
150
207
  }
208
+ case 'fetch': {
209
+ const url = typeof args.url === 'string' ? args.url : '';
210
+ try {
211
+ const urlObj = new URL(url);
212
+ const shortUrl = urlObj.hostname + (urlObj.pathname !== '/' ? urlObj.pathname : '');
213
+ return { name: displayName, info: shortUrl || null };
214
+ } catch {
215
+ return { name: displayName, info: url || null };
216
+ }
217
+ }
218
+ case 'plan':
219
+ return { name: displayName, info: null };
151
220
  default:
152
221
  return { name: displayName, info: null };
153
222
  }
@@ -188,38 +257,18 @@ function formatListTree(result: unknown): string[] {
188
257
  }
189
258
 
190
259
  function formatGrepResult(result: unknown): string[] {
191
- if (typeof result !== 'string') return [];
260
+ if (typeof result !== 'string') return ['No results returned'];
261
+
192
262
  try {
193
263
  const parsed = JSON.parse(result);
194
264
 
195
- if (Array.isArray(parsed)) {
196
- if (parsed.length === 0) return ['No results'];
197
-
198
- if (typeof parsed[0] === 'string') {
199
- return parsed.map(file => ` ${file}`);
200
- }
201
-
202
- if (typeof parsed[0] === 'object' && parsed[0] !== null) {
203
- const lines: string[] = [];
204
- for (const item of parsed) {
205
- if (item.file) {
206
- lines.push(`${item.file}:`);
207
- if (Array.isArray(item.matches)) {
208
- for (const match of item.matches) {
209
- if (match.line && match.content) {
210
- lines.push(` ${match.line}: ${match.content.trim()}`);
211
- }
212
- }
213
- }
214
- }
215
- }
216
- return lines.length > 0 ? lines : ['No matches'];
217
- }
265
+ if (typeof parsed.total_matches === 'number' && typeof parsed.files_with_matches === 'number') {
266
+ return [`${parsed.total_matches} matches in ${parsed.files_with_matches} files`];
218
267
  }
219
268
 
220
- return [result];
269
+ return ['No results returned'];
221
270
  } catch {
222
- return typeof result === 'string' ? [result] : [];
271
+ return ['No results returned'];
223
272
  }
224
273
  }
225
274
 
@@ -238,7 +287,15 @@ function getToolErrorText(result: unknown): string | null {
238
287
 
239
288
  function formatToolBodyLines(toolName: string, args: Record<string, unknown>, result: unknown): string[] {
240
289
  const errorText = getToolErrorText(result);
241
- if (errorText) return [`Tool error: ${errorText}`];
290
+ if (errorText) {
291
+ if (toolName === 'fetch') {
292
+ const statusMatch = errorText.match(/HTTP (\d+(?: [a-zA-Z ]+)?)/);
293
+ if (statusMatch) {
294
+ return [`${statusMatch[1]} - Failed to fetch`];
295
+ }
296
+ }
297
+ return [`Tool error: ${errorText}`];
298
+ }
242
299
 
243
300
  switch (toolName) {
244
301
  case 'read': {
@@ -318,6 +375,67 @@ function formatToolBodyLines(toolName: string, args: Record<string, unknown>, re
318
375
  return ['Exploration completed'];
319
376
  }
320
377
 
378
+ case 'fetch': {
379
+ if (typeof result === 'string') {
380
+ const url = typeof args.url === 'string' ? args.url : 'URL';
381
+ const statusMatch = result.match(/\*\*Status:\*\* (\d+(?: [a-zA-Z ]+)?)/);
382
+ if (statusMatch) {
383
+ return [`${statusMatch[1]} - Fetched ${url}`];
384
+ }
385
+ return [`Fetched ${url}`];
386
+ }
387
+ return ['Fetch completed'];
388
+ }
389
+
390
+ case 'plan': {
391
+ if (result && typeof result === 'object') {
392
+ const obj = result as Record<string, unknown>;
393
+ const explanation = typeof obj.explanation === 'string' ? obj.explanation.trim() : '';
394
+ const planItems = Array.isArray(obj.plan) ? obj.plan : [];
395
+ const lines: string[] = [];
396
+
397
+ if (explanation) {
398
+ lines.push(explanation);
399
+ }
400
+
401
+ const normalized = planItems
402
+ .map((item) => {
403
+ if (!item || typeof item !== 'object') return null;
404
+ const entry = item as Record<string, unknown>;
405
+ const step = typeof entry.step === 'string' ? entry.step : '';
406
+ const status = typeof entry.status === 'string' ? entry.status : 'pending';
407
+ if (!step.trim()) return null;
408
+ return { step: step.trim(), status };
409
+ })
410
+ .filter((item): item is { step: string; status: string } => !!item);
411
+
412
+ const inProgressItems = normalized.filter(item => item.status === 'in_progress');
413
+ const pendingItems = normalized.filter(item => item.status === 'pending');
414
+ const completedItems = normalized.filter(item => item.status === 'completed');
415
+
416
+ const sectionPrefix = ' ';
417
+ const itemPrefix = ' ';
418
+ const arrowPrefix = '> ';
419
+
420
+ const addSection = (label: string, items: Array<{ step: string; status: string }>, activeStep: string | null, marker: string) => {
421
+ if (items.length === 0) return;
422
+ lines.push(`${sectionPrefix}${label} (${items.length})`);
423
+ for (const item of items) {
424
+ const isActive = activeStep !== null && item.step === activeStep;
425
+ const prefix = isActive ? arrowPrefix : arrowPrefix;
426
+ lines.push(`${itemPrefix}${prefix}${marker} ${item.step}`);
427
+ }
428
+ };
429
+
430
+ addSection('In progress', inProgressItems, inProgressItems[0]?.step ?? null, '[~]');
431
+ addSection('Todo', pendingItems, null, '[ ]');
432
+ addSection('Completed', completedItems, null, '[✓]');
433
+
434
+ return lines.length > 0 ? lines : ['(no steps)'];
435
+ }
436
+ return ['(no steps)'];
437
+ }
438
+
321
439
  default: {
322
440
  const toolResultText = formatToolResult(result);
323
441
  if (!toolResultText) return [];
@@ -334,7 +452,8 @@ export function formatToolContent(
334
452
  maxLines?: number;
335
453
  }
336
454
  ): string {
337
- const lines: string[] = [formatToolHeader(toolName, args)];
455
+ const header = toolName === 'plan' ? formatPlanHeader(result) : formatToolHeader(toolName, args);
456
+ const lines: string[] = [header];
338
457
 
339
458
  const argsLine = formatKnownToolArgs(toolName, args);
340
459
  if (argsLine) lines.push(argsLine);
@@ -342,7 +461,7 @@ export function formatToolContent(
342
461
  const bodyLines = formatToolBodyLines(toolName, args, result);
343
462
  for (const line of bodyLines) lines.push(line);
344
463
 
345
- const skipTruncate = toolName === 'write' || toolName === 'edit';
464
+ const skipTruncate = toolName === 'write' || toolName === 'edit' || toolName === 'plan';
346
465
  if (skipTruncate) {
347
466
  return lines.join('\n');
348
467
  }
@@ -381,4 +500,4 @@ export function getToolWrapWidth(maxWidth: number, paragraphIndex: number): numb
381
500
 
382
501
  export function formatErrorMessage(errorType: 'API' | 'Mosaic' | 'Tool', errorMessage: string): string {
383
502
  return `${errorType} Error\n${errorMessage}`;
384
- }
503
+ }
package/src/web/app.tsx CHANGED
@@ -5,7 +5,7 @@ import { HomePage } from './components/HomePage';
5
5
  import { ChatPage } from './components/ChatPage';
6
6
  import { Message } from './types';
7
7
  import { createId, extractTitle, setDocumentTitle, formatToolMessage, parseToolHeader, formatErrorMessage, DEFAULT_MAX_TOOL_LINES, getRandomBlendWord } from './utils';
8
- import { Conversation, getAllConversations, getConversation, saveConversation, deleteConversation, createNewConversation } from './storage';
8
+ import { Conversation, getAllConversations, getConversation, saveConversation, deleteConversation, createNewConversation, mergeConversations } from './storage';
9
9
  import { QuestionRequest } from '../utils/questionBridge';
10
10
  import { ApprovalRequest } from '../utils/approvalBridge';
11
11
  import { parseRoute, navigateTo, replaceTo, Route } from './router';
@@ -42,8 +42,9 @@ function App() {
42
42
  const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null);
43
43
  const [conversations, setConversations] = useState<Conversation[]>([]);
44
44
  const [workspace, setWorkspace] = useState<string | null>(null);
45
- const [questionRequest, setQuestionRequest] = useState<QuestionRequest | null>(null);
46
- const [approvalRequest, setApprovalRequest] = useState<ApprovalRequest | null>(null);
45
+ const [questionRequest, setQuestionRequest] = useState<QuestionRequest | null>(null);
46
+ const [approvalRequest, setApprovalRequest] = useState<ApprovalRequest | null>(null);
47
+ const [requireApprovals, setRequireApprovals] = useState(true);
47
48
 
48
49
  const refreshConversations = useCallback(() => {
49
50
  setConversations(getAllConversations());
@@ -77,7 +78,28 @@ function App() {
77
78
  .then(res => res.json())
78
79
  .then(data => setWorkspace(data.workspace))
79
80
  .catch(() => { });
80
- }, [refreshConversations]);
81
+
82
+ fetch('/api/tui-conversations')
83
+ .then(res => res.ok ? res.json() : [])
84
+ .then((data: Conversation[]) => {
85
+ if (Array.isArray(data) && data.length > 0) {
86
+ const changed = mergeConversations(data);
87
+ if (changed) {
88
+ refreshConversations();
89
+ }
90
+ }
91
+ })
92
+ .catch(() => { });
93
+
94
+ fetch('/api/approvals')
95
+ .then(res => res.ok ? res.json() : null)
96
+ .then((data) => {
97
+ if (data && typeof data.requireApprovals === 'boolean') {
98
+ setRequireApprovals(data.requireApprovals);
99
+ }
100
+ })
101
+ .catch(() => { });
102
+ }, [refreshConversations]);
81
103
 
82
104
  useEffect(() => {
83
105
  if (route.page === 'chat' && route.conversationId) {
@@ -135,14 +157,15 @@ function App() {
135
157
  }
136
158
  }, [messages, currentTitle]);
137
159
 
138
- const handleSendMessage = async (content: string) => {
139
- if (!content.trim() || isProcessing) return;
160
+ const handleSendMessage = async (content: string, images: Message['images'] = []) => {
161
+ if ((!content.trim() && images.length === 0) || isProcessing) return;
140
162
 
141
- const userMessage: Message = {
142
- id: createId(),
143
- role: 'user',
144
- content: content,
145
- };
163
+ const userMessage: Message = {
164
+ id: createId(),
165
+ role: 'user',
166
+ content: content,
167
+ images: images.length > 0 ? images : undefined,
168
+ };
146
169
 
147
170
  let conversation = currentConversation;
148
171
  if (!conversation) {
@@ -167,12 +190,13 @@ function App() {
167
190
  method: 'POST',
168
191
  headers: { 'Content-Type': 'application/json' },
169
192
  body: JSON.stringify({
170
- message: userMessage.content,
171
- history: messages
172
- .filter((m) => m.role === 'user' || m.role === 'assistant')
173
- .map((m) => ({ role: m.role, content: m.content })),
174
- }),
175
- });
193
+ message: userMessage.content,
194
+ images: userMessage.images || [],
195
+ history: messages
196
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
197
+ .map((m) => ({ role: m.role, content: m.content, images: m.images || [] })),
198
+ }),
199
+ });
176
200
 
177
201
  if (!response.ok) {
178
202
  throw new Error(`HTTP error! status: ${response.status}`);
@@ -470,6 +494,13 @@ function App() {
470
494
  };
471
495
 
472
496
  const handleDeleteConversation = (conversationId: string) => {
497
+ if (conversationId.startsWith('tui_')) {
498
+ fetch('/api/tui-conversation/delete', {
499
+ method: 'POST',
500
+ headers: { 'Content-Type': 'application/json' },
501
+ body: JSON.stringify({ id: conversationId }),
502
+ }).catch(() => { });
503
+ }
473
504
  deleteConversation(conversationId);
474
505
  refreshConversations();
475
506
 
@@ -478,7 +509,7 @@ function App() {
478
509
  }
479
510
  };
480
511
 
481
- const handleRenameConversation = (conversationId: string, newTitle: string) => {
512
+ const handleRenameConversation = (conversationId: string, newTitle: string) => {
482
513
  const conversation = getConversation(conversationId);
483
514
  if (conversation) {
484
515
  const updated: Conversation = {
@@ -494,8 +525,35 @@ function App() {
494
525
  setCurrentTitle(newTitle);
495
526
  setDocumentTitle(newTitle);
496
527
  }
528
+
529
+ if (conversationId.startsWith('tui_')) {
530
+ fetch('/api/tui-conversation/rename', {
531
+ method: 'POST',
532
+ headers: { 'Content-Type': 'application/json' },
533
+ body: JSON.stringify({ id: conversationId, title: newTitle }),
534
+ }).catch(() => { });
535
+ }
497
536
  }
498
- };
537
+ };
538
+
539
+ const handleToggleApprovals = async () => {
540
+ try {
541
+ const next = !requireApprovals;
542
+ const res = await fetch('/api/approvals', {
543
+ method: 'POST',
544
+ headers: { 'Content-Type': 'application/json' },
545
+ body: JSON.stringify({ requireApprovals: next }),
546
+ });
547
+ if (res.ok) {
548
+ setRequireApprovals(next);
549
+ if (!next) {
550
+ setApprovalRequest(null);
551
+ }
552
+ }
553
+ } catch (error) {
554
+ console.error('Failed to toggle approvals:', error);
555
+ }
556
+ };
499
557
 
500
558
  const handleNavigateHome = () => {
501
559
  navigateTo({ page: 'home' });
@@ -547,20 +605,22 @@ function App() {
547
605
  sidebarProps={sidebarProps}
548
606
  />
549
607
  ) : (
550
- <ChatPage
551
- messages={messages}
552
- isProcessing={isProcessing}
553
- processingStartTime={processingStartTime}
554
- currentTokens={currentTokens}
555
- onSendMessage={handleSendMessage}
556
- onStopAgent={handleStopAgent}
557
- sidebarProps={sidebarProps}
558
- currentTitle={currentTitle}
559
- workspace={workspace}
560
- questionRequest={questionRequest}
561
- approvalRequest={approvalRequest}
562
- />
563
- )}
608
+ <ChatPage
609
+ messages={messages}
610
+ isProcessing={isProcessing}
611
+ processingStartTime={processingStartTime}
612
+ currentTokens={currentTokens}
613
+ onSendMessage={handleSendMessage}
614
+ onStopAgent={handleStopAgent}
615
+ sidebarProps={sidebarProps}
616
+ currentTitle={currentTitle}
617
+ workspace={workspace}
618
+ questionRequest={questionRequest}
619
+ approvalRequest={approvalRequest}
620
+ requireApprovals={requireApprovals}
621
+ onToggleApprovals={handleToggleApprovals}
622
+ />
623
+ )}
564
624
 
565
625
  <Modal
566
626
  isOpen={activeModal === 'settings'}
@@ -603,4 +663,4 @@ function App() {
603
663
 
604
664
 
605
665
  const root = ReactDOM.createRoot(document.getElementById('root')!);
606
- root.render(<App />);
666
+ root.render(<App />);
@@ -7,17 +7,18 @@
7
7
  overflow: hidden;
8
8
  }
9
9
 
10
- .chat-title-bar {
11
- display: flex;
12
- align-items: center;
13
- padding: 0.75rem 1.5rem;
14
- flex-shrink: 0;
15
- width: 100%;
16
- left: 0;
17
- position: sticky;
18
- top: 0;
19
- z-index: 10;
20
- }
10
+ .chat-title-bar {
11
+ display: flex;
12
+ align-items: center;
13
+ padding: 0.75rem 1.5rem;
14
+ flex-shrink: 0;
15
+ width: 100%;
16
+ left: 0;
17
+ position: sticky;
18
+ top: 0;
19
+ z-index: 10;
20
+ gap: 0.75rem;
21
+ }
21
22
 
22
23
  .chat-title {
23
24
  font-size: 0.9rem;
@@ -27,13 +28,42 @@
27
28
  cursor: default;
28
29
  }
29
30
 
30
- .chat-workspace {
31
- margin-left: auto;
32
- font-size: 0.9rem;
33
- font-family: 'Geist Medium Monospace', monospace;
34
- color: var(--text-secondary);
35
- cursor: default;
36
- }
31
+ .chat-workspace {
32
+ font-size: 0.9rem;
33
+ font-family: 'Geist Medium Monospace', monospace;
34
+ color: var(--text-secondary);
35
+ cursor: default;
36
+ }
37
+
38
+ .chat-title-actions {
39
+ margin-left: auto;
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 0.75rem;
43
+ }
44
+
45
+ .approval-toggle {
46
+ border: 1px solid var(--border-subtle);
47
+ background: transparent;
48
+ color: var(--text-secondary);
49
+ font-size: 0.8rem;
50
+ padding: 0.35rem 0.65rem;
51
+ border-radius: 999px;
52
+ cursor: pointer;
53
+ transition: all 0.15s ease;
54
+ }
55
+
56
+ .approval-toggle:hover {
57
+ background: var(--bg-hover);
58
+ color: var(--text-primary);
59
+ border-color: var(--text-muted);
60
+ }
61
+
62
+ .approval-toggle.active {
63
+ background: var(--accent-color);
64
+ border-color: var(--accent-color);
65
+ color: white;
66
+ }
37
67
 
38
68
  .chat-container {
39
69
  display: flex;
@@ -73,11 +103,11 @@
73
103
  gap: 0;
74
104
  }
75
105
 
76
- .input-area textarea {
77
- width: 100%;
78
- background: transparent;
79
- border: none;
80
- color: var(--text-primary);
106
+ .input-area textarea {
107
+ width: 100%;
108
+ background: transparent;
109
+ border: none;
110
+ color: var(--text-primary);
81
111
  font-size: 1rem;
82
112
  font-family: inherit;
83
113
  resize: none;
@@ -85,12 +115,54 @@
85
115
  min-height: 60px;
86
116
  max-height: 200px;
87
117
  padding: 0;
88
- line-height: 1.5;
89
- }
90
-
91
- .input-area textarea::placeholder {
92
- color: var(--text-muted);
93
- }
118
+ line-height: 1.5;
119
+ }
120
+
121
+ .input-area textarea::placeholder {
122
+ color: var(--text-muted);
123
+ }
124
+
125
+ .attachment-strip {
126
+ display: flex;
127
+ gap: 0.5rem;
128
+ flex-wrap: wrap;
129
+ padding-bottom: 0.5rem;
130
+ }
131
+
132
+ .attachment-item {
133
+ position: relative;
134
+ width: 64px;
135
+ height: 64px;
136
+ border-radius: 10px;
137
+ overflow: hidden;
138
+ border: 1px solid var(--border-code);
139
+ background: var(--bg-code);
140
+ }
141
+
142
+ .attachment-item img {
143
+ width: 100%;
144
+ height: 100%;
145
+ object-fit: cover;
146
+ display: block;
147
+ }
148
+
149
+ .attachment-item button {
150
+ position: absolute;
151
+ top: 4px;
152
+ right: 4px;
153
+ width: 18px;
154
+ height: 18px;
155
+ border-radius: 50%;
156
+ border: none;
157
+ background: rgba(0, 0, 0, 0.6);
158
+ color: white;
159
+ font-size: 12px;
160
+ cursor: pointer;
161
+ }
162
+
163
+ .attachment-item button:hover {
164
+ background: rgba(0, 0, 0, 0.8);
165
+ }
94
166
 
95
167
  .input-area textarea:disabled {
96
168
  opacity: 0.5;
@@ -209,4 +281,4 @@
209
281
 
210
282
  .send-btn.stop:hover {
211
283
  background: #ff3333;
212
- }
284
+ }