create-interview-cockpit 0.5.0 → 0.7.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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +384 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +530 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1895 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +960 -0
  23. package/template/client/src/store.ts +250 -6
  24. package/template/client/src/types.ts +36 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/cockpit.json +1 -1
  27. package/template/server/src/google-drive.ts +39 -3
  28. package/template/server/src/index.ts +954 -52
  29. package/template/server/src/infra-runner.ts +1104 -0
  30. package/template/server/src/storage.ts +22 -3
@@ -0,0 +1,977 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+ import {
5
+ Clipboard,
6
+ ClipboardCheck,
7
+ GripVertical,
8
+ Loader2,
9
+ Maximize2,
10
+ MessageSquare,
11
+ Minimize2,
12
+ Eye,
13
+ EyeOff,
14
+ Plus,
15
+ Save,
16
+ Send,
17
+ Trash2,
18
+ X,
19
+ } from "lucide-react";
20
+ import * as api from "../api";
21
+
22
+ const MIN_W = 560;
23
+ const MIN_H = 520;
24
+ const DEFAULT_W = 860;
25
+ const DEFAULT_H = 660;
26
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
27
+
28
+ // ── Data model ───────────────────────────────────────────────────
29
+ interface Note {
30
+ id: string;
31
+ name: string;
32
+ text: string;
33
+ updatedAt: number;
34
+ }
35
+
36
+ export function notesKey(questionId?: string | null): string {
37
+ return questionId ? `notes:q:${questionId}` : "notes:global";
38
+ }
39
+
40
+ function loadNotes(key: string): Note[] {
41
+ try {
42
+ const raw = localStorage.getItem(key);
43
+ if (!raw) return [];
44
+ const parsed = JSON.parse(raw);
45
+ return Array.isArray(parsed) ? parsed : [];
46
+ } catch {
47
+ return [];
48
+ }
49
+ }
50
+
51
+ function persistNotes(key: string, notes: Note[]): void {
52
+ try {
53
+ if (notes.length === 0) {
54
+ localStorage.removeItem(key);
55
+ } else {
56
+ localStorage.setItem(key, JSON.stringify(notes));
57
+ }
58
+ } catch {
59
+ // storage full — ignore
60
+ }
61
+ }
62
+
63
+ function genId(): string {
64
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
65
+ }
66
+
67
+ // ── Component ────────────────────────────────────────────────────
68
+ interface Props {
69
+ questionId?: string | null;
70
+ onClose: () => void;
71
+ }
72
+
73
+ export default function NotesModal({ questionId, onClose }: Props) {
74
+ const storageKey = notesKey(questionId);
75
+
76
+ const [notes, setNotes] = useState<Note[]>(() => loadNotes(storageKey));
77
+ const [activeId, setActiveId] = useState<string | null>(
78
+ () => loadNotes(storageKey)[0]?.id ?? null,
79
+ );
80
+
81
+ // Editable draft for the active note
82
+ const [draftName, setDraftName] = useState<string>(
83
+ () => loadNotes(storageKey)[0]?.name ?? "",
84
+ );
85
+ const [draftText, setDraftText] = useState<string>(
86
+ () => loadNotes(storageKey)[0]?.text ?? "",
87
+ );
88
+ const [dirty, setDirty] = useState(false);
89
+
90
+ // Save As dialog
91
+ const [saveAsValue, setSaveAsValue] = useState<string | null>(null); // null = hidden
92
+
93
+ // Preview / edit toggle
94
+ const [preview, setPreview] = useState(false);
95
+
96
+ // Reset preview when switching notes
97
+ useEffect(() => {
98
+ setPreview(false);
99
+ }, [activeId]);
100
+
101
+ // Re-load when the question changes
102
+ useEffect(() => {
103
+ const loaded = loadNotes(storageKey);
104
+ setNotes(loaded);
105
+ const first = loaded[0] ?? null;
106
+ setActiveId(first?.id ?? null);
107
+ setDraftName(first?.name ?? "");
108
+ setDraftText(first?.text ?? "");
109
+ setDirty(false);
110
+ }, [storageKey]);
111
+
112
+ // Sync draft when activeId changes (but not on every notes mutation)
113
+ const prevActiveId = useRef<string | null>(null);
114
+ useEffect(() => {
115
+ if (activeId === prevActiveId.current) return;
116
+ prevActiveId.current = activeId;
117
+ const note = notes.find((n) => n.id === activeId);
118
+ if (note) {
119
+ setDraftName(note.name);
120
+ setDraftText(note.text);
121
+ setDirty(false);
122
+ }
123
+ }, [activeId, notes]);
124
+
125
+ // ── Actions ──────────────────────────────────────────────────
126
+ const flushDraft = useCallback(
127
+ (prev: Note[]): Note[] => {
128
+ if (!activeId || !dirty) return prev;
129
+ const updated = prev.map((n) =>
130
+ n.id === activeId
131
+ ? {
132
+ ...n,
133
+ name: draftName.trim() || "Untitled",
134
+ text: draftText,
135
+ updatedAt: Date.now(),
136
+ }
137
+ : n,
138
+ );
139
+ persistNotes(storageKey, updated);
140
+ return updated;
141
+ },
142
+ [activeId, dirty, draftName, draftText, storageKey],
143
+ );
144
+
145
+ const handleSave = useCallback(() => {
146
+ if (!activeId) return;
147
+ setNotes((prev) => {
148
+ const updated = prev.map((n) =>
149
+ n.id === activeId
150
+ ? {
151
+ ...n,
152
+ name: draftName.trim() || "Untitled",
153
+ text: draftText,
154
+ updatedAt: Date.now(),
155
+ }
156
+ : n,
157
+ );
158
+ persistNotes(storageKey, updated);
159
+ return updated;
160
+ });
161
+ setDirty(false);
162
+ }, [activeId, draftName, draftText, storageKey]);
163
+
164
+ const handleAddNote = useCallback(() => {
165
+ setNotes((prev) => {
166
+ const flushed = flushDraft(prev);
167
+ const count = flushed.length + 1;
168
+ const newNote: Note = {
169
+ id: genId(),
170
+ name: `Note ${count}`,
171
+ text: "",
172
+ updatedAt: Date.now(),
173
+ };
174
+ const updated = [...flushed, newNote];
175
+ persistNotes(storageKey, updated);
176
+ setActiveId(newNote.id);
177
+ setDraftName(newNote.name);
178
+ setDraftText("");
179
+ setDirty(false);
180
+ return updated;
181
+ });
182
+ }, [flushDraft, storageKey]);
183
+
184
+ const handleSelectNote = useCallback(
185
+ (id: string) => {
186
+ if (id === activeId) return;
187
+ setNotes((prev) => {
188
+ const updated = flushDraft(prev);
189
+ persistNotes(storageKey, updated);
190
+ return updated;
191
+ });
192
+ setDirty(false);
193
+ setActiveId(id);
194
+ },
195
+ [activeId, flushDraft, storageKey],
196
+ );
197
+
198
+ const handleDeleteNote = useCallback(
199
+ (id: string) => {
200
+ setNotes((prev) => {
201
+ const updated = prev.filter((n) => n.id !== id);
202
+ persistNotes(storageKey, updated);
203
+ if (id === activeId) {
204
+ const next = updated[0] ?? null;
205
+ setActiveId(next?.id ?? null);
206
+ setDraftName(next?.name ?? "");
207
+ setDraftText(next?.text ?? "");
208
+ setDirty(false);
209
+ }
210
+ return updated;
211
+ });
212
+ },
213
+ [activeId, storageKey],
214
+ );
215
+
216
+ const handleSaveAs = useCallback(() => {
217
+ setSaveAsValue((draftName.trim() || "Untitled") + " copy");
218
+ }, [draftName]);
219
+
220
+ const confirmSaveAs = useCallback(() => {
221
+ const name = (saveAsValue ?? "").trim() || "Untitled";
222
+ const newNote: Note = {
223
+ id: genId(),
224
+ name,
225
+ text: draftText,
226
+ updatedAt: Date.now(),
227
+ };
228
+ setNotes((prev) => {
229
+ const updated = [...prev, newNote];
230
+ persistNotes(storageKey, updated);
231
+ return updated;
232
+ });
233
+ setActiveId(newNote.id);
234
+ setDraftName(name);
235
+ setDirty(false);
236
+ setSaveAsValue(null);
237
+ }, [saveAsValue, draftText, storageKey]);
238
+
239
+ // ── Chat ─────────────────────────────────────────────────────
240
+ interface ChatMessage {
241
+ id: string;
242
+ role: "user" | "assistant";
243
+ content: string;
244
+ }
245
+
246
+ const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
247
+ const [chatInput, setChatInput] = useState("");
248
+ const [chatLoading, setChatLoading] = useState(false);
249
+ const [chatCopiedId, setChatCopiedId] = useState<string | null>(null);
250
+ const chatScrollRef = useRef<HTMLDivElement>(null);
251
+ const chatInputRef = useRef<HTMLTextAreaElement>(null);
252
+ const chatAbortRef = useRef<{ aborted: boolean }>({ aborted: false });
253
+
254
+ // Reset chat when switching notes
255
+ useEffect(() => {
256
+ setChatMessages([]);
257
+ setChatInput("");
258
+ setChatLoading(false);
259
+ }, [activeId]);
260
+
261
+ useEffect(() => {
262
+ if (chatScrollRef.current) {
263
+ chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
264
+ }
265
+ }, [chatMessages, chatLoading]);
266
+
267
+ const handleChatSend = useCallback(async () => {
268
+ const text = chatInput.trim();
269
+ if (!text || chatLoading) return;
270
+ setChatInput("");
271
+ const userMsg: ChatMessage = {
272
+ id: crypto.randomUUID(),
273
+ role: "user",
274
+ content: text,
275
+ };
276
+ setChatMessages((prev) => [...prev, userMsg]);
277
+ setChatLoading(true);
278
+ const abort = { aborted: false };
279
+ chatAbortRef.current = abort;
280
+ const assistantId = crypto.randomUUID();
281
+ setChatMessages((prev) => [
282
+ ...prev,
283
+ { id: assistantId, role: "assistant", content: "" },
284
+ ]);
285
+ try {
286
+ const history = [...chatMessages, userMsg].map((m) => ({
287
+ role: m.role,
288
+ content: m.content,
289
+ }));
290
+ await api.streamNotesAsk(
291
+ {
292
+ messages: history,
293
+ noteContent: draftText,
294
+ noteName: draftName || "Untitled",
295
+ },
296
+ (delta) => {
297
+ if (abort.aborted) return;
298
+ setChatMessages((prev) =>
299
+ prev.map((m) =>
300
+ m.id === assistantId ? { ...m, content: m.content + delta } : m,
301
+ ),
302
+ );
303
+ },
304
+ );
305
+ } catch (err: unknown) {
306
+ if (!abort.aborted) {
307
+ setChatMessages((prev) =>
308
+ prev.map((m) =>
309
+ m.id === assistantId
310
+ ? { ...m, content: (err as Error)?.message ?? "Request failed" }
311
+ : m,
312
+ ),
313
+ );
314
+ }
315
+ } finally {
316
+ if (!abort.aborted) setChatLoading(false);
317
+ }
318
+ }, [chatInput, chatLoading, chatMessages, draftText, draftName]);
319
+
320
+ const handleCopyMessage = useCallback((id: string, content: string) => {
321
+ void navigator.clipboard.writeText(content).then(() => {
322
+ setChatCopiedId(id);
323
+ setTimeout(() => setChatCopiedId(null), 1800);
324
+ });
325
+ }, []);
326
+
327
+ // ── Drag / resize ─────────────────────────────────────────────
328
+ const [pos, setPos] = useState(() => ({
329
+ x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
330
+ y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
331
+ }));
332
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
333
+ const [maximized, setMaximized] = useState(false);
334
+
335
+ const dragStart = useRef<{
336
+ mx: number;
337
+ my: number;
338
+ ox: number;
339
+ oy: number;
340
+ } | null>(null);
341
+ const resizeDir = useRef<ResizeDir>(null);
342
+ const resizeStart = useRef<{
343
+ mx: number;
344
+ my: number;
345
+ ox: number;
346
+ oy: number;
347
+ ow: number;
348
+ oh: number;
349
+ } | null>(null);
350
+ const savedPos = useRef(pos);
351
+ const savedSize = useRef(size);
352
+
353
+ const onTitleMouseDown = useCallback(
354
+ (e: React.MouseEvent) => {
355
+ if (maximized) return;
356
+ e.preventDefault();
357
+ dragStart.current = {
358
+ mx: e.clientX,
359
+ my: e.clientY,
360
+ ox: pos.x,
361
+ oy: pos.y,
362
+ };
363
+ },
364
+ [maximized, pos],
365
+ );
366
+
367
+ const startResize = useCallback(
368
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
369
+ if (maximized) return;
370
+ e.preventDefault();
371
+ e.stopPropagation();
372
+ resizeDir.current = dir;
373
+ resizeStart.current = {
374
+ mx: e.clientX,
375
+ my: e.clientY,
376
+ ox: pos.x,
377
+ oy: pos.y,
378
+ ow: size.w,
379
+ oh: size.h,
380
+ };
381
+ },
382
+ [maximized, pos, size],
383
+ );
384
+
385
+ const toggleMax = useCallback(() => {
386
+ if (!maximized) {
387
+ savedPos.current = pos;
388
+ savedSize.current = size;
389
+ setMaximized(true);
390
+ } else {
391
+ setPos(savedPos.current);
392
+ setSize(savedSize.current);
393
+ setMaximized(false);
394
+ }
395
+ }, [maximized, pos, size]);
396
+
397
+ useEffect(() => {
398
+ const onMove = (e: MouseEvent) => {
399
+ const drag = dragStart.current;
400
+ const resize = resizeStart.current;
401
+ const dir = resizeDir.current;
402
+ if (drag) {
403
+ setPos({
404
+ x: Math.max(0, drag.ox + e.clientX - drag.mx),
405
+ y: Math.max(0, drag.oy + e.clientY - drag.my),
406
+ });
407
+ }
408
+ if (resize && dir) {
409
+ const dx = e.clientX - resize.mx;
410
+ const dy = e.clientY - resize.my;
411
+ setSize((prev) => {
412
+ let w = prev.w,
413
+ h = prev.h;
414
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
415
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
416
+ if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
417
+ if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
418
+ return { w, h };
419
+ });
420
+ if (dir.includes("w"))
421
+ setPos((p) => ({
422
+ ...p,
423
+ x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
424
+ }));
425
+ if (dir.includes("n"))
426
+ setPos((p) => ({
427
+ ...p,
428
+ y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
429
+ }));
430
+ }
431
+ };
432
+ const onUp = () => {
433
+ dragStart.current = null;
434
+ resizeStart.current = null;
435
+ resizeDir.current = null;
436
+ };
437
+ document.addEventListener("mousemove", onMove);
438
+ document.addEventListener("mouseup", onUp);
439
+ return () => {
440
+ document.removeEventListener("mousemove", onMove);
441
+ document.removeEventListener("mouseup", onUp);
442
+ };
443
+ }, []);
444
+
445
+ useEffect(() => {
446
+ const onKey = (e: KeyboardEvent) => {
447
+ if (e.key === "Escape" && saveAsValue === null) onClose();
448
+ };
449
+ document.addEventListener("keydown", onKey);
450
+ return () => document.removeEventListener("keydown", onKey);
451
+ }, [onClose, saveAsValue]);
452
+
453
+ const windowStyle: React.CSSProperties = maximized
454
+ ? {
455
+ position: "fixed",
456
+ inset: 0,
457
+ width: "100vw",
458
+ height: "100vh",
459
+ borderRadius: 0,
460
+ }
461
+ : {
462
+ position: "fixed",
463
+ left: pos.x,
464
+ top: pos.y,
465
+ width: size.w,
466
+ height: size.h,
467
+ minWidth: MIN_W,
468
+ minHeight: MIN_H,
469
+ };
470
+
471
+ // ── Render ───────────────────────────────────────────────────
472
+ return (
473
+ <div
474
+ className="z-50 flex flex-col bg-slate-900 border border-slate-700 rounded-xl shadow-2xl overflow-hidden select-none"
475
+ style={windowStyle}
476
+ >
477
+ {/* Resize handles */}
478
+ {!maximized && (
479
+ <>
480
+ <div
481
+ className="absolute top-0 left-0 right-0 h-1 cursor-n-resize z-10"
482
+ onMouseDown={startResize("n")}
483
+ />
484
+ <div
485
+ className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize z-10"
486
+ onMouseDown={startResize("s")}
487
+ />
488
+ <div
489
+ className="absolute top-0 left-0 bottom-0 w-1 cursor-w-resize z-10"
490
+ onMouseDown={startResize("w")}
491
+ />
492
+ <div
493
+ className="absolute top-0 right-0 bottom-0 w-1 cursor-e-resize z-10"
494
+ onMouseDown={startResize("e")}
495
+ />
496
+ <div
497
+ className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-20"
498
+ onMouseDown={startResize("nw")}
499
+ />
500
+ <div
501
+ className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-20"
502
+ onMouseDown={startResize("ne")}
503
+ />
504
+ <div
505
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-20"
506
+ onMouseDown={startResize("sw")}
507
+ />
508
+ <div
509
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-20"
510
+ onMouseDown={startResize("se")}
511
+ />
512
+ </>
513
+ )}
514
+
515
+ {/* Title bar */}
516
+ <div
517
+ className="flex items-center gap-2 px-3 py-2.5 bg-slate-800 border-b border-slate-700 shrink-0"
518
+ onMouseDown={onTitleMouseDown}
519
+ style={{ cursor: maximized ? "default" : "grab" }}
520
+ >
521
+ <GripVertical className="w-3.5 h-3.5 text-slate-600 shrink-0" />
522
+ <span className="text-sm font-semibold text-slate-100 flex-1">
523
+ Notes
524
+ {questionId && (
525
+ <span className="ml-2 text-xs font-normal text-slate-500">
526
+ — this question
527
+ </span>
528
+ )}
529
+ </span>
530
+ <button
531
+ type="button"
532
+ onMouseDown={(e) => e.stopPropagation()}
533
+ onClick={toggleMax}
534
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0"
535
+ title={maximized ? "Restore" : "Maximise"}
536
+ >
537
+ {maximized ? (
538
+ <Minimize2 className="w-3.5 h-3.5" />
539
+ ) : (
540
+ <Maximize2 className="w-3.5 h-3.5" />
541
+ )}
542
+ </button>
543
+ <button
544
+ type="button"
545
+ onMouseDown={(e) => e.stopPropagation()}
546
+ onClick={onClose}
547
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-red-400 transition-colors shrink-0"
548
+ title="Close (Esc)"
549
+ >
550
+ <X className="w-3.5 h-3.5" />
551
+ </button>
552
+ </div>
553
+
554
+ {/* Body: sidebar + editor + chat */}
555
+ <div className="flex-1 min-h-0 flex flex-col overflow-hidden">
556
+ {/* Top: sidebar + editor */}
557
+ <div className="flex-1 min-h-0 flex overflow-hidden">
558
+ {/* Notes list sidebar */}
559
+ <div className="w-44 shrink-0 flex flex-col border-r border-slate-700 bg-slate-900">
560
+ <div className="flex items-center justify-between px-2.5 py-2 border-b border-slate-800 shrink-0">
561
+ <span className="text-xs font-semibold text-slate-500 uppercase tracking-wide">
562
+ All notes
563
+ </span>
564
+ <button
565
+ type="button"
566
+ onMouseDown={(e) => e.stopPropagation()}
567
+ onClick={handleAddNote}
568
+ title="New note"
569
+ className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
570
+ >
571
+ <Plus className="w-3.5 h-3.5" />
572
+ </button>
573
+ </div>
574
+ <div className="flex-1 overflow-y-auto">
575
+ {notes.length === 0 && (
576
+ <p className="px-3 py-3 text-xs text-slate-600 italic">
577
+ No notes yet
578
+ </p>
579
+ )}
580
+ {notes.map((note) => (
581
+ <div
582
+ key={note.id}
583
+ onClick={() => handleSelectNote(note.id)}
584
+ className={`group flex items-center gap-1 px-2.5 py-2 cursor-pointer text-xs ${
585
+ note.id === activeId
586
+ ? "bg-slate-700 text-slate-100"
587
+ : "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
588
+ }`}
589
+ >
590
+ <span className="flex-1 truncate">
591
+ {note.name || "Untitled"}
592
+ </span>
593
+ <button
594
+ type="button"
595
+ onMouseDown={(e) => e.stopPropagation()}
596
+ onClick={(e) => {
597
+ e.stopPropagation();
598
+ handleDeleteNote(note.id);
599
+ }}
600
+ className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-red-400 transition-all shrink-0"
601
+ title="Delete note"
602
+ >
603
+ <Trash2 className="w-3 h-3" />
604
+ </button>
605
+ </div>
606
+ ))}
607
+ </div>
608
+ </div>
609
+
610
+ {/* Editor pane */}
611
+ <div className="flex-1 flex flex-col min-w-0">
612
+ {activeId ? (
613
+ <>
614
+ {/* Note name + preview toggle */}
615
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-slate-800 shrink-0">
616
+ <input
617
+ value={draftName}
618
+ onChange={(e) => {
619
+ setDraftName(e.target.value);
620
+ setDirty(true);
621
+ }}
622
+ onMouseDown={(e) => e.stopPropagation()}
623
+ placeholder="Note name…"
624
+ className="flex-1 bg-transparent text-sm font-semibold text-slate-100 placeholder-slate-600 outline-none"
625
+ />
626
+ <button
627
+ type="button"
628
+ onMouseDown={(e) => e.stopPropagation()}
629
+ onClick={() => setPreview((v) => !v)}
630
+ className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors ${
631
+ preview
632
+ ? "bg-violet-600/20 text-violet-300 hover:bg-violet-600/30"
633
+ : "text-slate-500 hover:bg-slate-800 hover:text-slate-300"
634
+ }`}
635
+ title={
636
+ preview
637
+ ? "Switch to edit mode"
638
+ : "Preview rendered Markdown"
639
+ }
640
+ >
641
+ {preview ? (
642
+ <EyeOff className="w-3 h-3" />
643
+ ) : (
644
+ <Eye className="w-3 h-3" />
645
+ )}
646
+ <span>{preview ? "Edit" : "Preview"}</span>
647
+ </button>
648
+ </div>
649
+
650
+ {/* Editor / Preview */}
651
+ {preview ? (
652
+ <div
653
+ className="flex-1 overflow-y-auto px-5 py-4 prose prose-invert prose-sm max-w-none bg-slate-950 text-slate-200"
654
+ onMouseDown={(e) => e.stopPropagation()}
655
+ >
656
+ {draftText.trim() ? (
657
+ <ReactMarkdown
658
+ remarkPlugins={[remarkGfm]}
659
+ components={{
660
+ code({ className, children, ...props }) {
661
+ const isBlock = className?.startsWith("language-");
662
+ return isBlock ? (
663
+ <pre className="bg-slate-900 rounded-lg p-3 overflow-x-auto my-2">
664
+ <code
665
+ className={`${className ?? ""} text-xs`}
666
+ {...props}
667
+ >
668
+ {children}
669
+ </code>
670
+ </pre>
671
+ ) : (
672
+ <code
673
+ className="bg-slate-900/70 px-1 rounded text-violet-300 text-[0.8em]"
674
+ {...props}
675
+ >
676
+ {children}
677
+ </code>
678
+ );
679
+ },
680
+ table({ children }) {
681
+ return (
682
+ <table className="border-collapse w-full my-2 text-sm">
683
+ {children}
684
+ </table>
685
+ );
686
+ },
687
+ th({ children }) {
688
+ return (
689
+ <th className="border border-slate-700 px-3 py-1.5 text-left text-slate-200 bg-slate-800">
690
+ {children}
691
+ </th>
692
+ );
693
+ },
694
+ td({ children }) {
695
+ return (
696
+ <td className="border border-slate-700 px-3 py-1.5">
697
+ {children}
698
+ </td>
699
+ );
700
+ },
701
+ }}
702
+ >
703
+ {draftText}
704
+ </ReactMarkdown>
705
+ ) : (
706
+ <p className="text-slate-600 italic text-sm">
707
+ Nothing to preview yet.
708
+ </p>
709
+ )}
710
+ </div>
711
+ ) : (
712
+ <textarea
713
+ value={draftText}
714
+ onChange={(e) => {
715
+ setDraftText(e.target.value);
716
+ setDirty(true);
717
+ }}
718
+ onMouseDown={(e) => e.stopPropagation()}
719
+ placeholder="Write your notes here… (Markdown supported)"
720
+ spellCheck
721
+ className="flex-1 bg-slate-950 text-sm text-slate-200 placeholder-slate-700 px-4 py-3 outline-none resize-none leading-relaxed font-mono"
722
+ />
723
+ )}
724
+
725
+ {/* Footer */}
726
+ <div className="flex items-center gap-2 px-3 py-2 border-t border-slate-800 bg-slate-900 shrink-0">
727
+ <button
728
+ type="button"
729
+ onMouseDown={(e) => e.stopPropagation()}
730
+ onClick={handleSave}
731
+ disabled={!dirty}
732
+ className={`flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium transition-colors ${
733
+ dirty
734
+ ? "bg-blue-600 hover:bg-blue-500 text-white"
735
+ : "bg-slate-800 text-slate-600 cursor-default"
736
+ }`}
737
+ title="Save (Ctrl+S)"
738
+ >
739
+ <Save className="w-3 h-3" />
740
+ Save
741
+ </button>
742
+ <button
743
+ type="button"
744
+ onMouseDown={(e) => e.stopPropagation()}
745
+ onClick={handleSaveAs}
746
+ className="flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium bg-slate-800 hover:bg-slate-700 text-slate-300 transition-colors"
747
+ title="Save as a new named note"
748
+ >
749
+ Save As
750
+ </button>
751
+ {dirty && (
752
+ <span className="ml-auto text-xs text-amber-500 select-none">
753
+ Unsaved changes
754
+ </span>
755
+ )}
756
+ </div>
757
+ </>
758
+ ) : (
759
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 text-slate-600">
760
+ <p className="text-sm">No note selected</p>
761
+ <button
762
+ type="button"
763
+ onMouseDown={(e) => e.stopPropagation()}
764
+ onClick={handleAddNote}
765
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs transition-colors"
766
+ >
767
+ <Plus className="w-3.5 h-3.5" />
768
+ New note
769
+ </button>
770
+ </div>
771
+ )}
772
+ </div>
773
+ </div>
774
+
775
+ {/* Bottom: Chat panel */}
776
+ <div className="h-56 shrink-0 border-t border-slate-700 flex flex-col bg-slate-950/80">
777
+ {/* Chat header */}
778
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-slate-800 shrink-0">
779
+ <div className="flex items-center gap-1.5 text-xs font-medium text-violet-400">
780
+ <MessageSquare className="w-3.5 h-3.5" />
781
+ <span>Chat about this note</span>
782
+ </div>
783
+ {chatMessages.length > 0 && (
784
+ <button
785
+ type="button"
786
+ onMouseDown={(e) => e.stopPropagation()}
787
+ onClick={() => setChatMessages([])}
788
+ className="text-[10px] text-slate-600 hover:text-slate-400 transition-colors"
789
+ >
790
+ clear
791
+ </button>
792
+ )}
793
+ </div>
794
+
795
+ {/* Messages */}
796
+ <div
797
+ ref={chatScrollRef}
798
+ className="flex-1 overflow-y-auto px-3 py-2 space-y-3"
799
+ >
800
+ {chatMessages.length === 0 && (
801
+ <p className="text-xs text-slate-600 pt-1">
802
+ Ask anything about your note —{" "}
803
+ <span className="text-slate-500">"Explain this concept"</span>{" "}
804
+ or{" "}
805
+ <span className="text-slate-500">"What's missing here?"</span>
806
+ </p>
807
+ )}
808
+ {chatMessages.map((msg) => (
809
+ <div
810
+ key={msg.id}
811
+ className={`flex flex-col gap-0.5 ${
812
+ msg.role === "user" ? "items-end" : "items-start"
813
+ }`}
814
+ >
815
+ <div
816
+ className={`max-w-[85%] rounded-xl px-3 py-2 text-xs leading-5 ${
817
+ msg.role === "user"
818
+ ? "bg-violet-600/30 text-violet-100 whitespace-pre-wrap"
819
+ : "bg-slate-800 text-slate-200 prose prose-invert prose-xs max-w-none"
820
+ }`}
821
+ >
822
+ {msg.role === "user" ? (
823
+ msg.content
824
+ ) : msg.content ? (
825
+ <ReactMarkdown
826
+ remarkPlugins={[remarkGfm]}
827
+ components={{
828
+ code({ className, children, ...props }) {
829
+ const isBlock = className?.startsWith("language-");
830
+ return isBlock ? (
831
+ <pre className="bg-slate-900/80 rounded p-2 overflow-x-auto my-1">
832
+ <code
833
+ className={`${className ?? ""} text-[11px]`}
834
+ {...props}
835
+ >
836
+ {children}
837
+ </code>
838
+ </pre>
839
+ ) : (
840
+ <code
841
+ className="bg-slate-900/60 px-1 rounded text-violet-300 text-[11px]"
842
+ {...props}
843
+ >
844
+ {children}
845
+ </code>
846
+ );
847
+ },
848
+ p({ children }) {
849
+ return <p className="mb-1 last:mb-0">{children}</p>;
850
+ },
851
+ ul({ children }) {
852
+ return (
853
+ <ul className="list-disc list-inside mb-1 space-y-0.5">
854
+ {children}
855
+ </ul>
856
+ );
857
+ },
858
+ ol({ children }) {
859
+ return (
860
+ <ol className="list-decimal list-inside mb-1 space-y-0.5">
861
+ {children}
862
+ </ol>
863
+ );
864
+ },
865
+ }}
866
+ >
867
+ {msg.content}
868
+ </ReactMarkdown>
869
+ ) : (
870
+ <span className="flex items-center gap-1.5 text-slate-500">
871
+ <Loader2 className="w-3 h-3 animate-spin" /> thinking…
872
+ </span>
873
+ )}
874
+ </div>
875
+ {msg.role === "assistant" && msg.content && (
876
+ <button
877
+ onClick={() => handleCopyMessage(msg.id, msg.content)}
878
+ onMouseDown={(e) => e.stopPropagation()}
879
+ className="flex items-center gap-1 text-[10px] text-slate-700 hover:text-slate-400 transition-colors px-1"
880
+ title="Copy response"
881
+ >
882
+ {chatCopiedId === msg.id ? (
883
+ <>
884
+ <ClipboardCheck className="w-3 h-3 text-emerald-400" />
885
+ <span className="text-emerald-400">Copied</span>
886
+ </>
887
+ ) : (
888
+ <>
889
+ <Clipboard className="w-3 h-3" />
890
+ <span>Copy</span>
891
+ </>
892
+ )}
893
+ </button>
894
+ )}
895
+ </div>
896
+ ))}
897
+ </div>
898
+
899
+ {/* Input */}
900
+ <div className="flex items-end gap-1.5 px-3 py-2 border-t border-slate-800 bg-slate-900/60 shrink-0">
901
+ <textarea
902
+ ref={chatInputRef}
903
+ rows={1}
904
+ value={chatInput}
905
+ onChange={(e) => setChatInput(e.target.value)}
906
+ onMouseDown={(e) => e.stopPropagation()}
907
+ onKeyDown={(e) => {
908
+ if (e.key === "Enter" && !e.shiftKey) {
909
+ e.preventDefault();
910
+ void handleChatSend();
911
+ }
912
+ }}
913
+ placeholder={
914
+ activeId ? "Ask about your note…" : "Select a note to chat…"
915
+ }
916
+ disabled={chatLoading || !activeId}
917
+ className="flex-1 bg-transparent text-xs text-slate-200 placeholder-slate-600 outline-none resize-none disabled:opacity-50 max-h-16"
918
+ />
919
+ <button
920
+ type="button"
921
+ onMouseDown={(e) => e.stopPropagation()}
922
+ onClick={() => void handleChatSend()}
923
+ disabled={chatLoading || !chatInput.trim() || !activeId}
924
+ className="p-1 rounded text-slate-600 hover:text-violet-400 hover:bg-violet-500/10 disabled:opacity-40 transition-colors shrink-0"
925
+ title="Send (Enter)"
926
+ >
927
+ {chatLoading ? (
928
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
929
+ ) : (
930
+ <Send className="w-3.5 h-3.5" />
931
+ )}
932
+ </button>
933
+ </div>
934
+ </div>
935
+ </div>
936
+
937
+ {/* Save As dialog */}
938
+ {saveAsValue !== null && (
939
+ <div className="absolute inset-0 bg-slate-900/80 backdrop-blur-sm flex items-center justify-center z-20">
940
+ <div
941
+ className="bg-slate-800 border border-slate-700 rounded-lg p-4 w-72 flex flex-col gap-3"
942
+ onMouseDown={(e) => e.stopPropagation()}
943
+ >
944
+ <p className="text-sm font-semibold text-slate-100">Save As</p>
945
+ <input
946
+ autoFocus
947
+ value={saveAsValue}
948
+ onChange={(e) => setSaveAsValue(e.target.value)}
949
+ onKeyDown={(e) => {
950
+ if (e.key === "Enter") confirmSaveAs();
951
+ if (e.key === "Escape") setSaveAsValue(null);
952
+ }}
953
+ placeholder="Note name…"
954
+ className="px-3 py-1.5 rounded bg-slate-950 border border-slate-700 text-sm text-slate-100 outline-none focus:border-blue-500 transition-colors"
955
+ />
956
+ <div className="flex gap-2 justify-end">
957
+ <button
958
+ type="button"
959
+ onClick={() => setSaveAsValue(null)}
960
+ className="px-3 py-1 rounded text-xs text-slate-400 hover:bg-slate-700 transition-colors"
961
+ >
962
+ Cancel
963
+ </button>
964
+ <button
965
+ type="button"
966
+ onClick={confirmSaveAs}
967
+ className="px-3 py-1 rounded text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors"
968
+ >
969
+ Save
970
+ </button>
971
+ </div>
972
+ </div>
973
+ </div>
974
+ )}
975
+ </div>
976
+ );
977
+ }