create-interview-cockpit 0.3.0 → 0.5.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 (28) hide show
  1. package/README.md +23 -0
  2. package/package.json +1 -1
  3. package/template/client/package-lock.json +42 -0
  4. package/template/client/package.json +5 -0
  5. package/template/client/src/App.tsx +45 -12
  6. package/template/client/src/api.ts +174 -0
  7. package/template/client/src/components/AiSettingsModal.tsx +1041 -0
  8. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  9. package/template/client/src/components/ChatMessage.tsx +110 -27
  10. package/template/client/src/components/ChatView.tsx +239 -137
  11. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  12. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  13. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  14. package/template/client/src/components/DocRefModal.tsx +502 -0
  15. package/template/client/src/components/FileAttachments.tsx +109 -9
  16. package/template/client/src/components/FilePickerModal.tsx +181 -0
  17. package/template/client/src/components/FileViewerModal.tsx +406 -28
  18. package/template/client/src/components/MarkdownRenderer.tsx +210 -2
  19. package/template/client/src/components/Sidebar.tsx +213 -125
  20. package/template/client/src/components/TextAnnotator.tsx +8 -15
  21. package/template/client/src/components/VizCraftEmbed.tsx +645 -0
  22. package/template/client/src/store.ts +275 -0
  23. package/template/client/src/types.ts +9 -0
  24. package/template/cockpit.json +1 -1
  25. package/template/data/ai-settings.json +49 -0
  26. package/template/server/src/google-drive.ts +109 -1
  27. package/template/server/src/index.ts +1187 -76
  28. package/template/server/src/storage.ts +359 -2
@@ -1,11 +1,12 @@
1
1
  import ReactMarkdown from "react-markdown";
2
2
  import remarkGfm from "remark-gfm";
3
- import { useMemo, useRef, useEffect } from "react";
3
+ import { useMemo, useRef, useEffect, useState } from "react";
4
4
  import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5
5
  import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
6
6
  import MermaidDiagram from "./MermaidDiagram";
7
+ import VizCraftEmbed from "./VizCraftEmbed";
7
8
  import { useStore } from "../store";
8
- import { Bookmark } from "lucide-react";
9
+ import { Bookmark, ExternalLink, Play, Save, Check } from "lucide-react";
9
10
 
10
11
  import type { Components } from "react-markdown";
11
12
 
@@ -14,6 +15,140 @@ interface Props {
14
15
  onAnnotationClick?: (annotId: string, rect: DOMRect) => void;
15
16
  bookmarkedBlockIndex?: number;
16
17
  onBookmarkBlock?: (blockIndex: number) => void;
18
+ onSpecRefined?: (originalSpec: string, newSpec: string) => void;
19
+ }
20
+
21
+ // ── Inline code block (AI-written code that can be opened in FileViewerModal) ──
22
+ // Parses an optional `// @ref: some-id` first-line directive so the AI can link
23
+ // back to this block anywhere in its response via [Label](inlineref://some-id).
24
+ function InlineCodeBlock({
25
+ codeString,
26
+ language,
27
+ }: {
28
+ codeString: string;
29
+ language: string;
30
+ }) {
31
+ const registerInlineCode = useStore((s) => s.registerInlineCode);
32
+ const openFileViewer = useStore((s) => s.openFileViewer);
33
+ const openCodeRunner = useStore((s) => s.openCodeRunner);
34
+ const currentQuestion = useStore((s) => s.currentQuestion);
35
+ const saveCodeSnippetToQuestion = useStore(
36
+ (s) => s.saveCodeSnippetToQuestion,
37
+ );
38
+ const [savedAi, setSavedAi] = useState<boolean>(false);
39
+
40
+ // Parse optional @ref directive on first line (strips it from display)
41
+ const parsed = useMemo(() => {
42
+ const firstLine = codeString.split("\n")[0]?.trim() ?? "";
43
+ const refMatch = firstLine.match(/^(?:\/\/|#|--)\s*@ref:\s*(.+)$/);
44
+ if (refMatch) {
45
+ const label = refMatch[1].trim();
46
+ return {
47
+ id: `inline:${label}`,
48
+ label,
49
+ displayCode: codeString.split("\n").slice(1).join("\n"),
50
+ };
51
+ }
52
+ return { id: null, label: `${language} snippet`, displayCode: codeString };
53
+ }, [codeString, language]);
54
+
55
+ // Stable UUID for blocks without an explicit @ref
56
+ const stableIdRef = useRef(`inline:${crypto.randomUUID()}`);
57
+ const finalId = parsed.id ?? stableIdRef.current;
58
+
59
+ // Register on mount when there is an explicit @ref so inlineref:// links resolve
60
+ useEffect(() => {
61
+ if (parsed.id) {
62
+ registerInlineCode(parsed.id, {
63
+ content: parsed.displayCode,
64
+ language,
65
+ label: parsed.label,
66
+ });
67
+ }
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, [parsed.id]);
70
+
71
+ const handlePopout = () => {
72
+ registerInlineCode(finalId, {
73
+ content: parsed.displayCode,
74
+ language,
75
+ label: parsed.label,
76
+ });
77
+ openFileViewer(finalId);
78
+ };
79
+
80
+ const handleRun = () => {
81
+ const runLang =
82
+ language === "typescript" || language === "tsx"
83
+ ? "typescript"
84
+ : "javascript";
85
+ openCodeRunner(parsed.displayCode, runLang);
86
+ };
87
+
88
+ const handleSaveAi = async () => {
89
+ if (!currentQuestion || savedAi) return;
90
+ await saveCodeSnippetToQuestion(
91
+ currentQuestion.id,
92
+ parsed.displayCode,
93
+ language,
94
+ parsed.label,
95
+ "ai",
96
+ );
97
+ setSavedAi(true);
98
+ setTimeout(() => setSavedAi(false), 2000);
99
+ };
100
+
101
+ return (
102
+ <div className="relative group/codeblock">
103
+ <div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-0.5 opacity-0 group-hover/codeblock:opacity-100 transition-all">
104
+ <button
105
+ type="button"
106
+ onClick={handleRun}
107
+ className="p-1 rounded bg-slate-700/80 hover:bg-slate-600/90 text-slate-400 hover:text-emerald-400 transition-colors"
108
+ title="Run in code runner"
109
+ >
110
+ <Play className="w-3.5 h-3.5" />
111
+ </button>
112
+ {currentQuestion && (
113
+ <button
114
+ type="button"
115
+ onClick={handleSaveAi}
116
+ className={`p-1 rounded bg-slate-700/80 hover:bg-slate-600/90 transition-colors ${
117
+ savedAi ? "text-cyan-400" : "text-slate-400 hover:text-cyan-400"
118
+ }`}
119
+ title="Save to question context"
120
+ >
121
+ {savedAi ? (
122
+ <Check className="w-3.5 h-3.5" />
123
+ ) : (
124
+ <Save className="w-3.5 h-3.5" />
125
+ )}
126
+ </button>
127
+ )}
128
+ <button
129
+ type="button"
130
+ onClick={handlePopout}
131
+ className="p-1 rounded bg-slate-700/80 hover:bg-slate-600/90 text-slate-400 hover:text-violet-400 transition-colors"
132
+ title="Open in code viewer"
133
+ >
134
+ <ExternalLink className="w-3.5 h-3.5" />
135
+ </button>
136
+ </div>
137
+ <SyntaxHighlighter
138
+ style={oneDark}
139
+ language={language}
140
+ PreTag="div"
141
+ customStyle={{
142
+ margin: "0.5rem 0",
143
+ borderRadius: "0.5rem",
144
+ fontSize: "0.8rem",
145
+ background: "#1e293b",
146
+ }}
147
+ >
148
+ {parsed.displayCode}
149
+ </SyntaxHighlighter>
150
+ </div>
151
+ );
17
152
  }
18
153
 
19
154
  const markdownComponents: Components = {
@@ -34,6 +169,10 @@ const markdownComponents: Components = {
34
169
  return <MermaidDiagram chart={codeString} />;
35
170
  }
36
171
 
172
+ if (lang === "viz") {
173
+ return <VizCraftEmbed spec={codeString} />;
174
+ }
175
+
37
176
  return (
38
177
  <SyntaxHighlighter
39
178
  style={oneDark}
@@ -174,8 +313,10 @@ export default function MarkdownRenderer({
174
313
  onAnnotationClick,
175
314
  bookmarkedBlockIndex,
176
315
  onBookmarkBlock,
316
+ onSpecRefined,
177
317
  }: Props) {
178
318
  const openFileViewer = useStore((s) => s.openFileViewer);
319
+ const openDocViewer = useStore((s) => s.openDocViewer);
179
320
  const bookmarkElemRef = useRef<HTMLDivElement | null>(null);
180
321
 
181
322
  // Scroll the bookmarked block into view when the bookmark changes or on mount
@@ -232,6 +373,27 @@ export default function MarkdownRenderer({
232
373
 
233
374
  return {
234
375
  ...markdownComponents,
376
+ // Override the code renderer so viz blocks get the onSpecRefined callback
377
+ code({ className, children, ...props }) {
378
+ const match = /language-(\w+)/.exec(className || "");
379
+ const codeString = String(children).replace(/\n$/, "");
380
+ if (match) {
381
+ const lang = match[1];
382
+ if (lang === "mermaid") return <MermaidDiagram chart={codeString} />;
383
+ if (lang === "viz")
384
+ return (
385
+ <VizCraftEmbed spec={codeString} onSpecRefined={onSpecRefined} />
386
+ );
387
+ // Fenced code block: render with pop-out button
388
+ return <InlineCodeBlock codeString={codeString} language={lang} />;
389
+ }
390
+ // Delegate to the static handler for inline code / unsupported langs
391
+ return (markdownComponents.code as Function)({
392
+ className,
393
+ children,
394
+ ...props,
395
+ });
396
+ },
235
397
  p({ children, node }) {
236
398
  return wrapBlock(
237
399
  (node as any)?.position?.start?.line,
@@ -283,6 +445,35 @@ export default function MarkdownRenderer({
283
445
  </span>
284
446
  );
285
447
  }
448
+ if (href?.startsWith("docref://")) {
449
+ const withoutScheme = href.slice("docref://".length);
450
+ const qIdx = withoutScheme.indexOf("?");
451
+ const fileId =
452
+ qIdx === -1 ? withoutScheme : withoutScheme.slice(0, qIdx);
453
+ const params = new URLSearchParams(
454
+ qIdx === -1 ? "" : withoutScheme.slice(qIdx + 1),
455
+ );
456
+ const fileName = params.has("n")
457
+ ? decodeURIComponent(params.get("n")!)
458
+ : "Document";
459
+ const quote = String(
460
+ typeof children === "string"
461
+ ? children
462
+ : Array.isArray(children)
463
+ ? children.join("")
464
+ : "",
465
+ ).trim();
466
+ return (
467
+ <button
468
+ type="button"
469
+ onClick={() => openDocViewer(fileId, quote, fileName)}
470
+ className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 underline decoration-emerald-500/50 underline-offset-2 transition-colors text-[0.85em]"
471
+ title={`View in ${fileName}`}
472
+ >
473
+ {children}
474
+ </button>
475
+ );
476
+ }
286
477
  if (href?.startsWith("coderef://")) {
287
478
  const filePath = href.slice("coderef://".length);
288
479
  return (
@@ -296,6 +487,19 @@ export default function MarkdownRenderer({
296
487
  </button>
297
488
  );
298
489
  }
490
+ if (href?.startsWith("inlineref://")) {
491
+ const id = `inline:${href.slice("inlineref://".length)}`;
492
+ return (
493
+ <button
494
+ type="button"
495
+ onClick={() => openFileViewer(id)}
496
+ className="inline-flex items-center gap-1 text-violet-400 hover:text-violet-300 underline decoration-violet-500/50 underline-offset-2 transition-colors font-mono text-[0.8em]"
497
+ title="Open inline code block"
498
+ >
499
+ {children}
500
+ </button>
501
+ );
502
+ }
299
503
  return (
300
504
  <a
301
505
  href={href}
@@ -311,8 +515,10 @@ export default function MarkdownRenderer({
311
515
  }, [
312
516
  onAnnotationClick,
313
517
  openFileViewer,
518
+ openDocViewer,
314
519
  bookmarkedBlockIndex,
315
520
  onBookmarkBlock,
521
+ onSpecRefined,
316
522
  ]);
317
523
 
318
524
  return (
@@ -323,6 +529,8 @@ export default function MarkdownRenderer({
323
529
  // Allow our internal schemes; block javascript: for safety
324
530
  if (url.startsWith("annot://")) return url;
325
531
  if (url.startsWith("coderef://")) return url;
532
+ if (url.startsWith("docref://")) return url;
533
+ if (url.startsWith("inlineref://")) return url;
326
534
  if (url.startsWith("javascript:")) return "";
327
535
  return url;
328
536
  }}
@@ -16,6 +16,7 @@ import {
16
16
  Loader2,
17
17
  ArrowLeft,
18
18
  RefreshCw,
19
+ Globe,
19
20
  } from "lucide-react";
20
21
 
21
22
  export default function Sidebar() {
@@ -36,6 +37,7 @@ export default function Sidebar() {
36
37
  fetchQuestions,
37
38
  uploadTopicFiles,
38
39
  removeTopicFile,
40
+ linkFileToTopic,
39
41
  workspaces,
40
42
  activeWorkspaceId,
41
43
  driveRootFolders,
@@ -44,6 +46,9 @@ export default function Sidebar() {
44
46
  selectDriveSubfolder,
45
47
  clearDriveSubfolder,
46
48
  syncWorkspace,
49
+ workspaceFiles,
50
+ uploadWorkspaceFiles,
51
+ removeWorkspaceFile,
47
52
  } = useStore();
48
53
 
49
54
  const [newTopicName, setNewTopicName] = useState("");
@@ -58,6 +63,18 @@ export default function Sidebar() {
58
63
  null,
59
64
  );
60
65
  const [editingQuestionTitle, setEditingQuestionTitle] = useState("");
66
+ const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
67
+ new Set(),
68
+ );
69
+
70
+ const toggleQuestionCollapse = (questionId: string) => {
71
+ setCollapsedQuestions((prev) => {
72
+ const next = new Set(prev);
73
+ if (next.has(questionId)) next.delete(questionId);
74
+ else next.add(questionId);
75
+ return next;
76
+ });
77
+ };
61
78
  const editInputRef = useRef<HTMLInputElement>(null);
62
79
 
63
80
  // Drive subfolder navigator
@@ -72,6 +89,7 @@ export default function Sidebar() {
72
89
  : null;
73
90
  const [navigating, setNavigating] = useState(false);
74
91
  const [syncing, setSyncing] = useState(false);
92
+ const [wsFilesExpanded, setWsFilesExpanded] = useState(true);
75
93
 
76
94
  // Load root folders whenever a Drive workspace becomes active with no subfolder selected
77
95
  useEffect(() => {
@@ -159,98 +177,208 @@ export default function Sidebar() {
159
177
  const renderQuestionRow = (
160
178
  q: Question,
161
179
  topicId: string,
162
- isChild: boolean,
163
- ) => (
164
- <div
165
- key={q.id}
166
- className={`group flex items-center gap-1.5 ${
167
- isChild ? "pl-7" : "pl-3"
168
- } pr-2 py-1 cursor-pointer transition-colors ${
169
- selectedQuestionId === q.id
170
- ? "bg-cyan-500/10 border-l-2 border-l-cyan-500 -ml-px"
171
- : "hover:bg-slate-800/30"
172
- }`}
173
- onClick={() =>
174
- editingQuestionId !== q.id && selectQuestion(topicId, q.id)
175
- }
176
- >
177
- {isChild && (
178
- <span className="text-slate-600 text-[11px] shrink-0 leading-none select-none mr-0.5">
179
-
180
- </span>
181
- )}
182
- <MessageSquare className="w-3 h-3 text-slate-600 shrink-0" />
183
- {editingQuestionId === q.id ? (
184
- <input
185
- ref={editInputRef}
186
- value={editingQuestionTitle}
187
- onChange={(e) => setEditingQuestionTitle(e.target.value)}
188
- onKeyDown={(e) => {
189
- if (e.key === "Enter") commitQuestionRename(q.id, topicId);
190
- if (e.key === "Escape") setEditingQuestionId(null);
191
- }}
192
- onBlur={() => commitQuestionRename(q.id, topicId)}
193
- onClick={(e) => e.stopPropagation()}
194
- className="flex-1 min-w-0 bg-slate-700 border border-cyan-500 rounded px-1 py-0 text-xs text-slate-200 focus:outline-none"
195
- />
196
- ) : (
197
- <span
198
- className="text-xs text-slate-400 truncate flex-1"
199
- onDoubleClick={(e) => {
200
- e.stopPropagation();
201
- setEditingQuestionId(q.id);
202
- setEditingQuestionTitle(q.title);
203
- }}
204
- >
205
- {q.title}
180
+ depth: number,
181
+ hasChildren: boolean,
182
+ isCollapsed: boolean,
183
+ onToggleCollapse: () => void,
184
+ ) => {
185
+ // 12px base left padding + 16px per depth level
186
+ const paddingLeft = 12 + depth * 16;
187
+ return (
188
+ <div
189
+ key={q.id}
190
+ className={`group flex items-center gap-1.5 pr-2 py-1 cursor-pointer transition-colors ${
191
+ selectedQuestionId === q.id
192
+ ? "bg-cyan-500/10 border-l-2 border-l-cyan-500 -ml-px"
193
+ : "hover:bg-slate-800/30"
194
+ }`}
195
+ style={{ paddingLeft }}
196
+ onClick={() =>
197
+ editingQuestionId !== q.id && selectQuestion(topicId, q.id)
198
+ }
199
+ >
200
+ {depth > 0 && (
201
+ <span className="text-slate-600 text-[11px] shrink-0 leading-none select-none mr-0.5">
202
+
203
+ </span>
204
+ )}
205
+ {hasChildren ? (
206
+ <button
207
+ onClick={(e) => {
208
+ e.stopPropagation();
209
+ onToggleCollapse();
210
+ }}
211
+ className="shrink-0 text-slate-500 hover:text-slate-300 p-0.5 -ml-0.5 rounded transition-colors"
212
+ title={isCollapsed ? "Expand" : "Collapse"}
213
+ >
214
+ {isCollapsed ? (
215
+ <ChevronRight className="w-3 h-3" />
216
+ ) : (
217
+ <ChevronDown className="w-3 h-3" />
218
+ )}
219
+ </button>
220
+ ) : (
221
+ <MessageSquare className="w-3 h-3 text-slate-600 shrink-0" />
222
+ )}
223
+ {editingQuestionId === q.id ? (
224
+ <input
225
+ ref={editInputRef}
226
+ value={editingQuestionTitle}
227
+ onChange={(e) => setEditingQuestionTitle(e.target.value)}
228
+ onKeyDown={(e) => {
229
+ if (e.key === "Enter") commitQuestionRename(q.id, topicId);
230
+ if (e.key === "Escape") setEditingQuestionId(null);
231
+ }}
232
+ onBlur={() => commitQuestionRename(q.id, topicId)}
233
+ onClick={(e) => e.stopPropagation()}
234
+ className="flex-1 min-w-0 bg-slate-700 border border-cyan-500 rounded px-1 py-0 text-xs text-slate-200 focus:outline-none"
235
+ />
236
+ ) : (
237
+ <span
238
+ className="text-xs text-slate-400 truncate flex-1"
239
+ onDoubleClick={(e) => {
240
+ e.stopPropagation();
241
+ setEditingQuestionId(q.id);
242
+ setEditingQuestionTitle(q.title);
243
+ }}
244
+ >
245
+ {q.title}
246
+ </span>
247
+ )}
248
+ <span className="text-[10px] text-slate-700 shrink-0">
249
+ {q.messages.length > 0 ? `${q.messages.length}` : ""}
206
250
  </span>
207
- )}
208
- <span className="text-[10px] text-slate-700 shrink-0">
209
- {q.messages.length > 0 ? `${q.messages.length}` : ""}
210
- </span>
211
- {editingQuestionId !== q.id && !isChild && (
212
- <button
213
- onClick={(e) => {
214
- e.stopPropagation();
215
- setAddingChildTo(q.id);
216
- setNewChildTitle("");
217
- }}
218
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
219
- title="Add child question"
220
- >
221
- <CornerDownRight className="w-2.5 h-2.5" />
222
- </button>
223
- )}
224
- {editingQuestionId !== q.id && (
251
+ {editingQuestionId !== q.id && (
252
+ <button
253
+ onClick={(e) => {
254
+ e.stopPropagation();
255
+ setAddingChildTo(q.id);
256
+ setNewChildTitle("");
257
+ }}
258
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
259
+ title="Add child question"
260
+ >
261
+ <CornerDownRight className="w-2.5 h-2.5" />
262
+ </button>
263
+ )}
264
+ {editingQuestionId !== q.id && (
265
+ <button
266
+ onClick={(e) => {
267
+ e.stopPropagation();
268
+ setEditingQuestionId(q.id);
269
+ setEditingQuestionTitle(q.title);
270
+ }}
271
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
272
+ title="Rename"
273
+ >
274
+ <Pencil className="w-2.5 h-2.5" />
275
+ </button>
276
+ )}
225
277
  <button
226
278
  onClick={(e) => {
227
279
  e.stopPropagation();
228
- setEditingQuestionId(q.id);
229
- setEditingQuestionTitle(q.title);
280
+ if (window.confirm(`Delete "${q.title}"? This cannot be undone.`)) {
281
+ removeQuestion(q.id, topicId);
282
+ }
230
283
  }}
231
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
232
- title="Rename"
284
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
233
285
  >
234
- <Pencil className="w-2.5 h-2.5" />
286
+ <Trash2 className="w-2.5 h-2.5" />
235
287
  </button>
236
- )}
237
- <button
238
- onClick={(e) => {
239
- e.stopPropagation();
240
- removeQuestion(q.id, topicId);
241
- }}
242
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
243
- >
244
- <Trash2 className="w-2.5 h-2.5" />
245
- </button>
246
- </div>
247
- );
288
+ </div>
289
+ );
290
+ };
291
+
292
+ // Recursively renders a question and all its descendants.
293
+ const renderQuestionSubtree = (
294
+ questions: Question[],
295
+ topicId: string,
296
+ parentId: string | null,
297
+ depth: number,
298
+ ): React.ReactNode => {
299
+ const qs = questions.filter(
300
+ (q) => (q.parentQuestionId ?? null) === parentId,
301
+ );
302
+ return qs.map((q) => {
303
+ const hasChildren = questions.some((c) => c.parentQuestionId === q.id);
304
+ const isCollapsed = collapsedQuestions.has(q.id);
305
+ return (
306
+ <Fragment key={q.id}>
307
+ {renderQuestionRow(q, topicId, depth, hasChildren, isCollapsed, () =>
308
+ toggleQuestionCollapse(q.id),
309
+ )}
310
+ {/* Add-child input — indented one level deeper than this question */}
311
+ {addingChildTo === q.id && (
312
+ <div
313
+ className="pr-2 py-1 animate-fadeIn"
314
+ style={{ paddingLeft: 12 + (depth + 1) * 16 }}
315
+ >
316
+ <input
317
+ autoFocus
318
+ value={newChildTitle}
319
+ onChange={(e) => setNewChildTitle(e.target.value)}
320
+ onKeyDown={(e) => {
321
+ if (e.key === "Enter") handleAddChildQuestion(topicId, q.id);
322
+ if (e.key === "Escape") {
323
+ setAddingChildTo(null);
324
+ setNewChildTitle("");
325
+ }
326
+ }}
327
+ onBlur={() => {
328
+ if (!newChildTitle.trim()) setAddingChildTo(null);
329
+ }}
330
+ placeholder="Child question title..."
331
+ className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
332
+ />
333
+ </div>
334
+ )}
335
+ {/* Recurse into children — hidden when collapsed */}
336
+ {!isCollapsed &&
337
+ renderQuestionSubtree(questions, topicId, q.id, depth + 1)}
338
+ </Fragment>
339
+ );
340
+ });
341
+ };
248
342
 
249
343
  return (
250
344
  <aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
251
345
  {/* Workspace switcher */}
252
346
  <WorkspaceSwitcher />
253
347
 
348
+ {/* Workspace-level files — apply to all topics */}
349
+ <div className="border-b border-slate-800 shrink-0">
350
+ <button
351
+ onClick={() => setWsFilesExpanded((v) => !v)}
352
+ className="w-full flex items-center gap-1.5 px-3 py-1.5 hover:bg-slate-800/40 transition-colors"
353
+ >
354
+ {wsFilesExpanded ? (
355
+ <ChevronDown className="w-3 h-3 text-slate-500 shrink-0" />
356
+ ) : (
357
+ <ChevronRight className="w-3 h-3 text-slate-500 shrink-0" />
358
+ )}
359
+ <Globe className="w-3 h-3 text-violet-400 shrink-0" />
360
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 flex-1 text-left">
361
+ Workspace Files
362
+ </span>
363
+ {workspaceFiles.length > 0 && (
364
+ <span className="text-[10px] text-slate-600">
365
+ {workspaceFiles.length}
366
+ </span>
367
+ )}
368
+ </button>
369
+ {wsFilesExpanded && (
370
+ <div className="px-3 pb-2">
371
+ <FileAttachments
372
+ files={workspaceFiles}
373
+ onUpload={(files) => uploadWorkspaceFiles(files)}
374
+ onRemove={(fileId) => removeWorkspaceFile(fileId)}
375
+ downloadBase="/api/workspace/context-files"
376
+ label="workspace"
377
+ />
378
+ </div>
379
+ )}
380
+ </div>
381
+
254
382
  {/* Header — Drive breadcrumb when inside a subfolder, else normal Topics header */}
255
383
  <div className="h-12 border-b border-slate-800 flex items-center justify-between px-3 shrink-0">
256
384
  {isDriveWs && currentSubFolder ? (
@@ -480,57 +608,17 @@ export default function Sidebar() {
480
608
  onRemove={(fileId) =>
481
609
  removeTopicFile(topic.id, fileId)
482
610
  }
611
+ onLink={(fileId, originalName) =>
612
+ linkFileToTopic(topic.id, fileId, originalName)
613
+ }
614
+ downloadBase={`/api/topics/${topic.id}/context-files`}
483
615
  label="topic"
484
616
  compact
485
617
  />
486
618
  </div>
487
619
 
488
- {/* Questions grouped: root children */}
489
- {(() => {
490
- const rootQs = questions.filter(
491
- (q) => !q.parentQuestionId,
492
- );
493
- return rootQs.map((q) => {
494
- const children = questions.filter(
495
- (c) => c.parentQuestionId === q.id,
496
- );
497
- return (
498
- <Fragment key={q.id}>
499
- {renderQuestionRow(q, topic.id, false)}
500
- {/* Add-child input */}
501
- {addingChildTo === q.id && (
502
- <div className="pl-7 pr-2 py-1 animate-fadeIn">
503
- <input
504
- autoFocus
505
- value={newChildTitle}
506
- onChange={(e) =>
507
- setNewChildTitle(e.target.value)
508
- }
509
- onKeyDown={(e) => {
510
- if (e.key === "Enter")
511
- handleAddChildQuestion(topic.id, q.id);
512
- if (e.key === "Escape") {
513
- setAddingChildTo(null);
514
- setNewChildTitle("");
515
- }
516
- }}
517
- onBlur={() => {
518
- if (!newChildTitle.trim())
519
- setAddingChildTo(null);
520
- }}
521
- placeholder="Child question title..."
522
- className="w-full bg-slate-800 border border-slate-700 rounded px-2 py-0.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500"
523
- />
524
- </div>
525
- )}
526
- {/* Children */}
527
- {children.map((c) =>
528
- renderQuestionRow(c, topic.id, true),
529
- )}
530
- </Fragment>
531
- );
532
- });
533
- })()}
620
+ {/* Questions recursive tree (unlimited depth) */}
621
+ {renderQuestionSubtree(questions, topic.id, null, 0)}
534
622
 
535
623
  {/* Add question input */}
536
624
  {addingQuestionTo === topic.id ? (