@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.
@@ -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
+ &lt;{inspectedElement.tagName}&gt;
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
+ }