create-interview-cockpit 0.4.0 → 0.6.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 (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +753 -1
  3. package/template/client/package.json +4 -0
  4. package/template/client/src/App.tsx +20 -0
  5. package/template/client/src/api.ts +455 -3
  6. package/template/client/src/components/AiSettingsModal.tsx +855 -248
  7. package/template/client/src/components/AnnotationDialog.tsx +3 -9
  8. package/template/client/src/components/ChatMessage.tsx +132 -27
  9. package/template/client/src/components/ChatView.tsx +365 -123
  10. package/template/client/src/components/CodeContextPanel.tsx +714 -0
  11. package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
  12. package/template/client/src/components/CodeRunnerModal.tsx +3030 -0
  13. package/template/client/src/components/DocRefModal.tsx +551 -0
  14. package/template/client/src/components/FileAttachments.tsx +128 -12
  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/InfraLabModal.tsx +1706 -0
  18. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  19. package/template/client/src/components/MarkdownRenderer.tsx +219 -2
  20. package/template/client/src/components/NotesModal.tsx +977 -0
  21. package/template/client/src/components/PlotEmbed.tsx +173 -0
  22. package/template/client/src/components/Sidebar.tsx +397 -127
  23. package/template/client/src/components/TextAnnotator.tsx +8 -15
  24. package/template/client/src/components/VizCraftEmbed.tsx +412 -25
  25. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  26. package/template/client/src/infraLab.ts +124 -0
  27. package/template/client/src/reactLab.ts +477 -0
  28. package/template/client/src/store.ts +416 -2
  29. package/template/client/src/types.ts +41 -1
  30. package/template/client/tsconfig.tsbuildinfo +1 -1
  31. package/template/cockpit.json +1 -1
  32. package/template/package.json +1 -1
  33. package/template/server/src/google-drive.ts +144 -2
  34. package/template/server/src/index.ts +1890 -188
  35. package/template/server/src/infra-runner.ts +1104 -0
  36. package/template/server/src/storage.ts +274 -3
@@ -16,8 +16,13 @@ import {
16
16
  Loader2,
17
17
  ArrowLeft,
18
18
  RefreshCw,
19
+ Globe,
20
+ SlidersHorizontal,
21
+ ArrowRightLeft,
19
22
  } from "lucide-react";
20
23
 
24
+ const ROOT_PARENT_VALUE = "__root__";
25
+
21
26
  export default function Sidebar() {
22
27
  const {
23
28
  topics,
@@ -30,12 +35,14 @@ export default function Sidebar() {
30
35
  renameTopic,
31
36
  addQuestion,
32
37
  addChildQuestion,
38
+ moveQuestion,
33
39
  removeQuestion,
34
40
  renameQuestion,
35
41
  selectQuestion,
36
42
  fetchQuestions,
37
43
  uploadTopicFiles,
38
44
  removeTopicFile,
45
+ linkFileToTopic,
39
46
  workspaces,
40
47
  activeWorkspaceId,
41
48
  driveRootFolders,
@@ -44,6 +51,10 @@ export default function Sidebar() {
44
51
  selectDriveSubfolder,
45
52
  clearDriveSubfolder,
46
53
  syncWorkspace,
54
+ workspaceFiles,
55
+ uploadWorkspaceFiles,
56
+ removeWorkspaceFile,
57
+ updateTopicSystemContext,
47
58
  } = useStore();
48
59
 
49
60
  const [newTopicName, setNewTopicName] = useState("");
@@ -58,6 +69,43 @@ export default function Sidebar() {
58
69
  null,
59
70
  );
60
71
  const [editingQuestionTitle, setEditingQuestionTitle] = useState("");
72
+ const [movingQuestionId, setMovingQuestionId] = useState<string | null>(null);
73
+ const [moveTargetParentId, setMoveTargetParentId] =
74
+ useState(ROOT_PARENT_VALUE);
75
+ const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
76
+ new Set(),
77
+ );
78
+ const [openTopicPrompts, setOpenTopicPrompts] = useState<Set<string>>(
79
+ new Set(),
80
+ );
81
+ const topicPromptTimers = useRef<
82
+ Record<string, ReturnType<typeof setTimeout>>
83
+ >({});
84
+
85
+ const toggleTopicPrompt = (topicId: string) => {
86
+ setOpenTopicPrompts((prev) => {
87
+ const next = new Set(prev);
88
+ if (next.has(topicId)) next.delete(topicId);
89
+ else next.add(topicId);
90
+ return next;
91
+ });
92
+ };
93
+
94
+ const handleTopicPromptChange = (topicId: string, value: string) => {
95
+ clearTimeout(topicPromptTimers.current[topicId]);
96
+ topicPromptTimers.current[topicId] = setTimeout(() => {
97
+ updateTopicSystemContext(topicId, value);
98
+ }, 600);
99
+ };
100
+
101
+ const toggleQuestionCollapse = (questionId: string) => {
102
+ setCollapsedQuestions((prev) => {
103
+ const next = new Set(prev);
104
+ if (next.has(questionId)) next.delete(questionId);
105
+ else next.add(questionId);
106
+ return next;
107
+ });
108
+ };
61
109
  const editInputRef = useRef<HTMLInputElement>(null);
62
110
 
63
111
  // Drive subfolder navigator
@@ -72,6 +120,7 @@ export default function Sidebar() {
72
120
  : null;
73
121
  const [navigating, setNavigating] = useState(false);
74
122
  const [syncing, setSyncing] = useState(false);
123
+ const [wsFilesExpanded, setWsFilesExpanded] = useState(true);
75
124
 
76
125
  // Load root folders whenever a Drive workspace becomes active with no subfolder selected
77
126
  useEffect(() => {
@@ -156,103 +205,334 @@ export default function Sidebar() {
156
205
  setAddingChildTo(null);
157
206
  };
158
207
 
208
+ const getDescendantIds = (questions: Question[], questionId: string) => {
209
+ const descendants = new Set<string>();
210
+ const visit = (parentId: string) => {
211
+ for (const candidate of questions) {
212
+ if (candidate.parentQuestionId !== parentId) continue;
213
+ if (descendants.has(candidate.id)) continue;
214
+ descendants.add(candidate.id);
215
+ visit(candidate.id);
216
+ }
217
+ };
218
+ visit(questionId);
219
+ return descendants;
220
+ };
221
+
222
+ const buildMoveParentOptions = (
223
+ questions: Question[],
224
+ excludedIds: Set<string>,
225
+ parentId: string | null,
226
+ depth: number,
227
+ ): Array<{ id: string; title: string }> => {
228
+ const siblings = questions.filter(
229
+ (q) => (q.parentQuestionId ?? null) === parentId,
230
+ );
231
+ return siblings.flatMap((q) => {
232
+ if (excludedIds.has(q.id)) return [];
233
+ return [
234
+ {
235
+ id: q.id,
236
+ title: `${"— ".repeat(depth)}${q.title}`,
237
+ },
238
+ ...buildMoveParentOptions(questions, excludedIds, q.id, depth + 1),
239
+ ];
240
+ });
241
+ };
242
+
243
+ const handleMoveQuestion = async (
244
+ topicId: string,
245
+ questionId: string,
246
+ targetParentId: string | null,
247
+ ) => {
248
+ await moveQuestion(questionId, topicId, targetParentId);
249
+ setMovingQuestionId(null);
250
+ };
251
+
252
+ const renderMoveQuestionPicker = (
253
+ questions: Question[],
254
+ q: Question,
255
+ topicId: string,
256
+ depth: number,
257
+ ) => {
258
+ if (movingQuestionId !== q.id) return null;
259
+ const excludedIds = getDescendantIds(questions, q.id);
260
+ excludedIds.add(q.id);
261
+ const parentOptions = buildMoveParentOptions(
262
+ questions,
263
+ excludedIds,
264
+ null,
265
+ 0,
266
+ );
267
+ const targetParentId =
268
+ moveTargetParentId === ROOT_PARENT_VALUE ? null : moveTargetParentId;
269
+ const unchanged = (q.parentQuestionId ?? null) === targetParentId;
270
+
271
+ return (
272
+ <div
273
+ className="pr-2 py-1.5 animate-fadeIn"
274
+ style={{ paddingLeft: 12 + (depth + 1) * 16 }}
275
+ >
276
+ <div className="rounded border border-slate-700 bg-slate-800/70 p-2 space-y-2">
277
+ <div className="text-[11px] text-slate-500">Move under</div>
278
+ <select
279
+ autoFocus
280
+ value={moveTargetParentId}
281
+ onChange={(e) => setMoveTargetParentId(e.target.value)}
282
+ className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs text-slate-300 focus:outline-none focus:border-cyan-500"
283
+ >
284
+ <option value={ROOT_PARENT_VALUE}>No parent (top level)</option>
285
+ {parentOptions.map((option) => (
286
+ <option key={option.id} value={option.id}>
287
+ {option.title}
288
+ </option>
289
+ ))}
290
+ </select>
291
+ <div className="flex items-center justify-end gap-2">
292
+ <button
293
+ type="button"
294
+ onClick={() => setMovingQuestionId(null)}
295
+ className="px-2 py-1 rounded text-[11px] text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
296
+ >
297
+ Cancel
298
+ </button>
299
+ <button
300
+ type="button"
301
+ onClick={() => handleMoveQuestion(topicId, q.id, targetParentId)}
302
+ disabled={unchanged}
303
+ className="px-2 py-1 rounded text-[11px] bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 text-white transition-colors"
304
+ >
305
+ Move
306
+ </button>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ );
311
+ };
312
+
159
313
  const renderQuestionRow = (
160
314
  q: Question,
161
315
  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}
316
+ depth: number,
317
+ hasChildren: boolean,
318
+ isCollapsed: boolean,
319
+ onToggleCollapse: () => void,
320
+ ) => {
321
+ // 12px base left padding + 16px per depth level
322
+ const paddingLeft = 12 + depth * 16;
323
+ return (
324
+ <div
325
+ key={q.id}
326
+ className={`group flex items-center gap-1.5 pr-2 py-1 cursor-pointer transition-colors ${
327
+ selectedQuestionId === q.id
328
+ ? "bg-cyan-500/10 border-l-2 border-l-cyan-500 -ml-px"
329
+ : "hover:bg-slate-800/30"
330
+ }`}
331
+ style={{ paddingLeft }}
332
+ onClick={() =>
333
+ editingQuestionId !== q.id && selectQuestion(topicId, q.id)
334
+ }
335
+ >
336
+ {depth > 0 && (
337
+ <span className="text-slate-600 text-[11px] shrink-0 leading-none select-none mr-0.5">
338
+
339
+ </span>
340
+ )}
341
+ {hasChildren ? (
342
+ <button
343
+ onClick={(e) => {
344
+ e.stopPropagation();
345
+ onToggleCollapse();
346
+ }}
347
+ className="shrink-0 text-slate-500 hover:text-slate-300 p-0.5 -ml-0.5 rounded transition-colors"
348
+ title={isCollapsed ? "Expand" : "Collapse"}
349
+ >
350
+ {isCollapsed ? (
351
+ <ChevronRight className="w-3 h-3" />
352
+ ) : (
353
+ <ChevronDown className="w-3 h-3" />
354
+ )}
355
+ </button>
356
+ ) : (
357
+ <MessageSquare className="w-3 h-3 text-slate-600 shrink-0" />
358
+ )}
359
+ {editingQuestionId === q.id ? (
360
+ <input
361
+ ref={editInputRef}
362
+ value={editingQuestionTitle}
363
+ onChange={(e) => setEditingQuestionTitle(e.target.value)}
364
+ onKeyDown={(e) => {
365
+ if (e.key === "Enter") commitQuestionRename(q.id, topicId);
366
+ if (e.key === "Escape") setEditingQuestionId(null);
367
+ }}
368
+ onBlur={() => commitQuestionRename(q.id, topicId)}
369
+ onClick={(e) => e.stopPropagation()}
370
+ 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"
371
+ />
372
+ ) : (
373
+ <span
374
+ className="text-xs text-slate-400 truncate flex-1"
375
+ onDoubleClick={(e) => {
376
+ e.stopPropagation();
377
+ setEditingQuestionId(q.id);
378
+ setEditingQuestionTitle(q.title);
379
+ }}
380
+ >
381
+ {q.title}
382
+ </span>
383
+ )}
384
+ <span className="text-[10px] text-slate-700 shrink-0">
385
+ {q.messages.length > 0 ? `${q.messages.length}` : ""}
206
386
  </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 && (
387
+ {editingQuestionId !== q.id && (
388
+ <button
389
+ onClick={(e) => {
390
+ e.stopPropagation();
391
+ setAddingChildTo(q.id);
392
+ setNewChildTitle("");
393
+ }}
394
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
395
+ title="Add child question"
396
+ >
397
+ <CornerDownRight className="w-2.5 h-2.5" />
398
+ </button>
399
+ )}
400
+ {editingQuestionId !== q.id && (
401
+ <button
402
+ onClick={(e) => {
403
+ e.stopPropagation();
404
+ setEditingQuestionId(q.id);
405
+ setEditingQuestionTitle(q.title);
406
+ }}
407
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
408
+ title="Rename"
409
+ >
410
+ <Pencil className="w-2.5 h-2.5" />
411
+ </button>
412
+ )}
413
+ {editingQuestionId !== q.id && (
414
+ <button
415
+ onClick={(e) => {
416
+ e.stopPropagation();
417
+ setMovingQuestionId((prev) => (prev === q.id ? null : q.id));
418
+ setMoveTargetParentId(q.parentQuestionId ?? ROOT_PARENT_VALUE);
419
+ }}
420
+ className={`p-0.5 rounded opacity-0 group-hover:opacity-100 transition-all ${
421
+ movingQuestionId === q.id
422
+ ? "opacity-100 text-cyan-400"
423
+ : "text-slate-600 hover:text-cyan-400"
424
+ }`}
425
+ title="Move to a different parent"
426
+ >
427
+ <ArrowRightLeft className="w-2.5 h-2.5" />
428
+ </button>
429
+ )}
225
430
  <button
226
431
  onClick={(e) => {
227
432
  e.stopPropagation();
228
- setEditingQuestionId(q.id);
229
- setEditingQuestionTitle(q.title);
433
+ if (window.confirm(`Delete "${q.title}"? This cannot be undone.`)) {
434
+ removeQuestion(q.id, topicId);
435
+ }
230
436
  }}
231
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-cyan-400 transition-all"
232
- title="Rename"
437
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 text-slate-600 hover:text-red-400 transition-all"
233
438
  >
234
- <Pencil className="w-2.5 h-2.5" />
439
+ <Trash2 className="w-2.5 h-2.5" />
235
440
  </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
- );
441
+ </div>
442
+ );
443
+ };
444
+
445
+ // Recursively renders a question and all its descendants.
446
+ const renderQuestionSubtree = (
447
+ questions: Question[],
448
+ topicId: string,
449
+ parentId: string | null,
450
+ depth: number,
451
+ ): React.ReactNode => {
452
+ const qs = questions.filter(
453
+ (q) => (q.parentQuestionId ?? null) === parentId,
454
+ );
455
+ return qs.map((q) => {
456
+ const hasChildren = questions.some((c) => c.parentQuestionId === q.id);
457
+ const isCollapsed = collapsedQuestions.has(q.id);
458
+ return (
459
+ <Fragment key={q.id}>
460
+ {renderQuestionRow(q, topicId, depth, hasChildren, isCollapsed, () =>
461
+ toggleQuestionCollapse(q.id),
462
+ )}
463
+ {/* Add-child input — indented one level deeper than this question */}
464
+ {addingChildTo === q.id && (
465
+ <div
466
+ className="pr-2 py-1 animate-fadeIn"
467
+ style={{ paddingLeft: 12 + (depth + 1) * 16 }}
468
+ >
469
+ <input
470
+ autoFocus
471
+ value={newChildTitle}
472
+ onChange={(e) => setNewChildTitle(e.target.value)}
473
+ onKeyDown={(e) => {
474
+ if (e.key === "Enter") handleAddChildQuestion(topicId, q.id);
475
+ if (e.key === "Escape") {
476
+ setAddingChildTo(null);
477
+ setNewChildTitle("");
478
+ }
479
+ }}
480
+ onBlur={() => {
481
+ if (!newChildTitle.trim()) setAddingChildTo(null);
482
+ }}
483
+ placeholder="Child question title..."
484
+ 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"
485
+ />
486
+ </div>
487
+ )}
488
+ {renderMoveQuestionPicker(questions, q, topicId, depth)}
489
+ {/* Recurse into children — hidden when collapsed */}
490
+ {!isCollapsed &&
491
+ renderQuestionSubtree(questions, topicId, q.id, depth + 1)}
492
+ </Fragment>
493
+ );
494
+ });
495
+ };
250
496
 
251
497
  return (
252
498
  <aside className="w-72 border-r border-slate-800 flex flex-col bg-slate-900/50 shrink-0">
253
499
  {/* Workspace switcher */}
254
500
  <WorkspaceSwitcher />
255
501
 
502
+ {/* Workspace-level files — apply to all topics */}
503
+ <div className="border-b border-slate-800 shrink-0">
504
+ <button
505
+ onClick={() => setWsFilesExpanded((v) => !v)}
506
+ className="w-full flex items-center gap-1.5 px-3 py-1.5 hover:bg-slate-800/40 transition-colors"
507
+ >
508
+ {wsFilesExpanded ? (
509
+ <ChevronDown className="w-3 h-3 text-slate-500 shrink-0" />
510
+ ) : (
511
+ <ChevronRight className="w-3 h-3 text-slate-500 shrink-0" />
512
+ )}
513
+ <Globe className="w-3 h-3 text-violet-400 shrink-0" />
514
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 flex-1 text-left">
515
+ Workspace Files
516
+ </span>
517
+ {workspaceFiles.length > 0 && (
518
+ <span className="text-[10px] text-slate-600">
519
+ {workspaceFiles.length}
520
+ </span>
521
+ )}
522
+ </button>
523
+ {wsFilesExpanded && (
524
+ <div className="px-3 pb-2">
525
+ <FileAttachments
526
+ files={workspaceFiles}
527
+ onUpload={(files) => uploadWorkspaceFiles(files)}
528
+ onRemove={(fileId) => removeWorkspaceFile(fileId)}
529
+ downloadBase="/api/workspace/context-files"
530
+ label="workspace"
531
+ />
532
+ </div>
533
+ )}
534
+ </div>
535
+
256
536
  {/* Header — Drive breadcrumb when inside a subfolder, else normal Topics header */}
257
537
  <div className="h-12 border-b border-slate-800 flex items-center justify-between px-3 shrink-0">
258
538
  {isDriveWs && currentSubFolder ? (
@@ -482,57 +762,47 @@ export default function Sidebar() {
482
762
  onRemove={(fileId) =>
483
763
  removeTopicFile(topic.id, fileId)
484
764
  }
765
+ onLink={(fileId, originalName) =>
766
+ linkFileToTopic(topic.id, fileId, originalName)
767
+ }
768
+ downloadBase={`/api/topics/${topic.id}/context-files`}
485
769
  label="topic"
486
770
  compact
487
771
  />
488
772
  </div>
489
773
 
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
- })()}
774
+ {/* Topic-wide system prompt */}
775
+ <div className="pl-3 pr-2 py-1 border-b border-slate-800/50">
776
+ <button
777
+ onClick={() => toggleTopicPrompt(topic.id)}
778
+ className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-cyan-400 transition-colors w-full"
779
+ >
780
+ <SlidersHorizontal className="w-3 h-3" />
781
+ <span>Topic prompt</span>
782
+ <span className="ml-auto flex items-center gap-1">
783
+ {topic.systemContext && (
784
+ <span className="w-1.5 h-1.5 rounded-full bg-cyan-500" />
785
+ )}
786
+ <ChevronDown
787
+ className={`w-3 h-3 transition-transform ${openTopicPrompts.has(topic.id) ? "" : "-rotate-90"}`}
788
+ />
789
+ </span>
790
+ </button>
791
+ {openTopicPrompts.has(topic.id) && (
792
+ <textarea
793
+ defaultValue={topic.systemContext ?? ""}
794
+ onChange={(e) =>
795
+ handleTopicPromptChange(topic.id, e.target.value)
796
+ }
797
+ placeholder="System context added to every question in this topic…"
798
+ rows={4}
799
+ className="mt-1.5 w-full bg-slate-800/60 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300 placeholder-slate-600 focus:outline-none focus:border-cyan-500 resize-y leading-relaxed"
800
+ />
801
+ )}
802
+ </div>
803
+
804
+ {/* Questions — recursive tree (unlimited depth) */}
805
+ {renderQuestionSubtree(questions, topic.id, null, 0)}
536
806
 
537
807
  {/* Add question input */}
538
808
  {addingQuestionTo === topic.id ? (
@@ -12,9 +12,8 @@ interface Props {
12
12
  onAnnotationUpdate: (updated: Annotation) => void;
13
13
  bookmarkedBlockIndex?: number;
14
14
  onBookmarkBlock?: (blockIndex: number) => void;
15
- responseLength?: string;
16
- responseStyle?: string;
17
- responseAudience?: string;
15
+ preferenceSuffix?: string;
16
+ onSpecRefined?: (originalSpec: string, newSpec: string) => void;
18
17
  }
19
18
 
20
19
  type Phase = "idle" | "button" | "input" | "loading";
@@ -119,9 +118,8 @@ export default function TextAnnotator({
119
118
  onAnnotationUpdate,
120
119
  bookmarkedBlockIndex,
121
120
  onBookmarkBlock,
122
- responseLength,
123
- responseStyle,
124
- responseAudience,
121
+ preferenceSuffix,
122
+ onSpecRefined,
125
123
  }: Props) {
126
124
  const containerRef = useRef<HTMLDivElement>(null);
127
125
  const annotationsRef = useRef(annotations);
@@ -207,9 +205,7 @@ export default function TextAnnotator({
207
205
  selectedText,
208
206
  prompt: inputValue.trim(),
209
207
  messageContent: content,
210
- responseLength,
211
- responseStyle,
212
- responseAudience,
208
+ preferenceSuffix,
213
209
  }),
214
210
  });
215
211
  const data = await res.json();
@@ -248,9 +244,7 @@ export default function TextAnnotator({
248
244
  messageContent: content,
249
245
  priorResponse: annotation.response,
250
246
  followUps: annotation.followUps ?? [],
251
- responseLength,
252
- responseStyle,
253
- responseAudience,
247
+ preferenceSuffix,
254
248
  }),
255
249
  });
256
250
  const data = await res.json();
@@ -288,6 +282,7 @@ export default function TextAnnotator({
288
282
  onAnnotationClick={handleAnnotationClick}
289
283
  bookmarkedBlockIndex={bookmarkedBlockIndex}
290
284
  onBookmarkBlock={onBookmarkBlock}
285
+ onSpecRefined={onSpecRefined}
291
286
  />
292
287
 
293
288
  {/* Annotation dialog — opened by clicking an underlined annotation link */}
@@ -298,9 +293,7 @@ export default function TextAnnotator({
298
293
  onUpdate={onAnnotationUpdate}
299
294
  messageContent={content}
300
295
  initialPos={dialogPos}
301
- responseLength={responseLength}
302
- responseStyle={responseStyle}
303
- responseAudience={responseAudience}
296
+ preferenceSuffix={preferenceSuffix}
304
297
  />
305
298
  )}
306
299