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.
- package/package.json +1 -1
- package/template/client/package-lock.json +19 -0
- package/template/client/package.json +3 -0
- package/template/client/src/App.tsx +17 -0
- package/template/client/src/api.ts +135 -0
- package/template/client/src/components/AiSettingsModal.tsx +218 -4
- 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 +69 -4
- 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 +205 -2
- package/template/client/src/components/Sidebar.tsx +213 -127
- package/template/client/src/components/TextAnnotator.tsx +8 -15
- package/template/client/src/components/VizCraftEmbed.tsx +162 -19
- package/template/client/src/store.ts +201 -0
- package/template/client/src/types.ts +8 -0
- package/template/cockpit.json +1 -1
- package/template/package.json +1 -1
- package/template/server/src/google-drive.ts +109 -1
- package/template/server/src/index.ts +1107 -46
- 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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
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 ? (
|