create-interview-cockpit 0.4.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 (27) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +19 -0
  3. package/template/client/package.json +3 -0
  4. package/template/client/src/App.tsx +17 -0
  5. package/template/client/src/api.ts +135 -0
  6. package/template/client/src/components/AiSettingsModal.tsx +218 -4
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +110 -27
  9. package/template/client/src/components/ChatView.tsx +69 -4
  10. package/template/client/src/components/CodeContextPanel.tsx +297 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
  13. package/template/client/src/components/DocRefModal.tsx +502 -0
  14. package/template/client/src/components/FileAttachments.tsx +109 -9
  15. package/template/client/src/components/FilePickerModal.tsx +181 -0
  16. package/template/client/src/components/FileViewerModal.tsx +406 -28
  17. package/template/client/src/components/MarkdownRenderer.tsx +205 -2
  18. package/template/client/src/components/Sidebar.tsx +213 -127
  19. package/template/client/src/components/TextAnnotator.tsx +8 -15
  20. package/template/client/src/components/VizCraftEmbed.tsx +162 -19
  21. package/template/client/src/store.ts +201 -0
  22. package/template/client/src/types.ts +8 -0
  23. package/template/cockpit.json +1 -1
  24. package/template/package.json +1 -1
  25. package/template/server/src/google-drive.ts +109 -1
  26. package/template/server/src/index.ts +1107 -46
  27. package/template/server/src/storage.ts +263 -2
@@ -1,12 +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
7
  import VizCraftEmbed from "./VizCraftEmbed";
8
8
  import { useStore } from "../store";
9
- import { Bookmark } from "lucide-react";
9
+ import { Bookmark, ExternalLink, Play, Save, Check } from "lucide-react";
10
10
 
11
11
  import type { Components } from "react-markdown";
12
12
 
@@ -15,6 +15,140 @@ interface Props {
15
15
  onAnnotationClick?: (annotId: string, rect: DOMRect) => void;
16
16
  bookmarkedBlockIndex?: number;
17
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
+ );
18
152
  }
19
153
 
20
154
  const markdownComponents: Components = {
@@ -179,8 +313,10 @@ export default function MarkdownRenderer({
179
313
  onAnnotationClick,
180
314
  bookmarkedBlockIndex,
181
315
  onBookmarkBlock,
316
+ onSpecRefined,
182
317
  }: Props) {
183
318
  const openFileViewer = useStore((s) => s.openFileViewer);
319
+ const openDocViewer = useStore((s) => s.openDocViewer);
184
320
  const bookmarkElemRef = useRef<HTMLDivElement | null>(null);
185
321
 
186
322
  // Scroll the bookmarked block into view when the bookmark changes or on mount
@@ -237,6 +373,27 @@ export default function MarkdownRenderer({
237
373
 
238
374
  return {
239
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
+ },
240
397
  p({ children, node }) {
241
398
  return wrapBlock(
242
399
  (node as any)?.position?.start?.line,
@@ -288,6 +445,35 @@ export default function MarkdownRenderer({
288
445
  </span>
289
446
  );
290
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
+ }
291
477
  if (href?.startsWith("coderef://")) {
292
478
  const filePath = href.slice("coderef://".length);
293
479
  return (
@@ -301,6 +487,19 @@ export default function MarkdownRenderer({
301
487
  </button>
302
488
  );
303
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
+ }
304
503
  return (
305
504
  <a
306
505
  href={href}
@@ -316,8 +515,10 @@ export default function MarkdownRenderer({
316
515
  }, [
317
516
  onAnnotationClick,
318
517
  openFileViewer,
518
+ openDocViewer,
319
519
  bookmarkedBlockIndex,
320
520
  onBookmarkBlock,
521
+ onSpecRefined,
321
522
  ]);
322
523
 
323
524
  return (
@@ -328,6 +529,8 @@ export default function MarkdownRenderer({
328
529
  // Allow our internal schemes; block javascript: for safety
329
530
  if (url.startsWith("annot://")) return url;
330
531
  if (url.startsWith("coderef://")) return url;
532
+ if (url.startsWith("docref://")) return url;
533
+ if (url.startsWith("inlineref://")) return url;
331
534
  if (url.startsWith("javascript:")) return "";
332
535
  return url;
333
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,100 +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
- if (window.confirm(`Delete "${q.title}"? This cannot be undone.`)) {
241
- removeQuestion(q.id, topicId);
242
- }
243
- }}
244
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
245
- >
246
- <Trash2 className="w-2.5 h-2.5" />
247
- </button>
248
- </div>
249
- );
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
+ };
250
342
 
251
343
  return (
252
344
  <aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
253
345
  {/* Workspace switcher */}
254
346
  <WorkspaceSwitcher />
255
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
+
256
382
  {/* Header — Drive breadcrumb when inside a subfolder, else normal Topics header */}
257
383
  <div className="h-12 border-b border-slate-800 flex items-center justify-between px-3 shrink-0">
258
384
  {isDriveWs && currentSubFolder ? (
@@ -482,57 +608,17 @@ export default function Sidebar() {
482
608
  onRemove={(fileId) =>
483
609
  removeTopicFile(topic.id, fileId)
484
610
  }
611
+ onLink={(fileId, originalName) =>
612
+ linkFileToTopic(topic.id, fileId, originalName)
613
+ }
614
+ downloadBase={`/api/topics/${topic.id}/context-files`}
485
615
  label="topic"
486
616
  compact
487
617
  />
488
618
  </div>
489
619
 
490
- {/* Questions grouped: root children */}
491
- {(() => {
492
- const rootQs = questions.filter(
493
- (q) => !q.parentQuestionId,
494
- );
495
- return rootQs.map((q) => {
496
- const children = questions.filter(
497
- (c) => c.parentQuestionId === q.id,
498
- );
499
- return (
500
- <Fragment key={q.id}>
501
- {renderQuestionRow(q, topic.id, false)}
502
- {/* Add-child input */}
503
- {addingChildTo === q.id && (
504
- <div className="pl-7 pr-2 py-1 animate-fadeIn">
505
- <input
506
- autoFocus
507
- value={newChildTitle}
508
- onChange={(e) =>
509
- setNewChildTitle(e.target.value)
510
- }
511
- onKeyDown={(e) => {
512
- if (e.key === "Enter")
513
- handleAddChildQuestion(topic.id, q.id);
514
- if (e.key === "Escape") {
515
- setAddingChildTo(null);
516
- setNewChildTitle("");
517
- }
518
- }}
519
- onBlur={() => {
520
- if (!newChildTitle.trim())
521
- setAddingChildTo(null);
522
- }}
523
- placeholder="Child question title..."
524
- 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"
525
- />
526
- </div>
527
- )}
528
- {/* Children */}
529
- {children.map((c) =>
530
- renderQuestionRow(c, topic.id, true),
531
- )}
532
- </Fragment>
533
- );
534
- });
535
- })()}
620
+ {/* Questions recursive tree (unlimited depth) */}
621
+ {renderQuestionSubtree(questions, topic.id, null, 0)}
536
622
 
537
623
  {/* Add question input */}
538
624
  {addingQuestionTo === topic.id ? (