@run0/jiki-ui 0.1.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.
- package/dist/AIChatPanel.d.ts +26 -0
- package/dist/BrowserWindow.d.ts +22 -0
- package/dist/CodeEditor.d.ts +8 -0
- package/dist/FileExplorer.d.ts +9 -0
- package/dist/MobileTabBar.d.ts +28 -0
- package/dist/PanelToggle.d.ts +9 -0
- package/dist/Terminal.d.ts +11 -0
- package/dist/chat-theme.d.ts +11 -0
- package/dist/index.cjs +1742 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.mjs +1720 -0
- package/dist/index.mjs.map +1 -0
- package/dist/language-labels.d.ts +1 -0
- package/dist/shiki-languages.d.ts +1 -0
- package/dist/theme.d.ts +19 -0
- package/dist/types.d.ts +12 -0
- package/dist/use-shiki-highlighter.d.ts +7 -0
- package/dist/useMediaQuery.d.ts +1 -0
- package/package.json +52 -0
- package/src/AIChatPanel.tsx +577 -0
- package/src/BrowserWindow.tsx +370 -0
- package/src/CodeEditor.tsx +185 -0
- package/src/FileExplorer.tsx +157 -0
- package/src/MobileTabBar.tsx +133 -0
- package/src/PanelToggle.tsx +101 -0
- package/src/Terminal.tsx +178 -0
- package/src/chat-theme.ts +81 -0
- package/src/index.ts +33 -0
- package/src/language-labels.ts +26 -0
- package/src/shiki-languages.ts +27 -0
- package/src/theme.ts +144 -0
- package/src/types.ts +21 -0
- package/src/use-shiki-highlighter.ts +113 -0
- package/src/useMediaQuery.ts +17 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|
2
|
+
import type { AccentColor } from "./types";
|
|
3
|
+
import { getChatTheme } from "./chat-theme";
|
|
4
|
+
import type { InspectedElement } from "./BrowserWindow";
|
|
5
|
+
|
|
6
|
+
export interface ChatMessage {
|
|
7
|
+
id: string;
|
|
8
|
+
role: "user" | "assistant";
|
|
9
|
+
content: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
isStreaming?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AIChatPanelProps {
|
|
15
|
+
messages: ChatMessage[];
|
|
16
|
+
onSendMessage: (content: string) => void;
|
|
17
|
+
isStreaming?: boolean;
|
|
18
|
+
accentColor?: AccentColor;
|
|
19
|
+
title?: string;
|
|
20
|
+
modelLabel?: string;
|
|
21
|
+
onLoadMore?: () => void;
|
|
22
|
+
hasMoreMessages?: boolean;
|
|
23
|
+
/** Flat list of file paths for @ mention autocomplete */
|
|
24
|
+
filePaths?: string[];
|
|
25
|
+
/** Inspected element context to attach to next message */
|
|
26
|
+
inspectedElement?: InspectedElement | null;
|
|
27
|
+
/** Clear the inspected element context */
|
|
28
|
+
onClearInspectedElement?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Collapsible code block
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function CodeBlock({ lang, content }: { lang?: string; content: string }) {
|
|
36
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
37
|
+
const lineCount = content.split("\n").length;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="my-2 rounded-lg bg-zinc-900 border border-zinc-700/50 overflow-hidden">
|
|
41
|
+
<div
|
|
42
|
+
className="flex items-center justify-between px-3 py-1.5 bg-zinc-800/50 cursor-pointer select-none"
|
|
43
|
+
onClick={() => setCollapsed(c => !c)}>
|
|
44
|
+
<div className="flex items-center gap-2">
|
|
45
|
+
{lang && (
|
|
46
|
+
<span className="text-[10px] text-zinc-500 font-mono uppercase tracking-wider">
|
|
47
|
+
{lang}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
<span className="text-[10px] text-zinc-600 font-mono">
|
|
51
|
+
{lineCount} lines
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
<svg
|
|
55
|
+
className={`w-3 h-3 text-zinc-500 transition-transform ${collapsed ? "" : "rotate-180"}`}
|
|
56
|
+
viewBox="0 0 16 16"
|
|
57
|
+
fill="currentColor">
|
|
58
|
+
<path d="M4.646 5.646a.5.5 0 0 1 .708 0L8 8.293l2.646-2.647a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 0 1 0-.708z" />
|
|
59
|
+
</svg>
|
|
60
|
+
</div>
|
|
61
|
+
{!collapsed && (
|
|
62
|
+
<pre className="p-3 overflow-x-auto">
|
|
63
|
+
<code className="text-[12px] font-mono text-zinc-300 leading-relaxed whitespace-pre">
|
|
64
|
+
{content}
|
|
65
|
+
</code>
|
|
66
|
+
</pre>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Content renderer (text + code blocks)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function renderContent(text: string) {
|
|
77
|
+
const parts: Array<{
|
|
78
|
+
type: "text" | "code";
|
|
79
|
+
content: string;
|
|
80
|
+
lang?: string;
|
|
81
|
+
}> = [];
|
|
82
|
+
const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
|
|
83
|
+
let lastIndex = 0;
|
|
84
|
+
let match: RegExpExecArray | null;
|
|
85
|
+
|
|
86
|
+
while ((match = codeBlockRegex.exec(text)) !== null) {
|
|
87
|
+
if (match.index > lastIndex) {
|
|
88
|
+
parts.push({ type: "text", content: text.slice(lastIndex, match.index) });
|
|
89
|
+
}
|
|
90
|
+
parts.push({
|
|
91
|
+
type: "code",
|
|
92
|
+
content: match[2],
|
|
93
|
+
lang: match[1] || undefined,
|
|
94
|
+
});
|
|
95
|
+
lastIndex = match.index + match[0].length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (lastIndex < text.length) {
|
|
99
|
+
parts.push({ type: "text", content: text.slice(lastIndex) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (parts.length === 0) {
|
|
103
|
+
parts.push({ type: "text", content: text });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
{parts.map((part, i) => {
|
|
109
|
+
if (part.type === "code") {
|
|
110
|
+
return <CodeBlock key={i} lang={part.lang} content={part.content} />;
|
|
111
|
+
}
|
|
112
|
+
return (
|
|
113
|
+
<span key={i} className="whitespace-pre-wrap">
|
|
114
|
+
{part.content}
|
|
115
|
+
</span>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Inline file mention rendering (@/path/to/file)
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function renderUserContent(text: string) {
|
|
127
|
+
const parts = text.split(/(@\/[^\s]+)/g);
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
{parts.map((part, i) =>
|
|
131
|
+
part.startsWith("@/") ? (
|
|
132
|
+
<span
|
|
133
|
+
key={i}
|
|
134
|
+
className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-blue-500/20 text-blue-300 font-mono text-[11px]">
|
|
135
|
+
<svg
|
|
136
|
+
className="w-2.5 h-2.5 flex-shrink-0"
|
|
137
|
+
viewBox="0 0 16 16"
|
|
138
|
+
fill="currentColor">
|
|
139
|
+
<path d="M3.75 1.5A2.25 2.25 0 0 0 1.5 3.75v8.5A2.25 2.25 0 0 0 3.75 14.5h8.5a2.25 2.25 0 0 0 2.25-2.25V6.621a2.25 2.25 0 0 0-.659-1.591L10.47 1.659A2.25 2.25 0 0 0 8.879 1.5H3.75z" />
|
|
140
|
+
</svg>
|
|
141
|
+
{part.slice(1)}
|
|
142
|
+
</span>
|
|
143
|
+
) : (
|
|
144
|
+
<span key={i}>{part}</span>
|
|
145
|
+
),
|
|
146
|
+
)}
|
|
147
|
+
</>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Collapsible message wrapper
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function MessageBubble({
|
|
156
|
+
msg,
|
|
157
|
+
theme,
|
|
158
|
+
}: {
|
|
159
|
+
msg: ChatMessage;
|
|
160
|
+
theme: ReturnType<typeof getChatTheme>;
|
|
161
|
+
}) {
|
|
162
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
163
|
+
const hasCode = msg.role === "assistant" && /```/.test(msg.content);
|
|
164
|
+
const isLong = msg.content.length > 500;
|
|
165
|
+
const canCollapse = (hasCode || isLong) && !msg.isStreaming;
|
|
166
|
+
|
|
167
|
+
if (msg.role === "user") {
|
|
168
|
+
return (
|
|
169
|
+
<div className="flex justify-end">
|
|
170
|
+
<div
|
|
171
|
+
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[13px] leading-relaxed ${theme.userBubble} text-zinc-200`}>
|
|
172
|
+
{renderUserContent(msg.content)}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Assistant message — no bubble wrapper, just inline content
|
|
179
|
+
return (
|
|
180
|
+
<div className="text-[13px] leading-relaxed text-zinc-300">
|
|
181
|
+
{collapsed ? (
|
|
182
|
+
<button
|
|
183
|
+
onClick={() => setCollapsed(false)}
|
|
184
|
+
className="flex items-center gap-1.5 text-left">
|
|
185
|
+
<svg
|
|
186
|
+
className="w-2.5 h-2.5 text-zinc-600 flex-shrink-0"
|
|
187
|
+
viewBox="0 0 16 16"
|
|
188
|
+
fill="currentColor">
|
|
189
|
+
<path d="M6.646 3.646a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L9.293 7 6.646 4.354a.5.5 0 0 1 0-.708z" />
|
|
190
|
+
</svg>
|
|
191
|
+
<span className="text-[11px] text-zinc-600">
|
|
192
|
+
{msg.content
|
|
193
|
+
.split("\n")[0]
|
|
194
|
+
.replace(/```\w*/, "")
|
|
195
|
+
.trim()
|
|
196
|
+
.slice(0, 60) || "Response"}
|
|
197
|
+
...
|
|
198
|
+
</span>
|
|
199
|
+
</button>
|
|
200
|
+
) : (
|
|
201
|
+
<div>
|
|
202
|
+
{renderContent(msg.content)}
|
|
203
|
+
{msg.isStreaming && (
|
|
204
|
+
<span className="inline-flex items-center gap-1 ml-1">
|
|
205
|
+
<span
|
|
206
|
+
className={`inline-block h-1.5 w-1.5 rounded-full ${theme.streamingDot} animate-pulse`}
|
|
207
|
+
/>
|
|
208
|
+
</span>
|
|
209
|
+
)}
|
|
210
|
+
{canCollapse && (
|
|
211
|
+
<button
|
|
212
|
+
onClick={() => setCollapsed(true)}
|
|
213
|
+
className="mt-1 flex items-center gap-1 text-zinc-600 hover:text-zinc-400 transition-colors">
|
|
214
|
+
<svg
|
|
215
|
+
className="w-2.5 h-2.5"
|
|
216
|
+
viewBox="0 0 16 16"
|
|
217
|
+
fill="currentColor">
|
|
218
|
+
<path d="M4.646 5.646a.5.5 0 0 1 .708 0L8 8.293l2.646-2.647a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 0 1 0-.708z" />
|
|
219
|
+
</svg>
|
|
220
|
+
<span className="text-[10px]">Collapse</span>
|
|
221
|
+
</button>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Main component
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
export function AIChatPanel({
|
|
234
|
+
messages,
|
|
235
|
+
onSendMessage,
|
|
236
|
+
isStreaming = false,
|
|
237
|
+
accentColor = "blue",
|
|
238
|
+
title = "Chat",
|
|
239
|
+
modelLabel,
|
|
240
|
+
onLoadMore,
|
|
241
|
+
hasMoreMessages = false,
|
|
242
|
+
filePaths = [],
|
|
243
|
+
inspectedElement,
|
|
244
|
+
onClearInspectedElement,
|
|
245
|
+
}: AIChatPanelProps) {
|
|
246
|
+
const [input, setInput] = useState("");
|
|
247
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
248
|
+
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
249
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
250
|
+
const isAtBottomRef = useRef(true);
|
|
251
|
+
|
|
252
|
+
// @ mention state
|
|
253
|
+
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
|
254
|
+
const [mentionStart, setMentionStart] = useState(0);
|
|
255
|
+
const [mentionIdx, setMentionIdx] = useState(0);
|
|
256
|
+
|
|
257
|
+
const theme = getChatTheme(accentColor);
|
|
258
|
+
|
|
259
|
+
// Filtered file paths for autocomplete
|
|
260
|
+
const mentionFiltered = useMemo(() => {
|
|
261
|
+
if (mentionQuery === null) return [];
|
|
262
|
+
const q = mentionQuery.toLowerCase();
|
|
263
|
+
return filePaths.filter(p => p.toLowerCase().includes(q)).slice(0, 8);
|
|
264
|
+
}, [mentionQuery, filePaths]);
|
|
265
|
+
|
|
266
|
+
// Reset selection when filtered results change
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
setMentionIdx(0);
|
|
269
|
+
}, [mentionFiltered.length]);
|
|
270
|
+
|
|
271
|
+
// Auto-scroll to bottom when new messages arrive (if user hasn't scrolled up)
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
const el = scrollRef.current;
|
|
274
|
+
if (el && isAtBottomRef.current) {
|
|
275
|
+
el.scrollTop = el.scrollHeight;
|
|
276
|
+
}
|
|
277
|
+
}, [messages]);
|
|
278
|
+
|
|
279
|
+
// Track whether user is at bottom
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const el = scrollRef.current;
|
|
282
|
+
if (!el) return;
|
|
283
|
+
|
|
284
|
+
const handleScroll = () => {
|
|
285
|
+
const threshold = 40;
|
|
286
|
+
isAtBottomRef.current =
|
|
287
|
+
el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
el.addEventListener("scroll", handleScroll, { passive: true });
|
|
291
|
+
return () => el.removeEventListener("scroll", handleScroll);
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
// IntersectionObserver for lazy loading older messages
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
const sentinel = sentinelRef.current;
|
|
297
|
+
if (!sentinel || !onLoadMore || !hasMoreMessages) return;
|
|
298
|
+
|
|
299
|
+
const observer = new IntersectionObserver(
|
|
300
|
+
entries => {
|
|
301
|
+
if (entries[0].isIntersecting) {
|
|
302
|
+
onLoadMore();
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
{ root: scrollRef.current, threshold: 0 },
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
observer.observe(sentinel);
|
|
309
|
+
return () => observer.disconnect();
|
|
310
|
+
}, [onLoadMore, hasMoreMessages]);
|
|
311
|
+
|
|
312
|
+
// Auto-resize textarea
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
const ta = textareaRef.current;
|
|
315
|
+
if (!ta) return;
|
|
316
|
+
ta.style.height = "auto";
|
|
317
|
+
ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
|
|
318
|
+
}, [input]);
|
|
319
|
+
|
|
320
|
+
// Track @ mentions as user types
|
|
321
|
+
const handleInputChange = useCallback(
|
|
322
|
+
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
323
|
+
const val = e.target.value;
|
|
324
|
+
setInput(val);
|
|
325
|
+
|
|
326
|
+
const cursorPos = e.target.selectionStart;
|
|
327
|
+
const textBefore = val.slice(0, cursorPos);
|
|
328
|
+
|
|
329
|
+
const atMatch = textBefore.match(/(?:^|\s)@([^\s]*)$/);
|
|
330
|
+
if (atMatch) {
|
|
331
|
+
setMentionQuery(atMatch[1]);
|
|
332
|
+
setMentionStart(cursorPos - atMatch[1].length - 1);
|
|
333
|
+
} else {
|
|
334
|
+
setMentionQuery(null);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
[],
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const handleMentionSelect = useCallback(
|
|
341
|
+
(path: string) => {
|
|
342
|
+
const before = input.slice(0, mentionStart);
|
|
343
|
+
const after = input.slice(mentionStart + 1 + (mentionQuery?.length ?? 0));
|
|
344
|
+
const newInput = `${before}@${path}${after ? after : " "}`;
|
|
345
|
+
setInput(newInput);
|
|
346
|
+
setMentionQuery(null);
|
|
347
|
+
|
|
348
|
+
requestAnimationFrame(() => {
|
|
349
|
+
const ta = textareaRef.current;
|
|
350
|
+
if (ta) {
|
|
351
|
+
ta.focus();
|
|
352
|
+
const pos = before.length + 1 + path.length + 1;
|
|
353
|
+
ta.selectionStart = pos;
|
|
354
|
+
ta.selectionEnd = pos;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
[input, mentionStart, mentionQuery],
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const handleSend = useCallback(() => {
|
|
362
|
+
const trimmed = input.trim();
|
|
363
|
+
if (!trimmed || isStreaming) return;
|
|
364
|
+
|
|
365
|
+
// If there's an inspected element, prepend context
|
|
366
|
+
let messageContent = trimmed;
|
|
367
|
+
if (inspectedElement) {
|
|
368
|
+
const ctx = `[Inspected element: <${inspectedElement.tagName} class="${inspectedElement.className}"> "${inspectedElement.textContent.slice(0, 100)}"]\n\n`;
|
|
369
|
+
messageContent = ctx + trimmed;
|
|
370
|
+
onClearInspectedElement?.();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
onSendMessage(messageContent);
|
|
374
|
+
setInput("");
|
|
375
|
+
setMentionQuery(null);
|
|
376
|
+
isAtBottomRef.current = true;
|
|
377
|
+
}, [
|
|
378
|
+
input,
|
|
379
|
+
isStreaming,
|
|
380
|
+
onSendMessage,
|
|
381
|
+
inspectedElement,
|
|
382
|
+
onClearInspectedElement,
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
const handleKeyDown = useCallback(
|
|
386
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
387
|
+
// Handle autocomplete keyboard navigation
|
|
388
|
+
if (mentionQuery !== null && mentionFiltered.length > 0) {
|
|
389
|
+
if (e.key === "ArrowDown") {
|
|
390
|
+
e.preventDefault();
|
|
391
|
+
setMentionIdx(i => (i + 1) % mentionFiltered.length);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (e.key === "ArrowUp") {
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
setMentionIdx(
|
|
397
|
+
i => (i - 1 + mentionFiltered.length) % mentionFiltered.length,
|
|
398
|
+
);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (e.key === "Enter" || e.key === "Tab") {
|
|
402
|
+
e.preventDefault();
|
|
403
|
+
handleMentionSelect(mentionFiltered[mentionIdx]);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (e.key === "Escape") {
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
setMentionQuery(null);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
414
|
+
e.preventDefault();
|
|
415
|
+
handleSend();
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
[
|
|
419
|
+
handleSend,
|
|
420
|
+
mentionQuery,
|
|
421
|
+
mentionFiltered,
|
|
422
|
+
mentionIdx,
|
|
423
|
+
handleMentionSelect,
|
|
424
|
+
],
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<div className="h-full flex flex-col bg-zinc-950">
|
|
429
|
+
{/* Header */}
|
|
430
|
+
<div className="flex items-center justify-between px-3 py-1.5 bg-zinc-900/50 border-b border-zinc-800">
|
|
431
|
+
<div className="flex items-center gap-1.5">
|
|
432
|
+
<div className="flex gap-1">
|
|
433
|
+
<div className="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
|
434
|
+
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
|
435
|
+
<div className="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
|
436
|
+
</div>
|
|
437
|
+
<span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
|
438
|
+
{title}
|
|
439
|
+
</span>
|
|
440
|
+
</div>
|
|
441
|
+
{modelLabel && (
|
|
442
|
+
<span
|
|
443
|
+
className={`text-[10px] font-mono px-2 py-0.5 rounded-full border ${theme.modelBadge}`}>
|
|
444
|
+
{modelLabel}
|
|
445
|
+
</span>
|
|
446
|
+
)}
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
{/* Messages area */}
|
|
450
|
+
<div
|
|
451
|
+
ref={scrollRef}
|
|
452
|
+
className="flex-1 overflow-y-auto px-3 py-3 space-y-3">
|
|
453
|
+
{hasMoreMessages && (
|
|
454
|
+
<div ref={sentinelRef} className="flex justify-center py-2">
|
|
455
|
+
<div className="h-4 w-4 animate-spin rounded-full border border-zinc-600 border-t-zinc-400" />
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
{messages.map(msg => (
|
|
460
|
+
<MessageBubble key={msg.id} msg={msg} theme={theme} />
|
|
461
|
+
))}
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
{/* Input area */}
|
|
465
|
+
<div className="flex-shrink-0 border-t border-zinc-800 p-3">
|
|
466
|
+
{/* Inspected element context badge */}
|
|
467
|
+
{inspectedElement && (
|
|
468
|
+
<div className="mb-2 flex items-center gap-2 px-2.5 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
|
469
|
+
<svg
|
|
470
|
+
className="w-3.5 h-3.5 text-blue-400 flex-shrink-0"
|
|
471
|
+
viewBox="0 0 24 24"
|
|
472
|
+
fill="none"
|
|
473
|
+
stroke="currentColor"
|
|
474
|
+
strokeWidth={2}
|
|
475
|
+
strokeLinecap="round"
|
|
476
|
+
strokeLinejoin="round">
|
|
477
|
+
<path d="M3 12l3 0" />
|
|
478
|
+
<path d="M12 3l0 3" />
|
|
479
|
+
<path d="M7.8 7.8l-2.2 -2.2" />
|
|
480
|
+
<path d="M16.2 7.8l2.2 -2.2" />
|
|
481
|
+
<path d="M7.8 16.2l-2.2 2.2" />
|
|
482
|
+
<path d="M12 12l9 3l-4 2l-2 4l-3 -9" />
|
|
483
|
+
</svg>
|
|
484
|
+
<div className="flex-1 min-w-0">
|
|
485
|
+
<span className="text-[11px] font-mono text-blue-300">
|
|
486
|
+
<{inspectedElement.tagName}>
|
|
487
|
+
</span>
|
|
488
|
+
{inspectedElement.textContent && (
|
|
489
|
+
<span className="text-[11px] text-zinc-500 ml-1.5 truncate">
|
|
490
|
+
"{inspectedElement.textContent.slice(0, 40)}
|
|
491
|
+
{inspectedElement.textContent.length > 40 ? "..." : ""}"
|
|
492
|
+
</span>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
<button
|
|
496
|
+
onClick={onClearInspectedElement}
|
|
497
|
+
className="text-zinc-500 hover:text-zinc-300 transition-colors flex-shrink-0">
|
|
498
|
+
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
|
|
499
|
+
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z" />
|
|
500
|
+
</svg>
|
|
501
|
+
</button>
|
|
502
|
+
</div>
|
|
503
|
+
)}
|
|
504
|
+
|
|
505
|
+
<div className="relative flex items-end gap-2">
|
|
506
|
+
{/* @ mention dropdown */}
|
|
507
|
+
{mentionQuery !== null && mentionFiltered.length > 0 && (
|
|
508
|
+
<div className="absolute bottom-full left-0 right-0 mb-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl overflow-hidden z-10">
|
|
509
|
+
{mentionFiltered.map((path, i) => (
|
|
510
|
+
<button
|
|
511
|
+
key={path}
|
|
512
|
+
onMouseDown={e => {
|
|
513
|
+
e.preventDefault();
|
|
514
|
+
handleMentionSelect(path);
|
|
515
|
+
}}
|
|
516
|
+
onMouseEnter={() => setMentionIdx(i)}
|
|
517
|
+
className={`w-full text-left px-3 py-1.5 text-[12px] font-mono flex items-center gap-2 transition-colors ${
|
|
518
|
+
i === mentionIdx
|
|
519
|
+
? "bg-blue-500/15 text-blue-300"
|
|
520
|
+
: "text-zinc-400 hover:bg-zinc-800"
|
|
521
|
+
}`}>
|
|
522
|
+
<svg
|
|
523
|
+
className="w-3 h-3 flex-shrink-0 text-zinc-500"
|
|
524
|
+
viewBox="0 0 16 16"
|
|
525
|
+
fill="currentColor">
|
|
526
|
+
<path d="M3.75 1.5A2.25 2.25 0 0 0 1.5 3.75v8.5A2.25 2.25 0 0 0 3.75 14.5h8.5a2.25 2.25 0 0 0 2.25-2.25V6.621a2.25 2.25 0 0 0-.659-1.591L10.47 1.659A2.25 2.25 0 0 0 8.879 1.5H3.75z" />
|
|
527
|
+
</svg>
|
|
528
|
+
{path}
|
|
529
|
+
</button>
|
|
530
|
+
))}
|
|
531
|
+
</div>
|
|
532
|
+
)}
|
|
533
|
+
<textarea
|
|
534
|
+
ref={textareaRef}
|
|
535
|
+
value={input}
|
|
536
|
+
onChange={handleInputChange}
|
|
537
|
+
onKeyDown={handleKeyDown}
|
|
538
|
+
disabled={isStreaming}
|
|
539
|
+
placeholder={
|
|
540
|
+
isStreaming
|
|
541
|
+
? "Generating..."
|
|
542
|
+
: "Describe a component... (@ to reference files)"
|
|
543
|
+
}
|
|
544
|
+
rows={1}
|
|
545
|
+
className={`
|
|
546
|
+
flex-1 resize-none overflow-hidden bg-zinc-900 border border-zinc-700/50 rounded-lg px-3 py-2
|
|
547
|
+
text-[13px] text-zinc-200 font-mono outline-none
|
|
548
|
+
placeholder:text-zinc-600 disabled:opacity-50
|
|
549
|
+
focus:border-zinc-600 transition-colors
|
|
550
|
+
min-h-[60px]
|
|
551
|
+
${theme.inputCaret}
|
|
552
|
+
`}
|
|
553
|
+
/>
|
|
554
|
+
<button
|
|
555
|
+
onClick={handleSend}
|
|
556
|
+
disabled={isStreaming || !input.trim()}
|
|
557
|
+
className={`
|
|
558
|
+
flex-shrink-0 h-9 w-9 rounded-lg flex items-center justify-center
|
|
559
|
+
transition-colors disabled:cursor-not-allowed
|
|
560
|
+
${isStreaming || !input.trim() ? theme.sendButtonDisabled : theme.sendButton}
|
|
561
|
+
`}>
|
|
562
|
+
{isStreaming ? (
|
|
563
|
+
<div className="h-4 w-4 animate-spin rounded-full border border-zinc-500 border-t-zinc-300" />
|
|
564
|
+
) : (
|
|
565
|
+
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
|
|
566
|
+
<path d="M1.724 1.053a.5.5 0 0 0-.714.545l1.403 4.85a.5.5 0 0 0 .397.354l5.69.953c.268.045.268.545 0 .59l-5.69.953a.5.5 0 0 0-.397.354l-1.403 4.85a.5.5 0 0 0 .714.545l13-6.5a.5.5 0 0 0 0-.894l-13-6.5z" />
|
|
567
|
+
</svg>
|
|
568
|
+
)}
|
|
569
|
+
</button>
|
|
570
|
+
</div>
|
|
571
|
+
<p className="text-[10px] text-zinc-600 mt-1.5 px-1">
|
|
572
|
+
Enter to send · Shift+Enter for newline · @ to reference files
|
|
573
|
+
</p>
|
|
574
|
+
</div>
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
}
|