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.
- package/agent-pane/backend/data.db-shm +0 -0
- package/agent-pane/backend/data.db-wal +0 -0
- package/agent-pane/backend/dist/index.js +13399 -10390
- package/agent-pane/backend/package-lock.json +162 -92
- package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
- package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
- package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
- package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
- package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
- package/agent-pane/backend/src/claude/claude-types.ts +16 -4
- package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
- package/agent-pane/backend/src/db/index.ts +102 -0
- package/agent-pane/backend/src/db/schema.ts +4 -0
- package/agent-pane/backend/src/index.ts +3 -1
- package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
- package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
- package/agent-pane/backend/src/integration/threads.test.ts +309 -0
- package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
- package/agent-pane/backend/src/routes/providers.ts +11 -0
- package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
- package/agent-pane/backend/src/routes/sessions.ts +133 -28
- package/agent-pane/backend/src/routes/threads.ts +308 -0
- package/agent-pane/backend/src/routes/turns.ts +32 -9
- package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
- package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
- package/agent-pane/frontend/dist/index.html +2 -2
- package/agent-pane/frontend/package-lock.json +51 -9
- package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
- package/agent-pane/frontend/src/api/providers.ts +6 -1
- package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
- package/agent-pane/frontend/src/api/sessions.ts +13 -5
- package/agent-pane/frontend/src/api/websocket.ts +65 -1
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
- package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
- package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
- package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
- package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
- package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
- package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
- package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
- package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
- package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
- package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
- package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
- package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
- package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
- package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
- package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
- package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
- package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
- package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
- package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
- package/agent-pane/frontend/src/store/store.test.ts +5 -5
- package/agent-pane/frontend/src/store/store.ts +4 -1
- package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
- package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
- package/agent-pane/frontend/src/styles/app.css +150 -19
- package/agent-pane/frontend/src/test/fixtures.ts +4 -0
- package/agent-pane/frontend/src/types/index.ts +5 -2
- package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
- package/agent-pane/frontend/vite.config.ts +2 -2
- package/agent-pane/shared/provider-types.ts +14 -0
- package/agent-pane/shared/types.ts +60 -0
- package/package.json +1 -1
- package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
- package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
- package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
- package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
- package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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,
|
|
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',
|