clawport-ui 0.1.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 (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,685 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { MemoryFile } from "@/lib/types";
5
+ import { RefreshCw, Copy, Check, Download } from "lucide-react";
6
+ import { renderMarkdown, colorizeJson } from "@/lib/sanitize";
7
+ import { Skeleton } from "@/components/ui/skeleton";
8
+ import { ErrorState } from "@/components/ErrorState";
9
+
10
+ /* ─── Helpers ───────────────────────────────────────────────────── */
11
+
12
+ function timeAgo(dateStr: string): string {
13
+ const diff = Date.now() - new Date(dateStr).getTime();
14
+ const mins = Math.floor(diff / 60000);
15
+ const hrs = Math.floor(diff / 3600000);
16
+ const days = Math.floor(diff / 86400000);
17
+ if (mins < 1) return "just now";
18
+ if (mins < 60) return `${mins}m ago`;
19
+ if (hrs < 24) return `${hrs}h ago`;
20
+ return `${days}d ago`;
21
+ }
22
+
23
+ function formatBytes(bytes: number): string {
24
+ if (bytes < 1024) return `${bytes}B`;
25
+ const kb = bytes / 1024;
26
+ if (kb < 1024) return `${kb.toFixed(1)}KB`;
27
+ return `${(kb / 1024).toFixed(1)}MB`;
28
+ }
29
+
30
+ function wordCount(text: string): number {
31
+ return text.trim().split(/\s+/).filter(Boolean).length;
32
+ }
33
+
34
+ function isJsonFile(file: MemoryFile): boolean {
35
+ return file.label.includes("JSON") || file.path.endsWith(".json");
36
+ }
37
+
38
+ /* ─── Icons ─────────────────────────────────────────────────────── */
39
+
40
+ function FileIcon({ isJson }: { isJson: boolean }) {
41
+ return (
42
+ <svg
43
+ width="16"
44
+ height="16"
45
+ viewBox="0 0 16 16"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ strokeWidth="1.25"
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ style={{ color: isJson ? "var(--system-blue)" : "var(--text-tertiary)", flexShrink: 0 }}
52
+ >
53
+ {isJson ? (
54
+ /* clipboard icon for JSON */
55
+ <>
56
+ <rect x="4" y="2" width="8" height="12" rx="1.5" />
57
+ <path d="M6 2V1.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V2" />
58
+ <line x1="6.5" y1="6" x2="9.5" y2="6" />
59
+ <line x1="6.5" y1="8.5" x2="9.5" y2="8.5" />
60
+ <line x1="6.5" y1="11" x2="8" y2="11" />
61
+ </>
62
+ ) : (
63
+ /* document icon for MD */
64
+ <>
65
+ <path d="M4 1.5h5.5L12 4v9.5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-12a1 1 0 0 1 1-1z" />
66
+ <polyline points="9.5 1.5 9.5 4.5 12 4.5" />
67
+ <line x1="5.5" y1="7.5" x2="10.5" y2="7.5" />
68
+ <line x1="5.5" y1="10" x2="10.5" y2="10" />
69
+ </>
70
+ )}
71
+ </svg>
72
+ );
73
+ }
74
+
75
+ function FolderIcon() {
76
+ return (
77
+ <svg
78
+ width="48"
79
+ height="48"
80
+ viewBox="0 0 24 24"
81
+ fill="none"
82
+ stroke="currentColor"
83
+ strokeWidth="1.25"
84
+ strokeLinecap="round"
85
+ strokeLinejoin="round"
86
+ style={{ color: "var(--text-tertiary)" }}
87
+ >
88
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
89
+ </svg>
90
+ );
91
+ }
92
+
93
+ function BackArrow() {
94
+ return (
95
+ <svg
96
+ width="16"
97
+ height="16"
98
+ viewBox="0 0 16 16"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ strokeWidth="1.5"
102
+ strokeLinecap="round"
103
+ strokeLinejoin="round"
104
+ >
105
+ <polyline points="10 3 5 8 10 13" />
106
+ </svg>
107
+ );
108
+ }
109
+
110
+ /* ─── Component ─────────────────────────────────────────────────── */
111
+
112
+ export default function MemoryPage() {
113
+ const [files, setFiles] = useState<MemoryFile[]>([]);
114
+ const [selected, setSelected] = useState<MemoryFile | null>(null);
115
+ const [loading, setLoading] = useState(true);
116
+ const [error, setError] = useState<string | null>(null);
117
+ const [search, setSearch] = useState("");
118
+ const [copied, setCopied] = useState(false);
119
+ const [mobileShowContent, setMobileShowContent] = useState(false);
120
+
121
+ const listRef = useRef<HTMLDivElement>(null);
122
+ const searchRef = useRef<HTMLInputElement>(null);
123
+
124
+ const refresh = useCallback(() => {
125
+ setLoading(true);
126
+ setError(null);
127
+ fetch("/api/memory")
128
+ .then((r) => {
129
+ if (!r.ok) throw new Error("Failed to load memory files");
130
+ return r.json();
131
+ })
132
+ .then((data: MemoryFile[]) => {
133
+ setFiles(data);
134
+ if (data.length > 0 && !selected) setSelected(data[0]);
135
+ setLoading(false);
136
+ })
137
+ .catch((err) => {
138
+ setError(err instanceof Error ? err.message : "Unknown error");
139
+ setLoading(false);
140
+ });
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, []);
143
+
144
+ useEffect(() => {
145
+ refresh();
146
+ }, [refresh]);
147
+
148
+ /* Filtered files by search */
149
+ const filteredFiles = files.filter((f) =>
150
+ f.label.toLowerCase().includes(search.toLowerCase()) ||
151
+ f.path.toLowerCase().includes(search.toLowerCase())
152
+ );
153
+
154
+ /* Keyboard navigation in file list */
155
+ function handleListKeyDown(e: React.KeyboardEvent) {
156
+ const items = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
157
+ if (!items || items.length === 0) return;
158
+
159
+ const currentIdx = Array.from(items).findIndex(
160
+ (el) => el.getAttribute("aria-selected") === "true"
161
+ );
162
+
163
+ let nextIdx = currentIdx;
164
+
165
+ if (e.key === "ArrowDown") {
166
+ e.preventDefault();
167
+ nextIdx = Math.min(currentIdx + 1, items.length - 1);
168
+ } else if (e.key === "ArrowUp") {
169
+ e.preventDefault();
170
+ nextIdx = Math.max(currentIdx - 1, 0);
171
+ } else if (e.key === "Enter") {
172
+ e.preventDefault();
173
+ if (currentIdx >= 0) {
174
+ items[currentIdx].click();
175
+ setMobileShowContent(true);
176
+ }
177
+ return;
178
+ } else if (e.key === "Escape") {
179
+ e.preventDefault();
180
+ searchRef.current?.focus();
181
+ return;
182
+ }
183
+
184
+ if (nextIdx !== currentIdx && nextIdx >= 0) {
185
+ items[nextIdx].click();
186
+ items[nextIdx].focus();
187
+ }
188
+ }
189
+
190
+ /* Copy content */
191
+ function copyContent() {
192
+ if (!selected) return;
193
+ navigator.clipboard.writeText(selected.content).then(() => {
194
+ setCopied(true);
195
+ setTimeout(() => setCopied(false), 2000);
196
+ });
197
+ }
198
+
199
+ /* Download content */
200
+ function downloadContent() {
201
+ if (!selected) return;
202
+ const blob = new Blob([selected.content], { type: "text/plain" });
203
+ const url = URL.createObjectURL(blob);
204
+ const a = document.createElement("a");
205
+ a.href = url;
206
+ a.download = selected.path.split("/").pop() || "file.md";
207
+ a.click();
208
+ URL.revokeObjectURL(url);
209
+ }
210
+
211
+ /* Select file and show content on mobile */
212
+ function selectFile(file: MemoryFile) {
213
+ setSelected(file);
214
+ setMobileShowContent(true);
215
+ }
216
+
217
+ /* Computed */
218
+ const isJson = selected ? isJsonFile(selected) : false;
219
+ const lineCount = selected ? selected.content.split("\n").length : 0;
220
+ const words = selected ? wordCount(selected.content) : 0;
221
+ const sizeBytes = selected ? new Blob([selected.content]).size : 0;
222
+
223
+ /* Breadcrumb from path */
224
+ const breadcrumb = selected?.path.replace(/^\//, "").split("/") ?? [];
225
+
226
+ /* Error state */
227
+ if (error && files.length === 0) {
228
+ return <ErrorState message={error} onRetry={refresh} />;
229
+ }
230
+
231
+ /* ─── Rendered content ────────────────────────────────────────── */
232
+ let renderedContent: React.ReactNode = null;
233
+ if (selected) {
234
+ if (isJson) {
235
+ try {
236
+ const pretty = JSON.stringify(JSON.parse(selected.content), null, 2);
237
+ const lines = pretty.split("\n");
238
+ renderedContent = (
239
+ <div
240
+ style={{
241
+ background: "var(--code-bg)",
242
+ border: "1px solid var(--code-border)",
243
+ borderRadius: "var(--radius-md)",
244
+ padding: "var(--space-4)",
245
+ overflow: "auto",
246
+ }}
247
+ >
248
+ <div className="flex">
249
+ {/* Line numbers */}
250
+ <div
251
+ className="flex-shrink-0 select-none"
252
+ style={{
253
+ paddingRight: "var(--space-4)",
254
+ marginRight: "var(--space-4)",
255
+ borderRight: "1px solid var(--separator)",
256
+ }}
257
+ >
258
+ {lines.map((_, i) => (
259
+ <div
260
+ key={i}
261
+ className="font-mono text-right"
262
+ style={{
263
+ fontSize: "var(--text-caption2)",
264
+ lineHeight: "var(--leading-relaxed)",
265
+ color: "var(--text-tertiary)",
266
+ minWidth: "2.5ch",
267
+ }}
268
+ >
269
+ {i + 1}
270
+ </div>
271
+ ))}
272
+ </div>
273
+ {/* JSON content */}
274
+ <pre
275
+ className="font-mono flex-1"
276
+ style={{
277
+ fontSize: "var(--text-footnote)",
278
+ lineHeight: "var(--leading-relaxed)",
279
+ color: "var(--code-text)",
280
+ whiteSpace: "pre-wrap",
281
+ margin: 0,
282
+ }}
283
+ dangerouslySetInnerHTML={{
284
+ __html: colorizeJson(pretty),
285
+ }}
286
+ />
287
+ </div>
288
+ </div>
289
+ );
290
+ } catch {
291
+ renderedContent = (
292
+ <div
293
+ style={{
294
+ background: "var(--code-bg)",
295
+ border: "1px solid var(--code-border)",
296
+ borderRadius: "var(--radius-md)",
297
+ padding: "var(--space-4)",
298
+ }}
299
+ >
300
+ <pre
301
+ className="font-mono"
302
+ style={{
303
+ fontSize: "var(--text-footnote)",
304
+ color: "var(--system-red)",
305
+ whiteSpace: "pre-wrap",
306
+ margin: 0,
307
+ }}
308
+ >
309
+ {selected.content}
310
+ </pre>
311
+ </div>
312
+ );
313
+ }
314
+ } else {
315
+ renderedContent = (
316
+ <div
317
+ style={{
318
+ fontSize: "var(--text-subheadline)",
319
+ lineHeight: "var(--leading-relaxed)",
320
+ color: "var(--text-secondary)",
321
+ }}
322
+ dangerouslySetInnerHTML={{
323
+ __html: `<p class="mb-3" style="color:var(--text-secondary)">${renderMarkdown(selected.content)}</p>`,
324
+ }}
325
+ />
326
+ );
327
+ }
328
+ }
329
+
330
+ return (
331
+ <div
332
+ className="flex h-full animate-fade-in"
333
+ style={{ background: "var(--bg)" }}
334
+ >
335
+ {/* ── File list sidebar ──────────────────────────────────── */}
336
+ <aside
337
+ className={`flex-shrink-0 flex flex-col ${
338
+ mobileShowContent && selected ? "hidden md:flex" : "flex"
339
+ }`}
340
+ style={{
341
+ width: "100%",
342
+ maxWidth: "100%",
343
+ background: "var(--material-regular)",
344
+ backdropFilter: "var(--sidebar-backdrop)",
345
+ WebkitBackdropFilter: "var(--sidebar-backdrop)",
346
+ borderRight: "1px solid var(--separator)",
347
+ }}
348
+ >
349
+ <style>{`@media (min-width: 768px) { aside { width: 260px !important; min-width: 260px !important; } }`}</style>
350
+
351
+ {/* Sidebar header */}
352
+ <div
353
+ className="flex items-center justify-between flex-shrink-0"
354
+ style={{
355
+ padding: "var(--space-3) var(--space-4)",
356
+ borderBottom: "1px solid var(--separator)",
357
+ }}
358
+ >
359
+ <span
360
+ style={{
361
+ fontSize: "var(--text-body)",
362
+ fontWeight: "var(--weight-semibold)",
363
+ color: "var(--text-primary)",
364
+ }}
365
+ >
366
+ Memory
367
+ </span>
368
+ <button
369
+ onClick={refresh}
370
+ className="btn-ghost focus-ring"
371
+ aria-label="Refresh file list"
372
+ style={{
373
+ width: 28,
374
+ height: 28,
375
+ display: "flex",
376
+ alignItems: "center",
377
+ justifyContent: "center",
378
+ borderRadius: "var(--radius-sm)",
379
+ padding: 0,
380
+ }}
381
+ >
382
+ <RefreshCw size={14} style={{ color: "var(--text-tertiary)" }} />
383
+ </button>
384
+ </div>
385
+
386
+ {/* Search */}
387
+ <div style={{ padding: "var(--space-2) var(--space-3)" }}>
388
+ <input
389
+ ref={searchRef}
390
+ type="search"
391
+ placeholder="Search files..."
392
+ value={search}
393
+ onChange={(e) => setSearch(e.target.value)}
394
+ className="apple-input focus-ring"
395
+ aria-label="Search memory files"
396
+ style={{
397
+ width: "100%",
398
+ height: 32,
399
+ fontSize: "var(--text-footnote)",
400
+ padding: "0 var(--space-3)",
401
+ borderRadius: "var(--radius-sm)",
402
+ }}
403
+ />
404
+ </div>
405
+
406
+ {/* File list */}
407
+ <div
408
+ ref={listRef}
409
+ role="listbox"
410
+ aria-label="Memory files"
411
+ onKeyDown={handleListKeyDown}
412
+ className="flex-1 overflow-y-auto"
413
+ >
414
+ {loading ? (
415
+ /* Skeleton rows */
416
+ <div style={{ padding: "var(--space-2) var(--space-3)" }}>
417
+ {[1, 2, 3, 4].map((i) => (
418
+ <div
419
+ key={i}
420
+ style={{
421
+ padding: "var(--space-3) var(--space-3)",
422
+ display: "flex",
423
+ alignItems: "flex-start",
424
+ gap: "var(--space-2)",
425
+ }}
426
+ >
427
+ <Skeleton
428
+ className="flex-shrink-0"
429
+ style={{ width: 16, height: 16, borderRadius: 4 }}
430
+ />
431
+ <div style={{ flex: 1 }}>
432
+ <Skeleton style={{ width: "80%", height: 13, marginBottom: 6 }} />
433
+ <Skeleton style={{ width: "50%", height: 10 }} />
434
+ </div>
435
+ </div>
436
+ ))}
437
+ </div>
438
+ ) : filteredFiles.length === 0 ? (
439
+ <div
440
+ className="flex items-center justify-center"
441
+ style={{
442
+ height: 120,
443
+ fontSize: "var(--text-footnote)",
444
+ color: "var(--text-tertiary)",
445
+ }}
446
+ >
447
+ No files match
448
+ </div>
449
+ ) : (
450
+ filteredFiles.map((file) => {
451
+ const isActive = selected?.path === file.path;
452
+ const json = isJsonFile(file);
453
+ return (
454
+ <button
455
+ key={file.path}
456
+ role="option"
457
+ aria-selected={isActive}
458
+ onClick={() => selectFile(file)}
459
+ className="w-full text-left hover-bg focus-ring"
460
+ style={{
461
+ display: "flex",
462
+ alignItems: "flex-start",
463
+ gap: "var(--space-2)",
464
+ padding: "var(--space-3) var(--space-3)",
465
+ border: "none",
466
+ cursor: "pointer",
467
+ background: isActive
468
+ ? "var(--fill-secondary)"
469
+ : "transparent",
470
+ borderLeft: isActive
471
+ ? "3px solid var(--accent)"
472
+ : "3px solid transparent",
473
+ }}
474
+ >
475
+ <FileIcon isJson={json} />
476
+ <div className="min-w-0 flex-1">
477
+ <div
478
+ className="truncate"
479
+ style={{
480
+ fontSize: "var(--text-footnote)",
481
+ fontWeight: "var(--weight-semibold)",
482
+ color: "var(--text-primary)",
483
+ lineHeight: "var(--leading-snug)",
484
+ }}
485
+ >
486
+ {file.label}
487
+ </div>
488
+ <div
489
+ style={{
490
+ fontSize: "var(--text-caption2)",
491
+ color: "var(--text-tertiary)",
492
+ marginTop: 2,
493
+ }}
494
+ >
495
+ {formatBytes(new Blob([file.content]).size)} {"\u00b7"}{" "}
496
+ {timeAgo(file.lastModified)}
497
+ </div>
498
+ </div>
499
+ </button>
500
+ );
501
+ })
502
+ )}
503
+ </div>
504
+ </aside>
505
+
506
+ {/* ── Content view ───────────────────────────────────────── */}
507
+ <main
508
+ className={`flex-1 flex flex-col overflow-hidden ${
509
+ !mobileShowContent || !selected ? "hidden md:flex" : "flex"
510
+ }`}
511
+ style={{ background: "var(--bg)" }}
512
+ >
513
+ {selected ? (
514
+ <>
515
+ {/* Content header (sticky) */}
516
+ <div
517
+ className="flex-shrink-0"
518
+ style={{
519
+ padding: "var(--space-3) var(--space-6)",
520
+ borderBottom: "1px solid var(--separator)",
521
+ background: "var(--material-regular)",
522
+ backdropFilter: "blur(20px)",
523
+ WebkitBackdropFilter: "blur(20px)",
524
+ }}
525
+ >
526
+ {/* Mobile back button */}
527
+ <button
528
+ onClick={() => setMobileShowContent(false)}
529
+ className="md:hidden btn-ghost focus-ring"
530
+ aria-label="Back to file list"
531
+ style={{
532
+ display: "inline-flex",
533
+ alignItems: "center",
534
+ gap: "var(--space-1)",
535
+ padding: "4px 8px",
536
+ borderRadius: "var(--radius-sm)",
537
+ fontSize: "var(--text-footnote)",
538
+ color: "var(--system-blue)",
539
+ marginBottom: "var(--space-2)",
540
+ marginLeft: "-8px",
541
+ }}
542
+ >
543
+ <BackArrow />
544
+ Files
545
+ </button>
546
+
547
+ <div className="flex items-center justify-between">
548
+ <div className="min-w-0 flex-1">
549
+ {/* Breadcrumb */}
550
+ <div
551
+ className="truncate"
552
+ style={{
553
+ fontSize: "var(--text-footnote)",
554
+ fontWeight: "var(--weight-semibold)",
555
+ color: "var(--text-primary)",
556
+ }}
557
+ >
558
+ {breadcrumb.map((part, i) => (
559
+ <span key={i}>
560
+ {i > 0 && (
561
+ <span
562
+ style={{
563
+ color: "var(--text-tertiary)",
564
+ margin: "0 4px",
565
+ }}
566
+ >
567
+ /
568
+ </span>
569
+ )}
570
+ <span
571
+ style={{
572
+ color:
573
+ i === breadcrumb.length - 1
574
+ ? "var(--text-primary)"
575
+ : "var(--text-tertiary)",
576
+ }}
577
+ >
578
+ {part}
579
+ </span>
580
+ </span>
581
+ ))}
582
+ </div>
583
+
584
+ {/* Metadata */}
585
+ <div
586
+ style={{
587
+ fontSize: "var(--text-caption2)",
588
+ color: "var(--text-tertiary)",
589
+ marginTop: 2,
590
+ }}
591
+ >
592
+ {lineCount} line{lineCount !== 1 ? "s" : ""}
593
+ {!isJson && <> {"\u00b7"} {words.toLocaleString()} words</>}
594
+ {" \u00b7 "}
595
+ {formatBytes(sizeBytes)}
596
+ {" \u00b7 "}
597
+ {timeAgo(selected.lastModified)}
598
+ </div>
599
+ </div>
600
+
601
+ {/* Action buttons */}
602
+ <div className="flex items-center flex-shrink-0" style={{ gap: "var(--space-2)" }}>
603
+ <button
604
+ onClick={copyContent}
605
+ className="btn-ghost focus-ring"
606
+ aria-label="Copy file content"
607
+ style={{
608
+ padding: "6px 12px",
609
+ borderRadius: "var(--radius-sm)",
610
+ fontSize: "var(--text-caption1)",
611
+ fontWeight: "var(--weight-medium)",
612
+ display: "inline-flex",
613
+ alignItems: "center",
614
+ gap: 4,
615
+ }}
616
+ >
617
+ {copied ? <Check size={14} /> : <Copy size={14} />}
618
+ {copied ? "Copied" : "Copy"}
619
+ </button>
620
+ <button
621
+ onClick={downloadContent}
622
+ className="btn-ghost focus-ring"
623
+ aria-label="Download file"
624
+ style={{
625
+ padding: "6px 12px",
626
+ borderRadius: "var(--radius-sm)",
627
+ fontSize: "var(--text-caption1)",
628
+ fontWeight: "var(--weight-medium)",
629
+ display: "inline-flex",
630
+ alignItems: "center",
631
+ gap: 4,
632
+ }}
633
+ >
634
+ <Download size={14} />
635
+ Download
636
+ </button>
637
+ </div>
638
+ </div>
639
+ </div>
640
+
641
+ {/* Scrollable content area */}
642
+ <div
643
+ className="flex-1 overflow-y-auto"
644
+ style={{
645
+ padding: "var(--space-8) var(--space-10)",
646
+ }}
647
+ >
648
+ <div style={{ maxWidth: 760, margin: "0 auto" }}>
649
+ {renderedContent}
650
+ </div>
651
+ </div>
652
+ </>
653
+ ) : (
654
+ /* ── Empty state (no file selected) ──────────────────── */
655
+ <div
656
+ className="flex flex-col items-center justify-center h-full"
657
+ style={{ gap: "var(--space-3)" }}
658
+ >
659
+ <FolderIcon />
660
+ <span
661
+ style={{
662
+ fontSize: "var(--text-subheadline)",
663
+ fontWeight: "var(--weight-medium)",
664
+ color: "var(--text-secondary)",
665
+ marginTop: "var(--space-2)",
666
+ }}
667
+ >
668
+ Select a file
669
+ </span>
670
+ <span
671
+ style={{
672
+ fontSize: "var(--text-footnote)",
673
+ color: "var(--text-tertiary)",
674
+ textAlign: "center",
675
+ maxWidth: 240,
676
+ }}
677
+ >
678
+ Choose a file from the sidebar to view its contents
679
+ </span>
680
+ </div>
681
+ )}
682
+ </main>
683
+ </div>
684
+ );
685
+ }