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.
- package/README.md +23 -0
- package/package.json +1 -1
- package/template/client/package-lock.json +42 -0
- package/template/client/package.json +5 -0
- package/template/client/src/App.tsx +45 -12
- package/template/client/src/api.ts +174 -0
- package/template/client/src/components/AiSettingsModal.tsx +1041 -0
- package/template/client/src/components/AnnotationDialog.tsx +3 -9
- package/template/client/src/components/ChatMessage.tsx +110 -27
- package/template/client/src/components/ChatView.tsx +239 -137
- package/template/client/src/components/CodeContextPanel.tsx +297 -0
- package/template/client/src/components/CodeLineAnnotationPopup.tsx +179 -0
- package/template/client/src/components/CodeRunnerModal.tsx +1549 -0
- package/template/client/src/components/DocRefModal.tsx +502 -0
- package/template/client/src/components/FileAttachments.tsx +109 -9
- package/template/client/src/components/FilePickerModal.tsx +181 -0
- package/template/client/src/components/FileViewerModal.tsx +406 -28
- package/template/client/src/components/MarkdownRenderer.tsx +210 -2
- package/template/client/src/components/Sidebar.tsx +213 -125
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +645 -0
- package/template/client/src/store.ts +275 -0
- package/template/client/src/types.ts +9 -0
- package/template/cockpit.json +1 -1
- package/template/data/ai-settings.json +49 -0
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1187 -76
- 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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
<
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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-
|
|
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
|
-
<
|
|
286
|
+
<Trash2 className="w-2.5 h-2.5" />
|
|
235
287
|
</button>
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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 ? (
|