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.
- package/package.json +1 -1
- package/template/client/package-lock.json +734 -1
- package/template/client/package.json +1 -0
- package/template/client/src/App.tsx +3 -0
- package/template/client/src/api.ts +321 -4
- package/template/client/src/components/AiSettingsModal.tsx +818 -425
- package/template/client/src/components/ChatMessage.tsx +34 -12
- package/template/client/src/components/ChatView.tsx +298 -121
- package/template/client/src/components/CodeContextPanel.tsx +419 -2
- package/template/client/src/components/CodeRunnerModal.tsx +1601 -120
- package/template/client/src/components/DocRefModal.tsx +55 -6
- package/template/client/src/components/FileAttachments.tsx +20 -4
- package/template/client/src/components/InfraLabModal.tsx +1706 -0
- package/template/client/src/components/LinkedConvosPicker.tsx +128 -0
- package/template/client/src/components/MarkdownRenderer.tsx +22 -8
- package/template/client/src/components/NotesModal.tsx +977 -0
- package/template/client/src/components/PlotEmbed.tsx +173 -0
- package/template/client/src/components/Sidebar.tsx +184 -0
- package/template/client/src/components/VizCraftEmbed.tsx +257 -13
- package/template/client/src/components/WorkspaceSwitcher.tsx +4 -0
- package/template/client/src/infraLab.ts +124 -0
- package/template/client/src/reactLab.ts +477 -0
- package/template/client/src/store.ts +219 -6
- package/template/client/src/types.ts +35 -3
- package/template/client/tsconfig.tsbuildinfo +1 -1
- package/template/server/src/google-drive.ts +37 -3
- package/template/server/src/index.ts +693 -52
- package/template/server/src/infra-runner.ts +1104 -0
- package/template/server/src/storage.ts +13 -3
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { X, MessageSquare, Check, Loader2 } from "lucide-react";
|
|
3
|
+
import type { Question } from "../types";
|
|
4
|
+
import { useStore } from "../store";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
question: Question;
|
|
8
|
+
topicId: string;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function LinkedConvosPicker({
|
|
13
|
+
question,
|
|
14
|
+
topicId,
|
|
15
|
+
onClose,
|
|
16
|
+
}: Props) {
|
|
17
|
+
const { linkConversation, unlinkConversation } = useStore();
|
|
18
|
+
const [siblings, setSiblings] = useState<Question[]>([]);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
const [pending, setPending] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
fetch(`/api/topics/${topicId}/questions`)
|
|
24
|
+
.then((r) => r.json())
|
|
25
|
+
.then((qs: Question[]) => {
|
|
26
|
+
// Exclude the current question itself
|
|
27
|
+
setSiblings(qs.filter((q2) => q2.id !== question.id));
|
|
28
|
+
})
|
|
29
|
+
.finally(() => setLoading(false));
|
|
30
|
+
}, [topicId, question.id]);
|
|
31
|
+
|
|
32
|
+
const linked = new Set(question.linkedConversationIds ?? []);
|
|
33
|
+
|
|
34
|
+
const toggle = async (targetId: string) => {
|
|
35
|
+
setPending(targetId);
|
|
36
|
+
try {
|
|
37
|
+
if (linked.has(targetId)) {
|
|
38
|
+
await unlinkConversation(question.id, targetId);
|
|
39
|
+
} else {
|
|
40
|
+
await linkConversation(question.id, targetId);
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
setPending(null);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
49
|
+
<div className="bg-slate-900 border border-slate-700 rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[70vh]">
|
|
50
|
+
{/* Header */}
|
|
51
|
+
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-700 shrink-0">
|
|
52
|
+
<MessageSquare className="w-4 h-4 text-violet-400 shrink-0" />
|
|
53
|
+
<span className="text-sm font-semibold text-slate-200 flex-1">
|
|
54
|
+
Link conversations as context
|
|
55
|
+
</span>
|
|
56
|
+
<button
|
|
57
|
+
onClick={onClose}
|
|
58
|
+
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
|
|
59
|
+
>
|
|
60
|
+
<X className="w-4 h-4" />
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Body */}
|
|
65
|
+
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-1">
|
|
66
|
+
{loading && (
|
|
67
|
+
<div className="flex items-center justify-center py-8">
|
|
68
|
+
<Loader2 className="w-5 h-5 text-violet-400 animate-spin" />
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
{!loading && siblings.length === 0 && (
|
|
72
|
+
<p className="text-xs text-slate-500 text-center py-8">
|
|
73
|
+
No other conversations in this topic yet.
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
76
|
+
{!loading &&
|
|
77
|
+
siblings.map((sibling) => {
|
|
78
|
+
const isLinked = linked.has(sibling.id);
|
|
79
|
+
const isPending = pending === sibling.id;
|
|
80
|
+
const msgCount = sibling.messages?.length ?? 0;
|
|
81
|
+
return (
|
|
82
|
+
<button
|
|
83
|
+
key={sibling.id}
|
|
84
|
+
onClick={() => toggle(sibling.id)}
|
|
85
|
+
disabled={isPending}
|
|
86
|
+
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
|
|
87
|
+
isLinked
|
|
88
|
+
? "bg-violet-500/15 border border-violet-500/30 hover:bg-violet-500/20"
|
|
89
|
+
: "bg-slate-800/50 border border-transparent hover:bg-slate-800"
|
|
90
|
+
}`}
|
|
91
|
+
>
|
|
92
|
+
<div
|
|
93
|
+
className={`w-4 h-4 rounded border shrink-0 flex items-center justify-center transition-colors ${
|
|
94
|
+
isLinked
|
|
95
|
+
? "bg-violet-500 border-violet-500"
|
|
96
|
+
: "border-slate-600"
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
{isPending ? (
|
|
100
|
+
<Loader2 className="w-2.5 h-2.5 animate-spin text-white" />
|
|
101
|
+
) : isLinked ? (
|
|
102
|
+
<Check className="w-2.5 h-2.5 text-white" />
|
|
103
|
+
) : null}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="flex-1 min-w-0">
|
|
106
|
+
<p className="text-xs font-medium text-slate-200 truncate">
|
|
107
|
+
{sibling.title}
|
|
108
|
+
</p>
|
|
109
|
+
<p className="text-[10px] text-slate-500 mt-0.5">
|
|
110
|
+
{msgCount} message{msgCount !== 1 ? "s" : ""}
|
|
111
|
+
</p>
|
|
112
|
+
</div>
|
|
113
|
+
</button>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Footer hint */}
|
|
119
|
+
<div className="px-4 py-2.5 border-t border-slate-800 shrink-0">
|
|
120
|
+
<p className="text-[10px] text-slate-500">
|
|
121
|
+
Linked conversations are included as read-only background context
|
|
122
|
+
for the AI.
|
|
123
|
+
</p>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -4,6 +4,7 @@ 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 PlotEmbed from "./PlotEmbed";
|
|
7
8
|
import VizCraftEmbed from "./VizCraftEmbed";
|
|
8
9
|
import { useStore } from "../store";
|
|
9
10
|
import { Bookmark, ExternalLink, Play, Save, Check } from "lucide-react";
|
|
@@ -56,15 +57,24 @@ function InlineCodeBlock({
|
|
|
56
57
|
const stableIdRef = useRef(`inline:${crypto.randomUUID()}`);
|
|
57
58
|
const finalId = parsed.id ?? stableIdRef.current;
|
|
58
59
|
|
|
59
|
-
// Register on mount when there is an explicit @ref so inlineref:// links resolve
|
|
60
|
+
// Register on mount when there is an explicit @ref so inlineref:// links resolve.
|
|
61
|
+
// Guard: skip the store update if the entry is already registered with the same
|
|
62
|
+
// content — this prevents an infinite render loop when the component remounts
|
|
63
|
+
// because a parent re-renders (each store update would otherwise remount this,
|
|
64
|
+
// triggering the effect again, updating the store again…).
|
|
60
65
|
useEffect(() => {
|
|
61
|
-
if (parsed.id)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
if (!parsed.id) return;
|
|
67
|
+
const existing = useStore.getState().inlineCodeSnippets[parsed.id];
|
|
68
|
+
if (
|
|
69
|
+
existing?.content === parsed.displayCode &&
|
|
70
|
+
existing?.language === language
|
|
71
|
+
)
|
|
72
|
+
return;
|
|
73
|
+
registerInlineCode(parsed.id, {
|
|
74
|
+
content: parsed.displayCode,
|
|
75
|
+
language,
|
|
76
|
+
label: parsed.label,
|
|
77
|
+
});
|
|
68
78
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
79
|
}, [parsed.id]);
|
|
70
80
|
|
|
@@ -169,6 +179,10 @@ const markdownComponents: Components = {
|
|
|
169
179
|
return <MermaidDiagram chart={codeString} />;
|
|
170
180
|
}
|
|
171
181
|
|
|
182
|
+
if (lang === "plot" || lang === "vega" || lang === "vega-lite") {
|
|
183
|
+
return <PlotEmbed spec={codeString} />;
|
|
184
|
+
}
|
|
185
|
+
|
|
172
186
|
if (lang === "viz") {
|
|
173
187
|
return <VizCraftEmbed spec={codeString} />;
|
|
174
188
|
}
|