claude-ws 0.1.1 → 0.1.3
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/next.config.ts +5 -0
- package/package.json +18 -6
- package/src/app/api/tasks/[id]/stats/route.ts +19 -5
- package/src/app/globals.css +35 -6
- package/src/app/layout.tsx +9 -6
- package/src/components/claude/code-block.tsx +4 -4
- package/src/components/claude/diff-view.tsx +6 -6
- package/src/components/claude/message-block.tsx +4 -4
- package/src/components/claude/response-renderer.tsx +42 -3
- package/src/components/claude/tool-use-block.tsx +10 -10
- package/src/components/kanban/board.tsx +81 -66
- package/src/components/providers/socket-provider.tsx +2 -1
- package/src/components/sidebar/file-browser/file-icon.tsx +12 -98
- package/src/components/task/conversation-view.tsx +138 -21
- package/src/components/task/file-icon.tsx +65 -33
- package/src/components/task/prompt-input.tsx +12 -7
- package/src/components/task/task-detail-panel.tsx +209 -69
- package/src/components/ui/detachable-window.tsx +468 -0
- package/src/components/ui/file-extension-icon.tsx +110 -0
- package/src/components/ui/running-dots.tsx +43 -4
- package/src/components/ui/scroll-area.tsx +1 -1
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { cn } from '@/lib/utils';
|
|
3
|
+
import { FileExtensionIcon } from '@/components/ui/file-extension-icon';
|
|
5
4
|
|
|
6
5
|
interface FileIconProps {
|
|
7
6
|
name: string;
|
|
@@ -11,101 +10,16 @@ interface FileIconProps {
|
|
|
11
10
|
size?: 'sm' | 'md';
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
// Icon config: [label, color]
|
|
15
|
-
const EXT_ICONS: Record<string, [string, string]> = {
|
|
16
|
-
js: ['JS', 'text-yellow-500'],
|
|
17
|
-
jsx: ['JS', 'text-yellow-400'],
|
|
18
|
-
mjs: ['JS', 'text-yellow-500'],
|
|
19
|
-
cjs: ['JS', 'text-yellow-500'],
|
|
20
|
-
ts: ['TS', 'text-blue-500'],
|
|
21
|
-
tsx: ['TS', 'text-blue-400'],
|
|
22
|
-
json: ['{ }', 'text-amber-500'],
|
|
23
|
-
yaml: ['Y', 'text-rose-400'],
|
|
24
|
-
yml: ['Y', 'text-rose-400'],
|
|
25
|
-
css: ['#', 'text-purple-500'],
|
|
26
|
-
scss: ['S#', 'text-pink-500'],
|
|
27
|
-
html: ['<>', 'text-orange-500'],
|
|
28
|
-
xml: ['<>', 'text-orange-400'],
|
|
29
|
-
svg: ['◇', 'text-amber-400'],
|
|
30
|
-
md: ['MD', 'text-sky-500'],
|
|
31
|
-
mdx: ['MD', 'text-sky-400'],
|
|
32
|
-
py: ['PY', 'text-green-500'],
|
|
33
|
-
go: ['GO', 'text-cyan-500'],
|
|
34
|
-
rs: ['RS', 'text-orange-600'],
|
|
35
|
-
sh: ['$', 'text-slate-400'],
|
|
36
|
-
sql: ['Q', 'text-cyan-500'],
|
|
37
|
-
png: ['◫', 'text-emerald-500'],
|
|
38
|
-
jpg: ['◫', 'text-emerald-500'],
|
|
39
|
-
jpeg: ['◫', 'text-emerald-500'],
|
|
40
|
-
gif: ['◫', 'text-emerald-500'],
|
|
41
|
-
webp: ['◫', 'text-emerald-500'],
|
|
42
|
-
ico: ['◫', 'text-emerald-500'],
|
|
43
|
-
txt: ['T', 'text-muted-foreground'],
|
|
44
|
-
log: ['≡', 'text-muted-foreground'],
|
|
45
|
-
env: ['⚙', 'text-yellow-600'],
|
|
46
|
-
lock: ['🔒', 'text-slate-500'],
|
|
47
|
-
db: ['DB', 'text-cyan-600'],
|
|
48
|
-
sqlite: ['DB', 'text-cyan-600'],
|
|
49
|
-
sqlite3: ['DB', 'text-cyan-600'],
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// Special files
|
|
53
|
-
const FILE_ICONS: Record<string, [string, string]> = {
|
|
54
|
-
'package.json': ['{ }', 'text-green-500'],
|
|
55
|
-
'tsconfig.json': ['TS', 'text-blue-500'],
|
|
56
|
-
'next.config.ts': ['N', 'text-slate-400'],
|
|
57
|
-
'next.config.js': ['N', 'text-slate-400'],
|
|
58
|
-
'next.config.mjs': ['N', 'text-slate-400'],
|
|
59
|
-
'tailwind.config.ts': ['TW', 'text-cyan-400'],
|
|
60
|
-
'tailwind.config.js': ['TW', 'text-cyan-400'],
|
|
61
|
-
'.gitignore': ['G', 'text-orange-500'],
|
|
62
|
-
'.npmignore': ['N', 'text-red-500'],
|
|
63
|
-
'.env': ['⚙', 'text-yellow-600'],
|
|
64
|
-
'.env.local': ['⚙', 'text-yellow-600'],
|
|
65
|
-
'README.md': ['i', 'text-sky-500'],
|
|
66
|
-
'LICENSE': ['§', 'text-amber-500'],
|
|
67
|
-
'LICENSE.md': ['§', 'text-amber-500'],
|
|
68
|
-
'LICENSE.txt': ['§', 'text-amber-500'],
|
|
69
|
-
'Dockerfile': ['🐳', 'text-sky-500'],
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
function getExt(name: string): string {
|
|
73
|
-
const i = name.lastIndexOf('.');
|
|
74
|
-
return i > 0 ? name.slice(i + 1).toLowerCase() : '';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
13
|
export function FileIcon({ name, type, isExpanded, className, size = 'md' }: FileIconProps) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const special = FILE_ICONS[name];
|
|
90
|
-
if (special) {
|
|
91
|
-
return (
|
|
92
|
-
<span className={cn(textSize, 'font-bold text-center', special[1], className)}>
|
|
93
|
-
{special[0]}
|
|
94
|
-
</span>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// By extension
|
|
99
|
-
const ext = getExt(name);
|
|
100
|
-
const config = EXT_ICONS[ext];
|
|
101
|
-
if (config) {
|
|
102
|
-
return (
|
|
103
|
-
<span className={cn(textSize, 'font-bold text-center', config[1], className)}>
|
|
104
|
-
{config[0]}
|
|
105
|
-
</span>
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Default
|
|
110
|
-
return <File className={cn(iconSize, 'text-muted-foreground', className)} />;
|
|
14
|
+
const iconSize = size === 'sm' ? 12 : 16;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<FileExtensionIcon
|
|
18
|
+
name={name}
|
|
19
|
+
type={type}
|
|
20
|
+
isExpanded={isExpanded}
|
|
21
|
+
size={iconSize}
|
|
22
|
+
className={className}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
111
25
|
}
|
|
@@ -5,7 +5,7 @@ import { Loader2, FileText } from 'lucide-react';
|
|
|
5
5
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
6
6
|
import { MessageBlock } from '@/components/claude/message-block';
|
|
7
7
|
import { ToolUseBlock } from '@/components/claude/tool-use-block';
|
|
8
|
-
import { RunningDots } from '@/components/ui/running-dots';
|
|
8
|
+
import { RunningDots, useRandomStatusVerb } from '@/components/ui/running-dots';
|
|
9
9
|
import { cn } from '@/lib/utils';
|
|
10
10
|
import type { ClaudeOutput, ClaudeContentBlock, AttemptFile, PendingFile } from '@/types';
|
|
11
11
|
|
|
@@ -125,9 +125,19 @@ export function ConversationView({
|
|
|
125
125
|
const [historicalTurns, setHistoricalTurns] = useState<ConversationTurn[]>([]);
|
|
126
126
|
const [isLoading, setIsLoading] = useState(true);
|
|
127
127
|
const [lastIsRunning, setLastIsRunning] = useState(isRunning);
|
|
128
|
+
const statusVerb = useRandomStatusVerb();
|
|
129
|
+
// Track if user is manually scrolling (to pause auto-scroll)
|
|
130
|
+
const userScrollingRef = useRef(false);
|
|
131
|
+
const userScrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
128
132
|
|
|
129
133
|
// Check if user is near bottom of scroll area (within threshold)
|
|
130
134
|
const isNearBottom = () => {
|
|
135
|
+
const detachedContainer = scrollAreaRef.current?.closest('[data-detached-scroll-container]');
|
|
136
|
+
if (detachedContainer) {
|
|
137
|
+
const threshold = 150;
|
|
138
|
+
return detachedContainer.scrollHeight - detachedContainer.scrollTop - detachedContainer.clientHeight < threshold;
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
const viewport = scrollAreaRef.current?.querySelector('[data-slot="scroll-area-viewport"]');
|
|
132
142
|
if (!viewport) return true;
|
|
133
143
|
const threshold = 150; // pixels from bottom
|
|
@@ -137,21 +147,66 @@ export function ConversationView({
|
|
|
137
147
|
// Scroll to bottom of scroll area viewport (only if user is near bottom)
|
|
138
148
|
const scrollToBottomIfNear = () => {
|
|
139
149
|
if (isNearBottom()) {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
|
|
150
|
+
const detachedContainer = scrollAreaRef.current?.closest('[data-detached-scroll-container]');
|
|
151
|
+
if (detachedContainer) {
|
|
152
|
+
detachedContainer.scrollTop = detachedContainer.scrollHeight;
|
|
153
|
+
} else {
|
|
154
|
+
const viewport = scrollAreaRef.current?.querySelector('[data-slot="scroll-area-viewport"]');
|
|
155
|
+
if (viewport) {
|
|
156
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
157
|
+
}
|
|
143
158
|
}
|
|
144
159
|
}
|
|
145
160
|
};
|
|
146
161
|
|
|
147
162
|
// Force scroll to bottom (bypasses isNearBottom check)
|
|
148
163
|
const scrollToBottom = () => {
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
|
|
164
|
+
const detachedContainer = scrollAreaRef.current?.closest('[data-detached-scroll-container]');
|
|
165
|
+
if (detachedContainer) {
|
|
166
|
+
detachedContainer.scrollTop = detachedContainer.scrollHeight;
|
|
167
|
+
} else {
|
|
168
|
+
const viewport = scrollAreaRef.current?.querySelector('[data-slot="scroll-area-viewport"]');
|
|
169
|
+
if (viewport) {
|
|
170
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
171
|
+
}
|
|
152
172
|
}
|
|
153
173
|
};
|
|
154
174
|
|
|
175
|
+
// Scroll to bottom with retry for better reliability (especially in detached mode)
|
|
176
|
+
const scrollToBottomWithRetry = (attempts = 3) => {
|
|
177
|
+
const attemptScroll = (remainingAttempts: number) => {
|
|
178
|
+
// Check if we're in a detached window
|
|
179
|
+
const detachedContainer = scrollAreaRef.current?.closest('[data-detached-scroll-container]');
|
|
180
|
+
|
|
181
|
+
if (detachedContainer) {
|
|
182
|
+
// In detached mode, scroll the detached container
|
|
183
|
+
detachedContainer.scrollTop = detachedContainer.scrollHeight;
|
|
184
|
+
requestAnimationFrame(() => {
|
|
185
|
+
const isAtBottom = detachedContainer.scrollHeight - detachedContainer.scrollTop - detachedContainer.clientHeight < 10;
|
|
186
|
+
if (!isAtBottom && remainingAttempts > 0) {
|
|
187
|
+
setTimeout(() => attemptScroll(remainingAttempts - 1), 100);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
} else {
|
|
191
|
+
// Normal mode, scroll the ScrollArea viewport
|
|
192
|
+
const viewport = scrollAreaRef.current?.querySelector('[data-slot="scroll-area-viewport"]');
|
|
193
|
+
if (viewport && viewport.scrollHeight > 0) {
|
|
194
|
+
viewport.scrollTop = viewport.scrollHeight;
|
|
195
|
+
requestAnimationFrame(() => {
|
|
196
|
+
const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 10;
|
|
197
|
+
if (!isAtBottom && remainingAttempts > 0) {
|
|
198
|
+
setTimeout(() => attemptScroll(remainingAttempts - 1), 100);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
} else if (remainingAttempts > 0) {
|
|
202
|
+
// Viewport not ready, retry
|
|
203
|
+
setTimeout(() => attemptScroll(remainingAttempts - 1), 100);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
attemptScroll(attempts);
|
|
208
|
+
};
|
|
209
|
+
|
|
155
210
|
// Load historical conversation
|
|
156
211
|
const loadHistory = async () => {
|
|
157
212
|
try {
|
|
@@ -174,9 +229,9 @@ export function ConversationView({
|
|
|
174
229
|
|
|
175
230
|
// Auto-scroll to bottom when switching to a new task (after history loads)
|
|
176
231
|
useEffect(() => {
|
|
177
|
-
if (!isLoading
|
|
178
|
-
//
|
|
179
|
-
|
|
232
|
+
if (!isLoading) {
|
|
233
|
+
// Use retry logic for better reliability in detached mode
|
|
234
|
+
scrollToBottomWithRetry(5);
|
|
180
235
|
}
|
|
181
236
|
}, [taskId, isLoading]);
|
|
182
237
|
|
|
@@ -188,11 +243,73 @@ export function ConversationView({
|
|
|
188
243
|
setLastIsRunning(isRunning);
|
|
189
244
|
}, [isRunning, lastIsRunning]);
|
|
190
245
|
|
|
191
|
-
//
|
|
246
|
+
// Detect user scroll to pause auto-scroll
|
|
192
247
|
useEffect(() => {
|
|
193
|
-
|
|
248
|
+
const getScrollContainer = () => {
|
|
249
|
+
const detachedContainer = scrollAreaRef.current?.closest('[data-detached-scroll-container]');
|
|
250
|
+
if (detachedContainer) return detachedContainer;
|
|
251
|
+
return scrollAreaRef.current?.querySelector('[data-slot="scroll-area-viewport"]');
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const handleScroll = () => {
|
|
255
|
+
// Mark user as scrolling
|
|
256
|
+
userScrollingRef.current = true;
|
|
257
|
+
|
|
258
|
+
// Clear previous timeout
|
|
259
|
+
if (userScrollTimeoutRef.current) {
|
|
260
|
+
clearTimeout(userScrollTimeoutRef.current);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Reset after user stops scrolling AND is near bottom
|
|
264
|
+
userScrollTimeoutRef.current = setTimeout(() => {
|
|
265
|
+
if (isNearBottom()) {
|
|
266
|
+
userScrollingRef.current = false;
|
|
267
|
+
}
|
|
268
|
+
}, 150);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const container = getScrollContainer();
|
|
272
|
+
if (container) {
|
|
273
|
+
container.addEventListener('scroll', handleScroll, { passive: true });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return () => {
|
|
277
|
+
if (container) {
|
|
278
|
+
container.removeEventListener('scroll', handleScroll);
|
|
279
|
+
}
|
|
280
|
+
if (userScrollTimeoutRef.current) {
|
|
281
|
+
clearTimeout(userScrollTimeoutRef.current);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}, [isLoading]);
|
|
285
|
+
|
|
286
|
+
// Auto-scroll to bottom on new messages (only if user is near bottom and not manually scrolling)
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!userScrollingRef.current) {
|
|
289
|
+
scrollToBottomIfNear();
|
|
290
|
+
}
|
|
194
291
|
}, [currentMessages, historicalTurns, isRunning]);
|
|
195
292
|
|
|
293
|
+
// Continuous auto-scroll during streaming (respects user scroll intent)
|
|
294
|
+
// Uses requestAnimationFrame to smoothly scroll as content appears
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (!isRunning) return;
|
|
297
|
+
|
|
298
|
+
let rafId: number;
|
|
299
|
+
|
|
300
|
+
const autoScroll = () => {
|
|
301
|
+
// Only auto-scroll if user is not manually scrolling
|
|
302
|
+
if (!userScrollingRef.current) {
|
|
303
|
+
scrollToBottomIfNear();
|
|
304
|
+
}
|
|
305
|
+
rafId = requestAnimationFrame(autoScroll);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
rafId = requestAnimationFrame(autoScroll);
|
|
309
|
+
|
|
310
|
+
return () => cancelAnimationFrame(rafId);
|
|
311
|
+
}, [isRunning]);
|
|
312
|
+
|
|
196
313
|
const renderContentBlock = (
|
|
197
314
|
block: ClaudeContentBlock,
|
|
198
315
|
index: number,
|
|
@@ -243,7 +360,7 @@ export function ConversationView({
|
|
|
243
360
|
const blocks = output.message.content;
|
|
244
361
|
|
|
245
362
|
return (
|
|
246
|
-
<div key={(output as any)._msgId || index} className="space-y-1 max-w-full overflow-hidden">
|
|
363
|
+
<div key={(output as any)._msgId || index} className="space-y-1 w-full max-w-full overflow-hidden">
|
|
247
364
|
{blocks.map((block, blockIndex) =>
|
|
248
365
|
renderContentBlock(block, blockIndex, lastToolUseId, toolResultsMap, isStreaming)
|
|
249
366
|
)}
|
|
@@ -305,7 +422,7 @@ export function ConversationView({
|
|
|
305
422
|
|
|
306
423
|
// User prompt - simple muted box with file thumbnails
|
|
307
424
|
const renderUserTurn = (turn: ConversationTurn) => (
|
|
308
|
-
<div key={`user-${turn.attemptId}`} className="bg-muted/40 rounded-lg px-4 py-3 text-[15px] leading-relaxed break-words space-y-3">
|
|
425
|
+
<div key={`user-${turn.attemptId}`} className="bg-muted/40 rounded-lg px-4 py-3 text-[15px] leading-relaxed break-words space-y-3 w-full max-w-full overflow-hidden">
|
|
309
426
|
<div>{turn.prompt}</div>
|
|
310
427
|
{turn.files && turn.files.length > 0 && (
|
|
311
428
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
@@ -349,7 +466,7 @@ export function ConversationView({
|
|
|
349
466
|
|
|
350
467
|
// Assistant response - clean text flow
|
|
351
468
|
const renderAssistantTurn = (turn: ConversationTurn) => (
|
|
352
|
-
<div key={`assistant-${turn.attemptId}`} className="space-y-4 max-w-full overflow-hidden">
|
|
469
|
+
<div key={`assistant-${turn.attemptId}`} className="space-y-4 w-full max-w-full overflow-hidden">
|
|
353
470
|
{turn.messages.map((msg, idx) => renderMessage(msg, idx, false, turn.messages))}
|
|
354
471
|
</div>
|
|
355
472
|
);
|
|
@@ -390,8 +507,8 @@ export function ConversationView({
|
|
|
390
507
|
: historicalTurns;
|
|
391
508
|
|
|
392
509
|
return (
|
|
393
|
-
<ScrollArea ref={scrollAreaRef} className={cn('h-full', className)}>
|
|
394
|
-
<div className="space-y-6 p-4 pb-24 max-w-full overflow-hidden">
|
|
510
|
+
<ScrollArea ref={scrollAreaRef} className={cn('h-full w-full max-w-full overflow-x-hidden', className)}>
|
|
511
|
+
<div className="space-y-6 p-4 pb-24 w-full max-w-full overflow-x-hidden box-border">
|
|
395
512
|
{/* Historical turns */}
|
|
396
513
|
{filteredHistoricalTurns.map(renderTurn)}
|
|
397
514
|
|
|
@@ -401,7 +518,7 @@ export function ConversationView({
|
|
|
401
518
|
<>
|
|
402
519
|
{/* User prompt if not in history */}
|
|
403
520
|
{!filteredHistoricalTurns.some(t => t.attemptId === currentAttemptId && t.type === 'user') && currentPrompt && (
|
|
404
|
-
<div className="bg-muted/40 rounded-lg px-4 py-3 text-[15px] leading-relaxed break-words space-y-3">
|
|
521
|
+
<div className="bg-muted/40 rounded-lg px-4 py-3 text-[15px] leading-relaxed break-words space-y-3 w-full max-w-full overflow-hidden">
|
|
405
522
|
<div>{currentPrompt}</div>
|
|
406
523
|
{currentFiles && currentFiles.length > 0 && (
|
|
407
524
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
@@ -437,7 +554,7 @@ export function ConversationView({
|
|
|
437
554
|
</div>
|
|
438
555
|
)}
|
|
439
556
|
{/* Streaming response */}
|
|
440
|
-
<div className="space-y-4 max-w-full overflow-hidden">
|
|
557
|
+
<div className="space-y-4 w-full max-w-full overflow-hidden">
|
|
441
558
|
{currentMessages.map((msg, idx) => renderMessage(msg, idx, true, currentMessages))}
|
|
442
559
|
</div>
|
|
443
560
|
</>
|
|
@@ -447,8 +564,8 @@ export function ConversationView({
|
|
|
447
564
|
{isRunning && !hasVisibleContent(currentMessages) &&
|
|
448
565
|
!filteredHistoricalTurns.some(t => t.attemptId === currentAttemptId && t.type === 'assistant') && (
|
|
449
566
|
<div className="flex items-center gap-2 text-muted-foreground text-sm py-1">
|
|
450
|
-
<RunningDots
|
|
451
|
-
<span className="font-mono text-[14px]">
|
|
567
|
+
<RunningDots />
|
|
568
|
+
<span className="font-mono text-[14px]" style={{ color: '#b9664a' }}>{statusVerb}...</span>
|
|
452
569
|
</div>
|
|
453
570
|
)}
|
|
454
571
|
</div>
|
|
@@ -1,49 +1,81 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import { FileExtensionIcon } from '@/components/ui/file-extension-icon';
|
|
4
|
+
|
|
5
|
+
// MIME type to extension mapping
|
|
6
|
+
const MIME_TO_EXT: Record<string, string> = {
|
|
7
|
+
'image/png': 'png',
|
|
8
|
+
'image/jpeg': 'jpg',
|
|
9
|
+
'image/jpg': 'jpg',
|
|
10
|
+
'image/gif': 'gif',
|
|
11
|
+
'image/webp': 'webp',
|
|
12
|
+
'image/svg+xml': 'svg',
|
|
13
|
+
'image/x-icon': 'ico',
|
|
14
|
+
'image/bmp': 'bmp',
|
|
15
|
+
'application/json': 'json',
|
|
16
|
+
'application/pdf': 'pdf',
|
|
17
|
+
'application/xml': 'xml',
|
|
18
|
+
'application/zip': 'zip',
|
|
19
|
+
'application/gzip': 'gz',
|
|
20
|
+
'application/x-tar': 'tar',
|
|
21
|
+
'application/x-rar-compressed': 'rar',
|
|
22
|
+
'application/x-7z-compressed': '7z',
|
|
23
|
+
'text/plain': 'txt',
|
|
24
|
+
'text/html': 'html',
|
|
25
|
+
'text/css': 'css',
|
|
26
|
+
'text/javascript': 'js',
|
|
27
|
+
'text/typescript': 'ts',
|
|
28
|
+
'text/markdown': 'md',
|
|
29
|
+
'text/xml': 'xml',
|
|
30
|
+
'text/csv': 'csv',
|
|
31
|
+
'application/javascript': 'js',
|
|
32
|
+
'application/typescript': 'ts',
|
|
33
|
+
'application/x-typescript': 'ts',
|
|
34
|
+
};
|
|
11
35
|
|
|
12
36
|
interface FileIconProps {
|
|
13
37
|
mimeType: string;
|
|
14
38
|
className?: string;
|
|
39
|
+
/** File name for better extension detection */
|
|
40
|
+
fileName?: string;
|
|
15
41
|
}
|
|
16
42
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
43
|
+
function getExtensionFromMime(mimeType: string): string {
|
|
44
|
+
// Direct mapping
|
|
45
|
+
if (mimeType in MIME_TO_EXT) {
|
|
46
|
+
return MIME_TO_EXT[mimeType];
|
|
47
|
+
}
|
|
20
48
|
|
|
49
|
+
// Fallback patterns
|
|
21
50
|
if (mimeType.startsWith('image/')) {
|
|
22
|
-
return
|
|
51
|
+
return mimeType.split('/')[1] || 'image';
|
|
23
52
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return <FileJson className={iconClass} />;
|
|
53
|
+
if (mimeType.startsWith('video/')) {
|
|
54
|
+
return mimeType.split('/')[1] || 'video';
|
|
27
55
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
mimeType.includes('typescript') ||
|
|
31
|
-
mimeType.includes('javascript') ||
|
|
32
|
-
mimeType === 'text/css' ||
|
|
33
|
-
mimeType === 'text/html' ||
|
|
34
|
-
mimeType === 'text/xml' ||
|
|
35
|
-
mimeType === 'application/xml'
|
|
36
|
-
) {
|
|
37
|
-
return <FileCode className={iconClass} />;
|
|
56
|
+
if (mimeType.startsWith('audio/')) {
|
|
57
|
+
return mimeType.split('/')[1] || 'audio';
|
|
38
58
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
mimeType === 'text/plain' ||
|
|
42
|
-
mimeType === 'text/markdown' ||
|
|
43
|
-
mimeType === 'application/pdf'
|
|
44
|
-
) {
|
|
45
|
-
return <FileText className={iconClass} />;
|
|
59
|
+
if (mimeType.includes('typescript')) {
|
|
60
|
+
return 'ts';
|
|
46
61
|
}
|
|
62
|
+
if (mimeType.includes('javascript')) {
|
|
63
|
+
return 'js';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return 'txt';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function FileIcon({ mimeType, className, fileName }: FileIconProps) {
|
|
70
|
+
// Use fileName if provided, otherwise derive from mimeType
|
|
71
|
+
const name = fileName || `file.${getExtensionFromMime(mimeType)}`;
|
|
47
72
|
|
|
48
|
-
return
|
|
73
|
+
return (
|
|
74
|
+
<FileExtensionIcon
|
|
75
|
+
name={name}
|
|
76
|
+
type="file"
|
|
77
|
+
size={24}
|
|
78
|
+
className={className}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
49
81
|
}
|
|
@@ -18,6 +18,7 @@ import { X } from 'lucide-react';
|
|
|
18
18
|
|
|
19
19
|
export interface PromptInputRef {
|
|
20
20
|
submit: () => void;
|
|
21
|
+
focus: () => void;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
interface PromptInputProps {
|
|
@@ -365,21 +366,24 @@ export const PromptInput = forwardRef<PromptInputRef, PromptInputProps>(({
|
|
|
365
366
|
fileInputRef.current?.click();
|
|
366
367
|
};
|
|
367
368
|
|
|
368
|
-
// Expose submit
|
|
369
|
+
// Expose submit and focus functions to parent via ref
|
|
369
370
|
useImperativeHandle(ref, () => ({
|
|
370
371
|
submit: () => {
|
|
371
372
|
if (!prompt.trim() || disabled) return;
|
|
372
373
|
handleSubmit({ preventDefault: () => { } } as FormEvent);
|
|
373
374
|
},
|
|
375
|
+
focus: () => {
|
|
376
|
+
textareaRef.current?.focus();
|
|
377
|
+
},
|
|
374
378
|
}));
|
|
375
379
|
|
|
376
380
|
return (
|
|
377
381
|
<FileDropZone
|
|
378
382
|
onFilesSelected={handleFilesSelected}
|
|
379
383
|
disabled={disabled}
|
|
380
|
-
className={cn('relative flex flex-col', className)}
|
|
384
|
+
className={cn('relative flex flex-col overflow-x-hidden', className)}
|
|
381
385
|
>
|
|
382
|
-
<form onSubmit={handleSubmit} className="flex flex-col gap-2 w-full min-w-0">
|
|
386
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-2 w-full min-w-0 overflow-x-hidden">
|
|
383
387
|
{/* Command Selector */}
|
|
384
388
|
<CommandSelector
|
|
385
389
|
isOpen={showCommands}
|
|
@@ -422,7 +426,7 @@ export const PromptInput = forwardRef<PromptInputRef, PromptInputProps>(({
|
|
|
422
426
|
)}
|
|
423
427
|
|
|
424
428
|
{/* Input area */}
|
|
425
|
-
<div className="w-full min-w-0">
|
|
429
|
+
<div className="w-full min-w-0 max-w-full">
|
|
426
430
|
{/* File Mention Dropdown */}
|
|
427
431
|
<FileMentionDropdown
|
|
428
432
|
query={fileMentionQuery}
|
|
@@ -433,10 +437,10 @@ export const PromptInput = forwardRef<PromptInputRef, PromptInputProps>(({
|
|
|
433
437
|
|
|
434
438
|
{/* Textarea and buttons as a single block */}
|
|
435
439
|
<div className={cn(
|
|
436
|
-
'rounded-md border overflow-hidden bg-background',
|
|
440
|
+
'rounded-md border overflow-hidden bg-background w-full max-w-full',
|
|
437
441
|
selectedCommand ? 'border-primary' : 'border-input'
|
|
438
442
|
)}>
|
|
439
|
-
<div className="relative">
|
|
443
|
+
<div className="relative w-full max-w-full">
|
|
440
444
|
<Textarea
|
|
441
445
|
ref={textareaRef}
|
|
442
446
|
value={prompt}
|
|
@@ -451,9 +455,10 @@ export const PromptInput = forwardRef<PromptInputRef, PromptInputProps>(({
|
|
|
451
455
|
placeholder={placeholder}
|
|
452
456
|
disabled={disabled}
|
|
453
457
|
className={cn(
|
|
454
|
-
'min-h-10 max-h-48 resize-none w-full break-words overflow-y-auto border-0 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0',
|
|
458
|
+
'min-h-10 max-h-48 resize-none w-full break-words overflow-y-auto border-0 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0 text-base',
|
|
455
459
|
selectedCommand && 'border-primary'
|
|
456
460
|
)}
|
|
461
|
+
style={{ fontSize: '16px' }}
|
|
457
462
|
/>
|
|
458
463
|
{selectedCommand && (
|
|
459
464
|
<div className="absolute top-2 right-2">
|