create-interview-cockpit 0.5.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 (29) hide show
  1. package/package.json +1 -1
  2. package/template/client/package-lock.json +734 -1
  3. package/template/client/package.json +1 -0
  4. package/template/client/src/App.tsx +3 -0
  5. package/template/client/src/api.ts +321 -4
  6. package/template/client/src/components/AiSettingsModal.tsx +818 -425
  7. package/template/client/src/components/ChatMessage.tsx +34 -12
  8. package/template/client/src/components/ChatView.tsx +298 -121
  9. package/template/client/src/components/CodeContextPanel.tsx +419 -2
  10. package/template/client/src/components/CodeRunnerModal.tsx +1601 -120
  11. package/template/client/src/components/DocRefModal.tsx +55 -6
  12. package/template/client/src/components/FileAttachments.tsx +20 -4
  13. package/template/client/src/components/InfraLabModal.tsx +1706 -0
  14. package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
  15. package/template/client/src/components/MarkdownRenderer.tsx +22 -8
  16. package/template/client/src/components/NotesModal.tsx +977 -0
  17. package/template/client/src/components/PlotEmbed.tsx +173 -0
  18. package/template/client/src/components/Sidebar.tsx +184 -0
  19. package/template/client/src/components/VizCraftEmbed.tsx +257 -13
  20. package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
  21. package/template/client/src/infraLab.ts +124 -0
  22. package/template/client/src/reactLab.ts +477 -0
  23. package/template/client/src/store.ts +219 -6
  24. package/template/client/src/types.ts +35 -3
  25. package/template/client/tsconfig.tsbuildinfo +1 -1
  26. package/template/server/src/google-drive.ts +37 -3
  27. package/template/server/src/index.ts +693 -52
  28. package/template/server/src/infra-runner.ts +1104 -0
  29. package/template/server/src/storage.ts +13 -3
@@ -0,0 +1,173 @@
1
+ import { memo, useEffect, useRef, useState } from "react";
2
+ import { Loader2, RefreshCw } from "lucide-react";
3
+ import { parse as parseYaml } from "yaml";
4
+
5
+ interface Props {
6
+ spec: string;
7
+ }
8
+
9
+ function sanitizePlotSpec(raw: string): string {
10
+ return raw
11
+ .trim()
12
+ .replace(/^```(?:plot|vega|vega-lite|json|yaml)?\s*/i, "")
13
+ .replace(/```\s*$/i, "")
14
+ .replace(/[\u201C\u201D]/g, '"')
15
+ .replace(/[\u2018\u2019\u02BC]/g, "'")
16
+ .replace(/\u2013|\u2014/g, "-");
17
+ }
18
+
19
+ function parsePlotSpec(raw: string): unknown {
20
+ const trimmed = sanitizePlotSpec(raw);
21
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
22
+ return JSON.parse(trimmed);
23
+ }
24
+ return parseYaml(trimmed);
25
+ }
26
+
27
+ const EMBED_OPTIONS = {
28
+ actions: false,
29
+ renderer: "svg" as const,
30
+ theme: "dark" as const,
31
+ config: {
32
+ background: "transparent",
33
+ title: {
34
+ color: "#e2e8f0",
35
+ subtitleColor: "#94a3b8",
36
+ },
37
+ axis: {
38
+ domainColor: "#475569",
39
+ gridColor: "rgba(71, 85, 105, 0.35)",
40
+ tickColor: "#64748b",
41
+ labelColor: "#cbd5e1",
42
+ titleColor: "#e2e8f0",
43
+ },
44
+ legend: {
45
+ labelColor: "#cbd5e1",
46
+ titleColor: "#e2e8f0",
47
+ },
48
+ view: {
49
+ stroke: "transparent",
50
+ },
51
+ },
52
+ };
53
+
54
+ export default memo(function PlotEmbed({ spec }: Props) {
55
+ const hostRef = useRef<HTMLDivElement>(null);
56
+ const finalizeRef = useRef<null | (() => void)>(null);
57
+ const [activeSpec, setActiveSpec] = useState(spec);
58
+ const [error, setError] = useState<string | null>(null);
59
+ const [fixing, setFixing] = useState(false);
60
+ const [rendering, setRendering] = useState(true);
61
+
62
+ useEffect(() => {
63
+ setActiveSpec(spec);
64
+ }, [spec]);
65
+
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+
69
+ const renderPlot = async () => {
70
+ setRendering(true);
71
+ setError(null);
72
+ finalizeRef.current?.();
73
+ finalizeRef.current = null;
74
+ if (hostRef.current) {
75
+ hostRef.current.innerHTML = "";
76
+ }
77
+
78
+ try {
79
+ const parsed = parsePlotSpec(activeSpec);
80
+ const { default: vegaEmbed } = await import("vega-embed");
81
+ if (cancelled || !hostRef.current) return;
82
+ const result = await vegaEmbed(
83
+ hostRef.current,
84
+ parsed as any,
85
+ EMBED_OPTIONS,
86
+ );
87
+ finalizeRef.current = () => result.finalize();
88
+ } catch (err) {
89
+ if (!cancelled) {
90
+ setError(err instanceof Error ? err.message : String(err));
91
+ }
92
+ } finally {
93
+ if (!cancelled) {
94
+ setRendering(false);
95
+ }
96
+ }
97
+ };
98
+
99
+ void renderPlot();
100
+
101
+ return () => {
102
+ cancelled = true;
103
+ finalizeRef.current?.();
104
+ finalizeRef.current = null;
105
+ };
106
+ }, [activeSpec]);
107
+
108
+ const handleFix = async () => {
109
+ setFixing(true);
110
+ try {
111
+ const res = await fetch("/api/fix-plot", {
112
+ method: "POST",
113
+ headers: { "Content-Type": "application/json" },
114
+ body: JSON.stringify({ spec: activeSpec, error }),
115
+ });
116
+ if (!res.ok) throw new Error("Fix request failed");
117
+ const { spec: fixed } = (await res.json()) as { spec: string };
118
+ setActiveSpec(fixed);
119
+ } catch (err) {
120
+ console.error("Fix plot error:", err);
121
+ } finally {
122
+ setFixing(false);
123
+ }
124
+ };
125
+
126
+ if (error) {
127
+ return (
128
+ <div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 my-2">
129
+ <div className="flex items-center justify-between mb-1 gap-3">
130
+ <div>
131
+ <p className="text-xs text-red-400">Plot error:</p>
132
+ <p className="text-[11px] text-slate-500 mt-1 whitespace-pre-wrap">
133
+ {error}
134
+ </p>
135
+ </div>
136
+ <button
137
+ onClick={handleFix}
138
+ disabled={fixing}
139
+ className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded bg-slate-700 text-slate-300 hover:bg-slate-600 disabled:opacity-50 transition-colors shrink-0"
140
+ >
141
+ {fixing ? (
142
+ <>
143
+ <Loader2 className="w-3 h-3 animate-spin" />
144
+ Fixing…
145
+ </>
146
+ ) : (
147
+ <>
148
+ <RefreshCw className="w-3 h-3" />
149
+ Fix plot
150
+ </>
151
+ )}
152
+ </button>
153
+ </div>
154
+ <pre className="text-xs text-slate-400 whitespace-pre-wrap overflow-x-auto">
155
+ {activeSpec}
156
+ </pre>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <div className="my-3 bg-slate-800/30 rounded-lg p-4 overflow-x-auto">
163
+ {rendering && (
164
+ <div className="flex justify-center pb-4">
165
+ <span className="text-xs text-slate-500 animate-pulse">
166
+ Rendering plot…
167
+ </span>
168
+ </div>
169
+ )}
170
+ <div ref={hostRef} className="plot-embed min-w-[280px]" />
171
+ </div>
172
+ );
173
+ });
@@ -17,8 +17,12 @@ import {
17
17
  ArrowLeft,
18
18
  RefreshCw,
19
19
  Globe,
20
+ SlidersHorizontal,
21
+ ArrowRightLeft,
20
22
  } from "lucide-react";
21
23
 
24
+ const ROOT_PARENT_VALUE = "__root__";
25
+
22
26
  export default function Sidebar() {
23
27
  const {
24
28
  topics,
@@ -31,6 +35,7 @@ export default function Sidebar() {
31
35
  renameTopic,
32
36
  addQuestion,
33
37
  addChildQuestion,
38
+ moveQuestion,
34
39
  removeQuestion,
35
40
  renameQuestion,
36
41
  selectQuestion,
@@ -49,6 +54,7 @@ export default function Sidebar() {
49
54
  workspaceFiles,
50
55
  uploadWorkspaceFiles,
51
56
  removeWorkspaceFile,
57
+ updateTopicSystemContext,
52
58
  } = useStore();
53
59
 
54
60
  const [newTopicName, setNewTopicName] = useState("");
@@ -63,9 +69,34 @@ export default function Sidebar() {
63
69
  null,
64
70
  );
65
71
  const [editingQuestionTitle, setEditingQuestionTitle] = useState("");
72
+ const [movingQuestionId, setMovingQuestionId] = useState<string | null>(null);
73
+ const [moveTargetParentId, setMoveTargetParentId] =
74
+ useState(ROOT_PARENT_VALUE);
66
75
  const [collapsedQuestions, setCollapsedQuestions] = useState<Set<string>>(
67
76
  new Set(),
68
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
+ };
69
100
 
70
101
  const toggleQuestionCollapse = (questionId: string) => {
71
102
  setCollapsedQuestions((prev) => {
@@ -174,6 +205,111 @@ export default function Sidebar() {
174
205
  setAddingChildTo(null);
175
206
  };
176
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
+
177
313
  const renderQuestionRow = (
178
314
  q: Question,
179
315
  topicId: string,
@@ -274,6 +410,23 @@ export default function Sidebar() {
274
410
  <Pencil className="w-2.5 h-2.5" />
275
411
  </button>
276
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
+ )}
277
430
  <button
278
431
  onClick={(e) => {
279
432
  e.stopPropagation();
@@ -332,6 +485,7 @@ export default function Sidebar() {
332
485
  />
333
486
  </div>
334
487
  )}
488
+ {renderMoveQuestionPicker(questions, q, topicId, depth)}
335
489
  {/* Recurse into children — hidden when collapsed */}
336
490
  {!isCollapsed &&
337
491
  renderQuestionSubtree(questions, topicId, q.id, depth + 1)}
@@ -617,6 +771,36 @@ export default function Sidebar() {
617
771
  />
618
772
  </div>
619
773
 
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
+
620
804
  {/* Questions — recursive tree (unlimited depth) */}
621
805
  {renderQuestionSubtree(questions, topic.id, null, 0)}
622
806