repoburg 1.3.196 → 1.3.197

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 (71) hide show
  1. package/agent-pane/backend/data.db-shm +0 -0
  2. package/agent-pane/backend/data.db-wal +0 -0
  3. package/agent-pane/backend/dist/index.js +13399 -10390
  4. package/agent-pane/backend/package-lock.json +162 -92
  5. package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
  6. package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
  7. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
  8. package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
  9. package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
  10. package/agent-pane/backend/src/claude/claude-types.ts +16 -4
  11. package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
  12. package/agent-pane/backend/src/db/index.ts +102 -0
  13. package/agent-pane/backend/src/db/schema.ts +4 -0
  14. package/agent-pane/backend/src/index.ts +3 -1
  15. package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
  16. package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
  17. package/agent-pane/backend/src/integration/threads.test.ts +309 -0
  18. package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
  19. package/agent-pane/backend/src/routes/providers.ts +11 -0
  20. package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
  21. package/agent-pane/backend/src/routes/sessions.ts +133 -28
  22. package/agent-pane/backend/src/routes/threads.ts +308 -0
  23. package/agent-pane/backend/src/routes/turns.ts +32 -9
  24. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
  25. package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
  26. package/agent-pane/frontend/dist/index.html +2 -2
  27. package/agent-pane/frontend/package-lock.json +51 -9
  28. package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
  29. package/agent-pane/frontend/src/api/providers.ts +6 -1
  30. package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
  31. package/agent-pane/frontend/src/api/sessions.ts +13 -5
  32. package/agent-pane/frontend/src/api/websocket.ts +65 -1
  33. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
  34. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
  35. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
  36. package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
  37. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
  38. package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
  40. package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
  41. package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
  42. package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
  43. package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
  44. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
  45. package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
  46. package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
  47. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
  48. package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
  49. package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
  50. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
  51. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
  52. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
  53. package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
  54. package/agent-pane/frontend/src/store/store.test.ts +5 -5
  55. package/agent-pane/frontend/src/store/store.ts +4 -1
  56. package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
  57. package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
  58. package/agent-pane/frontend/src/styles/app.css +150 -19
  59. package/agent-pane/frontend/src/test/fixtures.ts +4 -0
  60. package/agent-pane/frontend/src/types/index.ts +5 -2
  61. package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
  62. package/agent-pane/frontend/vite.config.ts +2 -2
  63. package/agent-pane/shared/provider-types.ts +14 -0
  64. package/agent-pane/shared/types.ts +60 -0
  65. package/package.json +1 -1
  66. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
  67. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
  68. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
  69. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
  70. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -71
  71. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +0 -114
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest';
2
2
  import { render, screen, fireEvent, cleanup } from '@testing-library/react';
3
3
  import CollapsibleToolCalls, { isToolItem } from './CollapsibleToolCalls.tsx';
4
- import { makeItem } from '../test/fixtures.ts';
4
+ import { makeItem, makeTurn } from '../test/fixtures.ts';
5
5
 
6
6
  describe('isToolItem', () => {
7
7
  it('returns true for tool types', () => {
@@ -25,14 +25,14 @@ describe('CollapsibleToolCalls', () => {
25
25
  });
26
26
 
27
27
  it('returns null when items is empty', () => {
28
- const { container } = render(<CollapsibleToolCalls items={[]} />);
28
+ const { container } = render(<CollapsibleToolCalls items={[]} turn={makeTurn('t1', 's1')} />);
29
29
  expect(container.firstChild).toBeNull();
30
30
  });
31
31
 
32
32
  it('shows collapsed state by default (no item content visible)', () => {
33
33
  const item = makeItem('i1', 't1', 'command_execution');
34
34
  item.data = { command: 'ls', aggregated_output: '' };
35
- render(<CollapsibleToolCalls items={[item]} />);
35
+ render(<CollapsibleToolCalls items={[item]} turn={makeTurn('t1', 's1')} />);
36
36
  expect(screen.getByText(/1 tool call/)).toBeInTheDocument();
37
37
  // Item card content should NOT be rendered when collapsed
38
38
  expect(screen.queryByText('Command')).not.toBeInTheDocument();
@@ -41,7 +41,7 @@ describe('CollapsibleToolCalls', () => {
41
41
  it('expands on header click and shows item cards', () => {
42
42
  const item = makeItem('i1', 't1', 'command_execution');
43
43
  item.data = { command: 'echo hello', aggregated_output: 'hello' };
44
- render(<CollapsibleToolCalls items={[item]} />);
44
+ render(<CollapsibleToolCalls items={[item]} turn={makeTurn('t1', 's1')} />);
45
45
  const header = document.querySelector('.tool-calls-header')!;
46
46
  fireEvent.click(header);
47
47
  expect(screen.getByText('Command')).toBeInTheDocument();
@@ -1,7 +1,7 @@
1
1
  import { useState } from 'react';
2
2
  import { Wrench, ChevronRight } from 'lucide-react';
3
3
  import { MemoItemCard } from './TurnContent.tsx';
4
- import type { Item } from '../types/index.ts';
4
+ import type { Item, Turn, DiscussionThread } from '../types/index.ts';
5
5
 
6
6
  const TOOL_TYPES = new Set(['command_execution', 'file_change', 'mcp_tool_call', 'web_search']);
7
7
 
@@ -11,9 +11,12 @@ export function isToolItem(item: Item): boolean {
11
11
 
12
12
  interface Props {
13
13
  items: Item[];
14
+ runtimeType?: string;
15
+ turn: Turn;
16
+ threads?: DiscussionThread[];
14
17
  }
15
18
 
16
- export default function CollapsibleToolCalls({ items }: Props) {
19
+ export default function CollapsibleToolCalls({ items, runtimeType, turn, threads = [] }: Props) {
17
20
  const [expanded, setExpanded] = useState(false);
18
21
 
19
22
  if (items.length === 0) return null;
@@ -38,10 +41,16 @@ export default function CollapsibleToolCalls({ items }: Props) {
38
41
  {expanded && (
39
42
  <div className="tool-calls-body">
40
43
  {items.map((item) => (
41
- <MemoItemCard key={item.id} item={item} />
44
+ <MemoItemCard
45
+ key={item.id}
46
+ item={item}
47
+ runtimeType={runtimeType}
48
+ turn={turn}
49
+ threads={threads}
50
+ />
42
51
  ))}
43
52
  </div>
44
53
  )}
45
54
  </div>
46
55
  );
47
- }
56
+ }
@@ -67,7 +67,7 @@ describe('CommandInputBar', () => {
67
67
  fireEvent.change(textarea, { target: { value: 'hello world' } });
68
68
  fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true });
69
69
 
70
- expect(submitPrompt).toHaveBeenCalledWith('hello world');
70
+ expect(submitPrompt).toHaveBeenCalledWith('hello world', undefined);
71
71
  });
72
72
 
73
73
  it('send button calls submitPrompt with trimmed value', async () => {
@@ -82,7 +82,7 @@ describe('CommandInputBar', () => {
82
82
  const sendBtn = document.querySelector('.cmd-send-btn') as HTMLElement;
83
83
  fireEvent.click(sendBtn);
84
84
 
85
- expect(submitPrompt).toHaveBeenCalledWith('test');
85
+ expect(submitPrompt).toHaveBeenCalledWith('test', undefined);
86
86
  });
87
87
 
88
88
  it('send button is disabled when input is empty', () => {
@@ -1,11 +1,12 @@
1
- import { useRef, useState, useEffect } from 'react';
2
- import { ArrowUp, Square } from 'lucide-react';
1
+ import { useRef, useState, useEffect, useCallback } from 'react';
2
+ import { ArrowUp, Square, Paperclip, X, FileText } from 'lucide-react';
3
3
  import { useStore } from '../store/store.ts';
4
4
  import SlashPopup, { type SlashCommand, filterCommands } from './input/SlashPopup.tsx';
5
5
  import OptionSubPopup from './input/OptionSubPopup.tsx';
6
6
  import SkillMentionPopup from './input/SkillMentionPopup.tsx';
7
7
  import { useProvider } from '../hooks/useProvider.ts';
8
- import type { SessionRuntimeConfig, Extension } from '../types/index.ts';
8
+ import { fetchProviderCommands } from '../api/providers.ts';
9
+ import type { ProviderSlashCommand, SessionRuntimeConfig, Extension, Attachment } from '../types/index.ts';
9
10
 
10
11
  const MIN_HEIGHT = 32;
11
12
  const MAX_HEIGHT = 200;
@@ -20,17 +21,64 @@ function parseRuntimeConfig(json: string | null | undefined): SessionRuntimeConf
20
21
  }
21
22
  }
22
23
 
24
+ /** Format file size for display. */
25
+ function formatSize(bytes: number): string {
26
+ if (bytes < 1024) return `${bytes}B`;
27
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
28
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
29
+ }
30
+
31
+ /** Frontend-only attachment type with preview URL and metadata. */
32
+ interface PendingAttachment {
33
+ id: string;
34
+ name: string;
35
+ media_type: string;
36
+ data: string; // base64 (no data: URI prefix)
37
+ previewUrl?: string; // object URL for image thumbnails
38
+ size: number;
39
+ }
40
+
41
+ /** Generate a unique ID for pending attachments. */
42
+ let attachmentIdCounter = 0;
43
+ function nextAttachmentId(): string {
44
+ attachmentIdCounter += 1;
45
+ return `att-${Date.now()}-${attachmentIdCounter}`;
46
+ }
47
+
23
48
  export default function CommandInputBar() {
24
49
  const [value, setValue] = useState('');
25
50
  const taRef = useRef<HTMLTextAreaElement>(null);
51
+ const fileInputRef = useRef<HTMLInputElement>(null);
26
52
  const { submitPrompt, cancelTurn, updateRuntimeConfig, setShowModelSelector } = useStore();
27
53
  const activeSessionId = useStore((s) => s.activeSessionId);
28
54
  const detail = useStore((s) => (s.activeSessionId ? s.sessionDetails[s.activeSessionId] : null));
29
55
  const restoredPrompt = useStore((s) => s.restoredPrompt);
56
+ const quotedText = useStore((s) => s.quotedText);
30
57
  const isRunning = detail?.is_running === 1;
31
58
  const noSession = !activeSessionId;
32
59
 
33
60
  const { capabilities } = useProvider(detail?.runtime_type);
61
+ const runtimeType = detail?.runtime_type ?? null;
62
+ const canAttach = capabilities.imageAttachments;
63
+
64
+ // Attachment state
65
+ const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
66
+ const [dragOver, setDragOver] = useState(false);
67
+ const [previewImage, setPreviewImage] = useState<PendingAttachment | null>(null);
68
+
69
+ // Provider-native slash commands (e.g. Claude CLI /compact, /clear) — fetched dynamically.
70
+ const [providerCommands, setProviderCommands] = useState<ProviderSlashCommand[]>([]);
71
+ useEffect(() => {
72
+ if (!runtimeType) {
73
+ setProviderCommands([]);
74
+ return;
75
+ }
76
+ let cancelled = false;
77
+ fetchProviderCommands(runtimeType)
78
+ .then((cmds) => { if (!cancelled) setProviderCommands(cmds); })
79
+ .catch(() => { if (!cancelled) setProviderCommands([]); });
80
+ return () => { cancelled = true; };
81
+ }, [runtimeType]);
34
82
 
35
83
  // Slash popup state
36
84
  const [slashOpen, setSlashOpen] = useState(false);
@@ -41,7 +89,7 @@ export default function CommandInputBar() {
41
89
  const [skillFilter, setSkillFilter] = useState('');
42
90
  const [selectedSkills, setSelectedSkills] = useState<Extension[]>([]);
43
91
 
44
- const commands = slashOpen ? filterCommands(slashFilter, capabilities) : [];
92
+ const commands = slashOpen ? filterCommands(slashFilter, capabilities, providerCommands) : [];
45
93
 
46
94
  // Restore prompt after cancel
47
95
  useEffect(() => {
@@ -57,6 +105,23 @@ export default function CommandInputBar() {
57
105
  }
58
106
  }, [restoredPrompt]);
59
107
 
108
+ // Append quoted text to input
109
+ useEffect(() => {
110
+ if (quotedText !== null) {
111
+ setValue((prev) => prev + quotedText);
112
+ setTimeout(() => {
113
+ if (taRef.current) {
114
+ taRef.current.style.height = 'auto';
115
+ taRef.current.style.height = Math.min(taRef.current.scrollHeight, MAX_HEIGHT) + 'px';
116
+ taRef.current.focus();
117
+ const len = taRef.current.value.length;
118
+ taRef.current.setSelectionRange(len, len);
119
+ }
120
+ }, 0);
121
+ useStore.setState({ quotedText: null });
122
+ }
123
+ }, [quotedText]);
124
+
60
125
  // ESC to cancel running turn (global — textarea is disabled while running)
61
126
  useEffect(() => {
62
127
  if (!isRunning) return;
@@ -70,14 +135,108 @@ export default function CommandInputBar() {
70
135
  return () => window.removeEventListener('keydown', onKey);
71
136
  }, [isRunning, cancelTurn]);
72
137
 
73
- // Close slash popup when session changes
138
+ // Close slash popup when session changes + clear attachments
74
139
  useEffect(() => {
75
140
  setSlashOpen(false);
76
141
  setSubPopup(undefined);
77
142
  setSkillPopup(false);
78
143
  setSelectedSkills([]);
144
+ // Revoke all preview URLs and clear attachments
145
+ setAttachments((prev) => {
146
+ for (const att of prev) {
147
+ if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
148
+ }
149
+ return [];
150
+ });
79
151
  }, [activeSessionId]);
80
152
 
153
+ // Esc key to close image preview modal
154
+ useEffect(() => {
155
+ if (!previewImage) return;
156
+ function onKey(e: KeyboardEvent) {
157
+ if (e.key === 'Escape') {
158
+ e.preventDefault();
159
+ setPreviewImage(null);
160
+ }
161
+ }
162
+ window.addEventListener('keydown', onKey);
163
+ return () => window.removeEventListener('keydown', onKey);
164
+ }, [previewImage]);
165
+
166
+ // --- Attachment handlers ---
167
+
168
+ const handleFiles = useCallback((files: FileList | File[] | null): void => {
169
+ if (!files) return;
170
+ const fileArray = Array.from(files);
171
+ for (const file of fileArray) {
172
+ const reader = new FileReader();
173
+ reader.onload = () => {
174
+ const result = reader.result as string;
175
+ // result = "data:image/png;base64,iVBOR..."
176
+ const base64 = result.split(',')[1] ?? '';
177
+ const previewUrl = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
178
+ setAttachments((prev) => [...prev, {
179
+ id: nextAttachmentId(),
180
+ name: file.name,
181
+ media_type: file.type || 'application/octet-stream',
182
+ data: base64,
183
+ previewUrl,
184
+ size: file.size,
185
+ }]);
186
+ };
187
+ reader.readAsDataURL(file);
188
+ }
189
+ }, []);
190
+
191
+ const removeAttachment = useCallback((id: string): void => {
192
+ setAttachments((prev) => {
193
+ const removed = prev.find((a) => a.id === id);
194
+ if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl);
195
+ return prev.filter((a) => a.id !== id);
196
+ });
197
+ }, []);
198
+
199
+ // --- Drag and drop ---
200
+ const handleDragOver = useCallback((e: React.DragEvent) => {
201
+ e.preventDefault();
202
+ e.stopPropagation();
203
+ if (!canAttach || isRunning) return;
204
+ setDragOver(true);
205
+ }, [canAttach, isRunning]);
206
+
207
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
208
+ e.preventDefault();
209
+ e.stopPropagation();
210
+ setDragOver(false);
211
+ }, []);
212
+
213
+ const handleDrop = useCallback((e: React.DragEvent) => {
214
+ e.preventDefault();
215
+ e.stopPropagation();
216
+ setDragOver(false);
217
+ if (!canAttach || isRunning) return;
218
+ handleFiles(e.dataTransfer.files);
219
+ }, [canAttach, isRunning, handleFiles]);
220
+
221
+ // --- Paste handler ---
222
+ const handlePaste = useCallback((e: React.ClipboardEvent) => {
223
+ if (!canAttach) return;
224
+ const items = e.clipboardData?.items;
225
+ if (!items) return;
226
+ const imageFiles: File[] = [];
227
+ for (let i = 0; i < items.length; i++) {
228
+ const item = items[i];
229
+ if (item.type.startsWith('image/')) {
230
+ const file = item.getAsFile();
231
+ if (file) imageFiles.push(file);
232
+ }
233
+ }
234
+ if (imageFiles.length > 0) {
235
+ e.preventDefault();
236
+ handleFiles(imageFiles);
237
+ }
238
+ }, [canAttach, handleFiles]);
239
+
81
240
  function autoResize() {
82
241
  const el = taRef.current;
83
242
  if (!el) return;
@@ -96,16 +255,43 @@ export default function CommandInputBar() {
96
255
  prompt = `${skillPrefix}\n\n${v}`;
97
256
  }
98
257
 
258
+ // Map pending attachments to shared Attachment type (strip id, previewUrl, size)
259
+ const sentAttachments: Attachment[] = attachments.map(({ id: _id, previewUrl: _previewUrl, size: _size, ...rest }) => rest);
260
+
99
261
  setValue('');
100
262
  setSelectedSkills([]);
101
263
  if (taRef.current) taRef.current.style.height = MIN_HEIGHT + 'px';
102
264
  setSlashOpen(false);
103
265
  setSubPopup(undefined);
104
266
  setSkillPopup(false);
105
- submitPrompt(prompt);
267
+
268
+ // Revoke all preview URLs and clear attachments
269
+ for (const att of attachments) {
270
+ if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
271
+ }
272
+ setAttachments([]);
273
+
274
+ submitPrompt(prompt, sentAttachments.length > 0 ? sentAttachments : undefined);
106
275
  }
107
276
 
108
277
  function handleSlashCommand(cmd: SlashCommand) {
278
+ // Provider-native commands (e.g. /compact) — insert as text; the runtime interprets them.
279
+ if (cmd.insertAsText) {
280
+ const replacement = `/${cmd.id} `;
281
+ setValue(replacement);
282
+ setSlashOpen(false);
283
+ setSubPopup(undefined);
284
+ requestAnimationFrame(() => {
285
+ if (taRef.current) {
286
+ taRef.current.focus();
287
+ const len = replacement.length;
288
+ taRef.current.setSelectionRange(len, len);
289
+ taRef.current.style.height = 'auto';
290
+ taRef.current.style.height = Math.min(taRef.current.scrollHeight, MAX_HEIGHT) + 'px';
291
+ }
292
+ });
293
+ return;
294
+ }
109
295
  if (cmd.subPopup) {
110
296
  setSubPopup(cmd.subPopup);
111
297
  } else if (cmd.id === 'skills') {
@@ -240,7 +426,12 @@ export default function CommandInputBar() {
240
426
  ];
241
427
 
242
428
  return (
243
- <div className="cmd-bar">
429
+ <div
430
+ className={`cmd-bar${dragOver ? ' drag-over' : ''}`}
431
+ onDragOver={handleDragOver}
432
+ onDragLeave={handleDragLeave}
433
+ onDrop={handleDrop}
434
+ >
244
435
  {slashOpen && !subPopup && (
245
436
  <SlashPopup
246
437
  commands={commands}
@@ -293,28 +484,100 @@ export default function CommandInputBar() {
293
484
  ))}
294
485
  </div>
295
486
  )}
296
- <textarea
297
- ref={taRef}
298
- className="cmd-textarea"
299
- placeholder={
300
- noSession ? 'Select or create a session…' : isRunning ? 'Agent is running…' : 'Type a message… Use / for commands'
301
- }
302
- autoComplete="off"
303
- value={value}
304
- disabled={isRunning || noSession}
305
- rows={1}
306
- style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }}
307
- onChange={handleChange}
308
- onKeyDown={handleKeyDown}
309
- />
310
- {isRunning ? (
311
- <button className="cmd-stop-btn" onClick={() => cancelTurn()} title="Stop (Esc)">
312
- <Square size={15} />
313
- </button>
314
- ) : (
315
- <button className="cmd-send-btn" onClick={handleSend} disabled={!value.trim() || noSession} title="Send (⌘↵)">
316
- <ArrowUp size={16} />
317
- </button>
487
+ {attachments.length > 0 && (
488
+ <div className="attachment-preview-area">
489
+ {attachments.map((att) => (
490
+ <div key={att.id} className="attachment-chip" title={att.name}>
491
+ {att.previewUrl ? (
492
+ <img
493
+ src={att.previewUrl}
494
+ alt={att.name}
495
+ className="attachment-thumb"
496
+ onClick={() => setPreviewImage(att)}
497
+ style={{ cursor: 'pointer' }}
498
+ />
499
+ ) : (
500
+ <FileText size={20} className="attachment-file-icon" />
501
+ )}
502
+ <div className="attachment-info">
503
+ <span className="attachment-name">{att.name}</span>
504
+ <span className="attachment-size">{formatSize(att.size)}</span>
505
+ </div>
506
+ <button
507
+ className="attachment-remove"
508
+ onClick={() => removeAttachment(att.id)}
509
+ title="Remove attachment"
510
+ >
511
+ <X size={14} />
512
+ </button>
513
+ </div>
514
+ ))}
515
+ </div>
516
+ )}
517
+ {canAttach && !isRunning && !noSession && (
518
+ <input
519
+ ref={fileInputRef}
520
+ type="file"
521
+ multiple
522
+ accept="image/*,application/pdf,text/*,.ts,.js,.tsx,.jsx,.py,.go,.rs,.java,.c,.cpp,.h,.css,.html,.xml,.yml,.yaml,.toml,.ini,.sh,.sql"
523
+ style={{ display: 'none' }}
524
+ onChange={(e) => {
525
+ handleFiles(e.target.files);
526
+ e.target.value = '';
527
+ }}
528
+ />
529
+ )}
530
+ <div className="cmd-input-row">
531
+ {canAttach && !isRunning && !noSession && (
532
+ <button
533
+ className="attachment-clip-btn"
534
+ onClick={() => fileInputRef.current?.click()}
535
+ title="Attach files"
536
+ disabled={isRunning}
537
+ >
538
+ <Paperclip size={16} />
539
+ </button>
540
+ )}
541
+ <textarea
542
+ ref={taRef}
543
+ className="cmd-textarea"
544
+ placeholder={
545
+ noSession ? 'Select or create a session…' : isRunning ? 'Agent is running…' : 'Type a message… Use / for commands'
546
+ }
547
+ autoComplete="off"
548
+ value={value}
549
+ disabled={isRunning || noSession}
550
+ rows={1}
551
+ style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }}
552
+ onChange={handleChange}
553
+ onKeyDown={handleKeyDown}
554
+ onPaste={handlePaste}
555
+ />
556
+ {isRunning ? (
557
+ <button className="cmd-stop-btn" onClick={() => cancelTurn()} title="Stop (Esc)">
558
+ <Square size={15} />
559
+ </button>
560
+ ) : (
561
+ <button className="cmd-send-btn" onClick={handleSend} disabled={!value.trim() || noSession} title="Send (⌘↵)">
562
+ <ArrowUp size={16} />
563
+ </button>
564
+ )}
565
+ </div>
566
+
567
+ {/* Image preview modal — only for images */}
568
+ {previewImage && (
569
+ <div className="image-preview-modal" onClick={() => setPreviewImage(null)}>
570
+ <div className="image-preview-content" onClick={(e) => e.stopPropagation()}>
571
+ <img src={previewImage.previewUrl} alt={previewImage.name} />
572
+ <div className="image-preview-info">
573
+ <span>{previewImage.name}</span>
574
+ <span>{formatSize(previewImage.size)}</span>
575
+ </div>
576
+ <button className="image-preview-close" onClick={() => setPreviewImage(null)}>
577
+ <X size={20} />
578
+ </button>
579
+ </div>
580
+ </div>
318
581
  )}
319
582
  </div>
320
583
  );
@@ -177,12 +177,8 @@ describe('CommandPalette', () => {
177
177
  render(<CommandPalette />);
178
178
  fireEvent.click(screen.getByText('Switch to Turns Tab'));
179
179
  expect(switchRightTab).toHaveBeenCalledWith('turns');
180
- fireEvent.click(screen.getByText('Switch to Metrics Tab'));
181
- expect(switchRightTab).toHaveBeenCalledWith('metrics');
182
180
  fireEvent.click(screen.getByText('Switch to Changes Tab'));
183
181
  expect(switchRightTab).toHaveBeenCalledWith('changes');
184
- fireEvent.click(screen.getByText('Switch to Logs Tab'));
185
- expect(switchRightTab).toHaveBeenCalledWith('logs');
186
182
  });
187
183
 
188
184
  it('Close Tab action calls closeTab', () => {
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useRef, useMemo } from 'react';
2
2
  import { useStore } from '../store/store.ts';
3
- import { Plus, Trash2, Send, Square, List, BarChart3, FileCode, ScrollText, X, CornerDownLeft } from 'lucide-react';
3
+ import { Plus, Trash2, Send, Square, List, FileCode, X, CornerDownLeft } from 'lucide-react';
4
4
  import type { RightTab } from '../types/index.ts';
5
5
 
6
6
  interface Command {
@@ -72,14 +72,6 @@ export default function CommandPalette() {
72
72
  action: () => store.switchRightTab('turns' as RightTab),
73
73
  available: !!store.activeSessionId,
74
74
  },
75
- {
76
- id: 'tab-metrics',
77
- label: 'Switch to Metrics Tab',
78
- hint: 'View token usage and session info',
79
- icon: BarChart3,
80
- action: () => store.switchRightTab('metrics' as RightTab),
81
- available: !!store.activeSessionId,
82
- },
83
75
  {
84
76
  id: 'tab-changes',
85
77
  label: 'Switch to Changes Tab',
@@ -88,14 +80,6 @@ export default function CommandPalette() {
88
80
  action: () => store.switchRightTab('changes' as RightTab),
89
81
  available: !!store.activeSessionId,
90
82
  },
91
- {
92
- id: 'tab-logs',
93
- label: 'Switch to Logs Tab',
94
- hint: 'View event log',
95
- icon: ScrollText,
96
- action: () => store.switchRightTab('logs' as RightTab),
97
- available: !!store.activeSessionId,
98
- },
99
83
  {
100
84
  id: 'close-tab',
101
85
  label: 'Close Tab',