create-interview-cockpit 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.
Files changed (39) hide show
  1. package/README.md +62 -0
  2. package/index.js +302 -0
  3. package/package.json +44 -0
  4. package/template/.env.example +14 -0
  5. package/template/client/index.html +12 -0
  6. package/template/client/package-lock.json +6012 -0
  7. package/template/client/package.json +34 -0
  8. package/template/client/postcss.config.cjs +6 -0
  9. package/template/client/src/App.tsx +120 -0
  10. package/template/client/src/api.ts +132 -0
  11. package/template/client/src/components/AnnotationDialog.tsx +307 -0
  12. package/template/client/src/components/ChatMessage.tsx +89 -0
  13. package/template/client/src/components/ChatView.tsx +763 -0
  14. package/template/client/src/components/CodeContextPanel.tsx +470 -0
  15. package/template/client/src/components/FileAttachments.tsx +107 -0
  16. package/template/client/src/components/FileViewerModal.tsx +470 -0
  17. package/template/client/src/components/MarkdownRenderer.tsx +333 -0
  18. package/template/client/src/components/MermaidDiagram.tsx +157 -0
  19. package/template/client/src/components/Sidebar.tsx +419 -0
  20. package/template/client/src/components/TextAnnotator.tsx +476 -0
  21. package/template/client/src/index.css +61 -0
  22. package/template/client/src/main.tsx +10 -0
  23. package/template/client/src/store.ts +321 -0
  24. package/template/client/src/types.ts +65 -0
  25. package/template/client/src/vite-env.d.ts +1 -0
  26. package/template/client/tailwind.config.cjs +8 -0
  27. package/template/client/tsconfig.json +16 -0
  28. package/template/client/tsconfig.tsbuildinfo +1 -0
  29. package/template/client/vite.config.ts +12 -0
  30. package/template/cockpit.json +3 -0
  31. package/template/data/context-files/.gitkeep +0 -0
  32. package/template/data/questions/.gitkeep +0 -0
  33. package/template/data/topics.json +1 -0
  34. package/template/package.json +14 -0
  35. package/template/server/package-lock.json +2266 -0
  36. package/template/server/package.json +31 -0
  37. package/template/server/src/index.ts +758 -0
  38. package/template/server/src/storage.ts +303 -0
  39. package/template/server/tsconfig.json +14 -0
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "interview-cockpit-client",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "tsc -b && vite build",
8
+ "preview": "vite preview"
9
+ },
10
+ "dependencies": {
11
+ "@ai-sdk/react": "^3.0.170",
12
+ "ai": "^6.0.168",
13
+ "lucide-react": "^0.460.0",
14
+ "mermaid": "^11.4.0",
15
+ "react": "^19.0.0",
16
+ "react-dom": "^19.0.0",
17
+ "react-markdown": "^9.0.0",
18
+ "react-syntax-highlighter": "^15.6.1",
19
+ "remark-gfm": "^4.0.0",
20
+ "zustand": "^5.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@tailwindcss/typography": "^0.5.15",
24
+ "@types/react": "^19.0.0",
25
+ "@types/react-dom": "^19.0.0",
26
+ "@types/react-syntax-highlighter": "^15.5.13",
27
+ "@vitejs/plugin-react": "^4.3.0",
28
+ "autoprefixer": "^10.4.0",
29
+ "postcss": "^8.4.0",
30
+ "tailwindcss": "^3.4.0",
31
+ "typescript": "^5.6.0",
32
+ "vite": "^6.0.0"
33
+ }
34
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
@@ -0,0 +1,120 @@
1
+ import { useEffect } from "react";
2
+ import { useStore } from "./store";
3
+ import Sidebar from "./components/Sidebar";
4
+ import ChatView from "./components/ChatView";
5
+ import CodeContextPanel from "./components/CodeContextPanel";
6
+ import FileViewerModal from "./components/FileViewerModal";
7
+ import { Code, Plane, PanelLeftClose, PanelLeft } from "lucide-react";
8
+
9
+ export default function App() {
10
+ const {
11
+ fetchTopics,
12
+ currentQuestion,
13
+ showCodePanel,
14
+ toggleCodePanel,
15
+ showSidebar,
16
+ toggleSidebar,
17
+ viewingFile,
18
+ closeFileViewer,
19
+ } = useStore();
20
+
21
+ useEffect(() => {
22
+ fetchTopics();
23
+ }, [fetchTopics]);
24
+
25
+ return (
26
+ <div className="flex h-screen bg-slate-950">
27
+ {showSidebar && <Sidebar />}
28
+
29
+ <main className="flex-1 flex flex-col min-w-0">
30
+ {/* Header bar */}
31
+ <header className="h-12 border-b border-slate-800 flex items-center justify-between px-4 shrink-0">
32
+ <div className="flex items-center gap-2">
33
+ <button
34
+ onClick={toggleSidebar}
35
+ className="p-1.5 rounded transition-colors text-slate-500 hover:text-slate-300 hover:bg-slate-800"
36
+ title={showSidebar ? "Hide sidebar" : "Show sidebar"}
37
+ >
38
+ {showSidebar ? (
39
+ <PanelLeftClose className="w-4 h-4" />
40
+ ) : (
41
+ <PanelLeft className="w-4 h-4" />
42
+ )}
43
+ </button>
44
+ <Plane className="w-5 h-5 text-cyan-400" />
45
+ <span className="text-sm font-semibold text-slate-300">
46
+ {currentQuestion ? currentQuestion.title : "Interview Cockpit"}
47
+ </span>
48
+ </div>
49
+ <button
50
+ onClick={toggleCodePanel}
51
+ className={`p-1.5 rounded transition-colors ${
52
+ showCodePanel
53
+ ? "bg-cyan-500/20 text-cyan-400"
54
+ : "text-slate-500 hover:text-slate-300"
55
+ }`}
56
+ title="Toggle code context"
57
+ >
58
+ <Code className="w-4 h-4" />
59
+ </button>
60
+ </header>
61
+
62
+ {/* Content area */}
63
+ <div className="flex-1 flex min-h-0">
64
+ <div className="flex-1 min-w-0">
65
+ {currentQuestion ? (
66
+ <ChatView key={currentQuestion.id} question={currentQuestion} />
67
+ ) : (
68
+ <EmptyState />
69
+ )}
70
+ </div>
71
+ {showCodePanel && (
72
+ <div
73
+ onWheel={(e) => {
74
+ // Walk up from the event target to check if any ancestor
75
+ // inside the code panel is itself scrollable — if so, let
76
+ // the browser handle it natively (no forwarding).
77
+ let el = e.target as HTMLElement | null;
78
+ while (el && el !== e.currentTarget) {
79
+ const oy = getComputedStyle(el).overflowY;
80
+ if (
81
+ (oy === "auto" || oy === "scroll") &&
82
+ el.scrollHeight > el.clientHeight
83
+ ) {
84
+ return;
85
+ }
86
+ el = el.parentElement;
87
+ }
88
+ // Nothing scrollable under cursor — forward to chat
89
+ const chatScroll = document.getElementById("chat-scroll-area");
90
+ if (chatScroll) chatScroll.scrollTop += e.deltaY;
91
+ }}
92
+ >
93
+ <CodeContextPanel />
94
+ </div>
95
+ )}
96
+ </div>
97
+ </main>
98
+
99
+ {viewingFile && (
100
+ <FileViewerModal filePath={viewingFile} onClose={closeFileViewer} />
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ function EmptyState() {
107
+ return (
108
+ <div className="flex-1 flex items-center justify-center h-full">
109
+ <div className="text-center">
110
+ <Plane className="w-12 h-12 text-slate-700 mx-auto mb-4" />
111
+ <h2 className="text-lg font-medium text-slate-500">
112
+ Interview Cockpit
113
+ </h2>
114
+ <p className="text-sm text-slate-600 mt-1">
115
+ Select a question from the sidebar to start
116
+ </p>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,132 @@
1
+ import type { Topic, Question, ContextFile } from "./types";
2
+
3
+ const BASE = "/api";
4
+
5
+ export async function fetchTopics(): Promise<Topic[]> {
6
+ const res = await fetch(`${BASE}/topics`);
7
+ return res.json();
8
+ }
9
+
10
+ export async function createTopic(name: string): Promise<Topic> {
11
+ const res = await fetch(`${BASE}/topics`, {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify({ name }),
15
+ });
16
+ return res.json();
17
+ }
18
+
19
+ export async function deleteTopic(id: string): Promise<void> {
20
+ await fetch(`${BASE}/topics/${id}`, { method: "DELETE" });
21
+ }
22
+
23
+ export async function updateTopic(
24
+ id: string,
25
+ data: { name?: string },
26
+ ): Promise<Topic> {
27
+ const res = await fetch(`${BASE}/topics/${id}`, {
28
+ method: "PATCH",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify(data),
31
+ });
32
+ return res.json();
33
+ }
34
+
35
+ // --- Topic Context Files ---
36
+
37
+ export async function uploadTopicFiles(
38
+ topicId: string,
39
+ files: FileList | File[],
40
+ ): Promise<ContextFile[]> {
41
+ const form = new FormData();
42
+ for (const file of files) form.append("files", file);
43
+ const res = await fetch(`${BASE}/topics/${topicId}/context-files`, {
44
+ method: "POST",
45
+ body: form,
46
+ });
47
+ return res.json();
48
+ }
49
+
50
+ export async function deleteTopicFile(
51
+ topicId: string,
52
+ fileId: string,
53
+ ): Promise<void> {
54
+ await fetch(`${BASE}/topics/${topicId}/context-files/${fileId}`, {
55
+ method: "DELETE",
56
+ });
57
+ }
58
+
59
+ // --- Questions ---
60
+
61
+ export async function fetchQuestions(topicId: string): Promise<Question[]> {
62
+ const res = await fetch(`${BASE}/topics/${topicId}/questions`);
63
+ return res.json();
64
+ }
65
+
66
+ export async function createQuestion(
67
+ topicId: string,
68
+ title: string,
69
+ parentQuestionId?: string,
70
+ ): Promise<Question> {
71
+ const res = await fetch(`${BASE}/topics/${topicId}/questions`, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({
75
+ title,
76
+ ...(parentQuestionId ? { parentQuestionId } : {}),
77
+ }),
78
+ });
79
+ return res.json();
80
+ }
81
+
82
+ export async function fetchQuestion(id: string): Promise<Question> {
83
+ const res = await fetch(`${BASE}/questions/${id}`);
84
+ return res.json();
85
+ }
86
+
87
+ export async function updateQuestion(
88
+ id: string,
89
+ data: Partial<Question>,
90
+ ): Promise<Question> {
91
+ const res = await fetch(`${BASE}/questions/${id}`, {
92
+ method: "PATCH",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify(data),
95
+ });
96
+ return res.json();
97
+ }
98
+
99
+ export async function deleteQuestion(id: string): Promise<void> {
100
+ await fetch(`${BASE}/questions/${id}`, { method: "DELETE" });
101
+ }
102
+
103
+ // --- Question Context Files ---
104
+
105
+ export async function uploadQuestionFiles(
106
+ questionId: string,
107
+ files: FileList | File[],
108
+ ): Promise<ContextFile[]> {
109
+ const form = new FormData();
110
+ for (const file of files) form.append("files", file);
111
+ const res = await fetch(`${BASE}/questions/${questionId}/context-files`, {
112
+ method: "POST",
113
+ body: form,
114
+ });
115
+ return res.json();
116
+ }
117
+
118
+ export async function deleteQuestionFile(
119
+ questionId: string,
120
+ fileId: string,
121
+ ): Promise<void> {
122
+ await fetch(`${BASE}/questions/${questionId}/context-files/${fileId}`, {
123
+ method: "DELETE",
124
+ });
125
+ }
126
+
127
+ // --- Code Context ---
128
+
129
+ export async function fetchCodeContextTree(): Promise<string[]> {
130
+ const res = await fetch(`${BASE}/code-context/tree`);
131
+ return res.json();
132
+ }
@@ -0,0 +1,307 @@
1
+ import { useState, useRef, useCallback, useEffect } from "react";
2
+ import { X, Loader2 } from "lucide-react";
3
+ import type { Annotation, AnnotationFollowUp } from "../types";
4
+ import MarkdownRenderer from "./MarkdownRenderer";
5
+
6
+ const DEFAULT_W = 480;
7
+ const DEFAULT_H = 440;
8
+ const MIN_W = 300;
9
+ const MIN_H = 220;
10
+
11
+ type ResizeDir = "e" | "s" | "se" | "sw" | "w" | "ne" | "nw" | "n" | null;
12
+
13
+ interface Props {
14
+ annotation: Annotation;
15
+ onClose: () => void;
16
+ onUpdate: (updated: Annotation) => void;
17
+ messageContent: string;
18
+ initialPos?: { x: number; y: number };
19
+ responseLength?: string;
20
+ responseStyle?: string;
21
+ responseAudience?: string;
22
+ }
23
+
24
+ export default function AnnotationDialog({
25
+ annotation,
26
+ onClose,
27
+ onUpdate,
28
+ messageContent,
29
+ initialPos,
30
+ responseLength,
31
+ responseStyle,
32
+ responseAudience,
33
+ }: Props) {
34
+ const [pos, setPos] = useState(() => ({
35
+ x: initialPos?.x ?? Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
36
+ y: initialPos?.y ?? Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
37
+ }));
38
+ const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
39
+ const [followUpInput, setFollowUpInput] = useState("");
40
+ const [followUpLoading, setFollowUpLoading] = useState(false);
41
+ const scrollBodyRef = useRef<HTMLDivElement>(null);
42
+
43
+ // Keep a ref to the latest annotation so async callbacks see current data
44
+ const annotationRef = useRef(annotation);
45
+ useEffect(() => {
46
+ annotationRef.current = annotation;
47
+ }, [annotation]);
48
+
49
+ // Scroll to bottom when follow-ups are added
50
+ useEffect(() => {
51
+ setTimeout(() => {
52
+ scrollBodyRef.current?.scrollTo({
53
+ top: scrollBodyRef.current.scrollHeight,
54
+ behavior: "smooth",
55
+ });
56
+ }, 50);
57
+ }, [annotation.followUps?.length]);
58
+
59
+ // ── Drag ───────────────────────────────────────────────
60
+ const dragStart = useRef<{
61
+ mx: number;
62
+ my: number;
63
+ ox: number;
64
+ oy: number;
65
+ } | null>(null);
66
+ const resizeDir = useRef<ResizeDir>(null);
67
+ const resizeStart = useRef<{
68
+ mx: number;
69
+ my: number;
70
+ ox: number;
71
+ oy: number;
72
+ ow: number;
73
+ oh: number;
74
+ } | null>(null);
75
+
76
+ useEffect(() => {
77
+ const onMove = (e: MouseEvent) => {
78
+ const drag = dragStart.current;
79
+ const resize = resizeStart.current;
80
+ const dir = resizeDir.current;
81
+
82
+ if (drag) {
83
+ setPos({
84
+ x: Math.max(0, drag.ox + (e.clientX - drag.mx)),
85
+ y: Math.max(0, drag.oy + (e.clientY - drag.my)),
86
+ });
87
+ }
88
+
89
+ if (resize && dir) {
90
+ const dx = e.clientX - resize.mx;
91
+ const dy = e.clientY - resize.my;
92
+ setSize((prev) => {
93
+ let w = prev.w;
94
+ let h = prev.h;
95
+ if (dir.includes("e")) w = Math.max(MIN_W, resize.ow + dx);
96
+ if (dir.includes("s")) h = Math.max(MIN_H, resize.oh + dy);
97
+ if (dir.includes("w")) w = Math.max(MIN_W, resize.ow - dx);
98
+ if (dir.includes("n")) h = Math.max(MIN_H, resize.oh - dy);
99
+ return { w, h };
100
+ });
101
+ if (dir.includes("w")) {
102
+ setPos((prev) => ({
103
+ ...prev,
104
+ x: Math.min(resize.ox + resize.ow - MIN_W, resize.ox + dx),
105
+ }));
106
+ }
107
+ if (dir.includes("n")) {
108
+ setPos((prev) => ({
109
+ ...prev,
110
+ y: Math.min(resize.oy + resize.oh - MIN_H, resize.oy + dy),
111
+ }));
112
+ }
113
+ }
114
+ };
115
+ const onUp = () => {
116
+ dragStart.current = null;
117
+ resizeStart.current = null;
118
+ resizeDir.current = null;
119
+ };
120
+ document.addEventListener("mousemove", onMove);
121
+ document.addEventListener("mouseup", onUp);
122
+ return () => {
123
+ document.removeEventListener("mousemove", onMove);
124
+ document.removeEventListener("mouseup", onUp);
125
+ };
126
+ }, []);
127
+
128
+ const onTitleMouseDown = useCallback(
129
+ (e: React.MouseEvent) => {
130
+ e.preventDefault();
131
+ dragStart.current = {
132
+ mx: e.clientX,
133
+ my: e.clientY,
134
+ ox: pos.x,
135
+ oy: pos.y,
136
+ };
137
+ },
138
+ [pos],
139
+ );
140
+
141
+ const startResize = useCallback(
142
+ (dir: ResizeDir) => (e: React.MouseEvent) => {
143
+ e.preventDefault();
144
+ e.stopPropagation();
145
+ resizeDir.current = dir;
146
+ resizeStart.current = {
147
+ mx: e.clientX,
148
+ my: e.clientY,
149
+ ox: pos.x,
150
+ oy: pos.y,
151
+ ow: size.w,
152
+ oh: size.h,
153
+ };
154
+ },
155
+ [pos, size],
156
+ );
157
+
158
+ const handleFollowUpSubmit = async () => {
159
+ if (!followUpInput.trim() || followUpLoading) return;
160
+ const ann = annotationRef.current;
161
+ setFollowUpLoading(true);
162
+ try {
163
+ const res = await fetch("/api/inline-ask", {
164
+ method: "POST",
165
+ headers: { "Content-Type": "application/json" },
166
+ body: JSON.stringify({
167
+ selectedText: ann.selectedText,
168
+ prompt: followUpInput.trim(),
169
+ messageContent,
170
+ priorResponse: ann.response,
171
+ followUps: ann.followUps ?? [],
172
+ responseLength,
173
+ responseStyle,
174
+ responseAudience,
175
+ }),
176
+ });
177
+ const data = await res.json();
178
+ const newFollowUp: AnnotationFollowUp = {
179
+ id: crypto.randomUUID(),
180
+ prompt: followUpInput.trim(),
181
+ response: data.response ?? "No response.",
182
+ createdAt: new Date().toISOString(),
183
+ };
184
+ onUpdate({ ...ann, followUps: [...(ann.followUps ?? []), newFollowUp] });
185
+ setFollowUpInput("");
186
+ } catch {
187
+ // silently ignore
188
+ } finally {
189
+ setFollowUpLoading(false);
190
+ }
191
+ };
192
+
193
+ const excerpt =
194
+ annotation.selectedText.length > 70
195
+ ? annotation.selectedText.slice(0, 70) + "…"
196
+ : annotation.selectedText;
197
+
198
+ return (
199
+ <div
200
+ className="fixed z-50 flex flex-col bg-slate-900 border border-slate-700/80 rounded-xl shadow-2xl overflow-hidden"
201
+ style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
202
+ >
203
+ {/* Title bar */}
204
+ <div
205
+ className="flex items-start gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700 cursor-move select-none shrink-0"
206
+ onMouseDown={onTitleMouseDown}
207
+ >
208
+ <div className="flex-1 min-w-0">
209
+ <p className="text-xs font-medium text-slate-300 truncate">
210
+ {annotation.prompt}
211
+ </p>
212
+ <p className="text-[10px] text-slate-500 truncate italic mt-0.5">
213
+ re: &ldquo;{excerpt}&rdquo;
214
+ </p>
215
+ </div>
216
+ <button
217
+ onMouseDown={(e) => e.stopPropagation()}
218
+ onClick={onClose}
219
+ className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors shrink-0 mt-0.5"
220
+ >
221
+ <X className="w-3.5 h-3.5" />
222
+ </button>
223
+ </div>
224
+
225
+ {/* Scrollable body */}
226
+ <div
227
+ ref={scrollBodyRef}
228
+ className="flex-1 overflow-y-auto px-4 py-3 text-sm"
229
+ >
230
+ <MarkdownRenderer content={annotation.response} />
231
+ {(annotation.followUps ?? []).map((fu) => (
232
+ <div key={fu.id}>
233
+ <hr className="border-slate-700 my-3" />
234
+ <p className="text-[11px] text-slate-500 mb-1.5">
235
+ ↳ You: {fu.prompt}
236
+ </p>
237
+ <MarkdownRenderer content={fu.response} />
238
+ </div>
239
+ ))}
240
+ </div>
241
+
242
+ {/* Follow-up input */}
243
+ <div className="shrink-0 border-t border-slate-700/60 bg-slate-800/80 px-2.5 py-2 flex gap-2 items-end">
244
+ <textarea
245
+ value={followUpInput}
246
+ onChange={(e) => setFollowUpInput(e.target.value)}
247
+ onKeyDown={(e) => {
248
+ if (e.key === "Enter" && !e.shiftKey) {
249
+ e.preventDefault();
250
+ handleFollowUpSubmit();
251
+ }
252
+ if (e.key === "Escape") onClose();
253
+ }}
254
+ placeholder="Ask a follow-up…"
255
+ rows={1}
256
+ disabled={followUpLoading}
257
+ className="flex-1 bg-slate-900 text-slate-200 text-xs rounded-lg px-2 py-1.5 resize-none outline-none border border-slate-700 focus:border-cyan-500 transition-colors disabled:opacity-50"
258
+ />
259
+ <button
260
+ onClick={handleFollowUpSubmit}
261
+ disabled={followUpLoading || !followUpInput.trim()}
262
+ className="shrink-0 bg-cyan-700 hover:bg-cyan-600 disabled:opacity-40 disabled:cursor-not-allowed text-white text-xs px-2.5 py-1.5 rounded-lg transition-colors flex items-center gap-1"
263
+ >
264
+ {followUpLoading ? (
265
+ <Loader2 size={12} className="animate-spin" />
266
+ ) : (
267
+ "Ask"
268
+ )}
269
+ </button>
270
+ </div>
271
+
272
+ {/* Resize handles */}
273
+ <div
274
+ className="absolute inset-x-0 bottom-0 h-1.5 cursor-s-resize"
275
+ onMouseDown={startResize("s")}
276
+ />
277
+ <div
278
+ className="absolute inset-y-0 right-0 w-1.5 cursor-e-resize"
279
+ onMouseDown={startResize("e")}
280
+ />
281
+ <div
282
+ className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize"
283
+ onMouseDown={startResize("se")}
284
+ />
285
+ <div
286
+ className="absolute inset-y-0 left-0 w-1.5 cursor-w-resize"
287
+ onMouseDown={startResize("w")}
288
+ />
289
+ <div
290
+ className="absolute inset-x-0 top-[32px] h-1.5 cursor-n-resize"
291
+ onMouseDown={startResize("n")}
292
+ />
293
+ <div
294
+ className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize"
295
+ onMouseDown={startResize("sw")}
296
+ />
297
+ <div
298
+ className="absolute top-[32px] right-0 w-3 h-3 cursor-ne-resize"
299
+ onMouseDown={startResize("ne")}
300
+ />
301
+ <div
302
+ className="absolute top-[32px] left-0 w-3 h-3 cursor-nw-resize"
303
+ onMouseDown={startResize("nw")}
304
+ />
305
+ </div>
306
+ );
307
+ }
@@ -0,0 +1,89 @@
1
+ import { memo } from "react";
2
+ import type { UIMessage } from "ai";
3
+ import { User, Bot } from "lucide-react";
4
+ import TextAnnotator from "./TextAnnotator";
5
+ import type { Annotation } from "../types";
6
+
7
+ interface Props {
8
+ message: UIMessage;
9
+ annotations?: Annotation[];
10
+ onAnnotationCreate?: (annotation: Annotation) => void;
11
+ onAnnotationUpdate?: (annotation: Annotation) => void;
12
+ bookmarkedBlockIndex?: number;
13
+ onSetBookmark?: (messageId: string, blockIndex: number) => void;
14
+ responseLength?: string;
15
+ responseStyle?: string;
16
+ responseAudience?: string;
17
+ }
18
+
19
+ function getTextContent(message: UIMessage): string {
20
+ if (message.parts) {
21
+ return message.parts
22
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
23
+ .map((p) => p.text)
24
+ .join("");
25
+ }
26
+ return "";
27
+ }
28
+
29
+ const ChatMessage = memo(function ChatMessage({
30
+ message,
31
+ annotations = [],
32
+ onAnnotationCreate,
33
+ onAnnotationUpdate,
34
+ bookmarkedBlockIndex,
35
+ onSetBookmark,
36
+ responseLength,
37
+ responseStyle,
38
+ responseAudience,
39
+ }: Props) {
40
+ const isUser = message.role === "user";
41
+ const content = getTextContent(message);
42
+
43
+ return (
44
+ <div className="flex gap-3 animate-fadeIn">
45
+ <div
46
+ className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 mt-0.5 ${
47
+ isUser
48
+ ? "bg-slate-700 text-slate-300"
49
+ : "bg-cyan-500/20 text-cyan-400"
50
+ }`}
51
+ >
52
+ {isUser ? (
53
+ <User className="w-3.5 h-3.5" />
54
+ ) : (
55
+ <Bot className="w-3.5 h-3.5" />
56
+ )}
57
+ </div>
58
+ <div className="min-w-0 flex-1">
59
+ <div className="text-[10px] font-medium text-slate-600 mb-1">
60
+ {isUser ? "You" : "Coach"}
61
+ </div>
62
+ <div className="text-sm leading-relaxed text-slate-200">
63
+ {isUser ? (
64
+ <p className="whitespace-pre-wrap text-slate-300">{content}</p>
65
+ ) : (
66
+ <TextAnnotator
67
+ content={content}
68
+ messageId={message.id}
69
+ annotations={annotations}
70
+ onAnnotationCreate={onAnnotationCreate ?? (() => {})}
71
+ onAnnotationUpdate={onAnnotationUpdate ?? (() => {})}
72
+ bookmarkedBlockIndex={bookmarkedBlockIndex}
73
+ onBookmarkBlock={
74
+ onSetBookmark
75
+ ? (idx) => onSetBookmark(message.id, idx)
76
+ : undefined
77
+ }
78
+ responseLength={responseLength}
79
+ responseStyle={responseStyle}
80
+ responseAudience={responseAudience}
81
+ />
82
+ )}
83
+ </div>
84
+ </div>
85
+ </div>
86
+ );
87
+ });
88
+
89
+ export default ChatMessage;