Skip to content

Commit a978b0b

Browse files
ataibarkaiclaude
andcommitted
feat(showcase/shell-dojo): URL state + file tree in code view
Two related dojo polish items so the page is more navigable: 1. Sync integration + demo selection to the URL as ?integration=<slug>&demo=<id>. On mount the page hydrates state from the query string (with fallback to the first deployed integration's first demo when params are missing or invalid). On every selection change the URL is rewritten via history.replaceState, so refreshing lands on the same selection and links are now shareable. 2. Replace the flat horizontal file-tab strip above the code panel with a vertical folder tree on the right side of the code view. Filenames like `src/app/api/copilotkit/route.ts` now nest under their `src/ → app/ → api/ → copilotkit/` ancestors instead of competing for horizontal space, which scales much better as demos accumulate backend + frontend files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f437231 commit a978b0b

1 file changed

Lines changed: 233 additions & 41 deletions

File tree

showcase/shell-dojo/src/app/page.tsx

Lines changed: 233 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useMemo, useCallback } from "react";
3+
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
44
import {
55
getIntegrations,
66
getFeatureCategories,
@@ -138,6 +138,41 @@ export default function DojoPage() {
138138
setSelectedFileIndex(0);
139139
}, []);
140140

141+
// URL ↔ selection sync. On mount we hydrate from ?integration=&demo=; after
142+
// mount, every selection change is written back via history.replaceState so
143+
// refreshing the page lands on the same integration + demo.
144+
const urlHydratedRef = useRef(false);
145+
146+
useEffect(() => {
147+
if (!urlHydratedRef.current) {
148+
const params = new URLSearchParams(window.location.search);
149+
const urlSlug = params.get("integration");
150+
const urlDemo = params.get("demo");
151+
if (urlSlug) {
152+
const found = integrations.find((i) => i.slug === urlSlug);
153+
if (found) {
154+
setSelectedSlug(urlSlug);
155+
if (urlDemo && found.demos.some((d) => d.id === urlDemo)) {
156+
setSelectedDemoId(urlDemo);
157+
} else if (found.demos.length > 0) {
158+
setSelectedDemoId(found.demos[0].id);
159+
}
160+
}
161+
}
162+
urlHydratedRef.current = true;
163+
return;
164+
}
165+
if (!selectedSlug) return;
166+
const params = new URLSearchParams();
167+
params.set("integration", selectedSlug);
168+
if (selectedDemoId) params.set("demo", selectedDemoId);
169+
window.history.replaceState(
170+
null,
171+
"",
172+
`${window.location.pathname}?${params.toString()}`,
173+
);
174+
}, [integrations, selectedSlug, selectedDemoId]);
175+
141176
const previewUrl =
142177
integration && selectedDemo
143178
? `${integration.backend_url}${selectedDemo.route}`
@@ -601,60 +636,46 @@ export default function DojoPage() {
601636
<div
602637
style={{
603638
display: "flex",
604-
flexDirection: "column",
639+
flexDirection: "row",
605640
height: "100%",
606641
borderRadius: 8,
607642
overflow: "hidden",
608643
background: "#ffffff",
609644
border: "2px solid var(--border-default)",
610645
}}
611646
>
612-
{/* File tabs */}
613647
<div
614648
style={{
649+
flex: 1,
650+
minWidth: 0,
615651
display: "flex",
616-
gap: 0,
617-
borderBottom: "1px solid var(--border-container)",
618-
background: "#f8f8fb",
619-
overflowX: "auto",
620-
flexShrink: 0,
652+
flexDirection: "column",
653+
overflow: "hidden",
621654
}}
622655
>
623-
{allFiles.map((file, idx) => (
624-
<button
625-
key={file.filename}
626-
onClick={() => setSelectedFileIndex(idx)}
627-
style={{
628-
padding: "10px 18px",
629-
fontSize: 13,
630-
border: "none",
631-
borderBottom:
632-
idx === selectedFileIndex
633-
? "2px solid var(--text-primary)"
634-
: "2px solid transparent",
635-
cursor: "pointer",
636-
background:
637-
idx === selectedFileIndex ? "#ffffff" : "transparent",
638-
color:
639-
idx === selectedFileIndex
640-
? "var(--text-primary)"
641-
: "var(--text-disabled)",
642-
fontWeight: idx === selectedFileIndex ? 500 : 400,
643-
whiteSpace: "nowrap",
644-
fontFamily:
645-
"'Spline Sans Mono', ui-monospace, SFMono-Regular, monospace",
646-
}}
647-
>
648-
{file.filename}
649-
</button>
650-
))}
656+
{allFiles[selectedFileIndex] && (
657+
<CodeBlock
658+
code={allFiles[selectedFileIndex].content}
659+
language={allFiles[selectedFileIndex].language}
660+
/>
661+
)}
651662
</div>
652-
{allFiles[selectedFileIndex] && (
653-
<CodeBlock
654-
code={allFiles[selectedFileIndex].content}
655-
language={allFiles[selectedFileIndex].language}
663+
<aside
664+
style={{
665+
width: 248,
666+
flexShrink: 0,
667+
borderLeft: "1px solid var(--border-container)",
668+
background: "#fafbfd",
669+
overflow: "auto",
670+
padding: "12px 8px",
671+
}}
672+
>
673+
<FileTree
674+
files={allFiles}
675+
selectedFileIndex={selectedFileIndex}
676+
onSelect={setSelectedFileIndex}
656677
/>
657-
)}
678+
</aside>
658679
</div>
659680
) : (
660681
<div
@@ -677,6 +698,177 @@ export default function DojoPage() {
677698
);
678699
}
679700

701+
type FileTreeNode = {
702+
name: string;
703+
fileIndex: number | null;
704+
children: Map<string, FileTreeNode>;
705+
};
706+
707+
function buildFileTree(files: { filename: string }[]): FileTreeNode {
708+
const root: FileTreeNode = {
709+
name: "",
710+
fileIndex: null,
711+
children: new Map(),
712+
};
713+
files.forEach((file, idx) => {
714+
const parts = file.filename.split("/").filter(Boolean);
715+
let current = root;
716+
parts.forEach((part, i) => {
717+
const isFile = i === parts.length - 1;
718+
const existing = current.children.get(part);
719+
if (existing) {
720+
if (isFile) existing.fileIndex = idx;
721+
current = existing;
722+
} else {
723+
const node: FileTreeNode = {
724+
name: part,
725+
fileIndex: isFile ? idx : null,
726+
children: new Map(),
727+
};
728+
current.children.set(part, node);
729+
current = node;
730+
}
731+
});
732+
});
733+
return root;
734+
}
735+
736+
function sortedChildren(node: FileTreeNode): FileTreeNode[] {
737+
return Array.from(node.children.values()).sort((a, b) => {
738+
const aIsFolder = a.fileIndex === null;
739+
const bIsFolder = b.fileIndex === null;
740+
if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1;
741+
return a.name.localeCompare(b.name);
742+
});
743+
}
744+
745+
function FileTree({
746+
files,
747+
selectedFileIndex,
748+
onSelect,
749+
}: {
750+
files: { filename: string }[];
751+
selectedFileIndex: number;
752+
onSelect: (idx: number) => void;
753+
}) {
754+
const root = useMemo(() => buildFileTree(files), [files]);
755+
return (
756+
<div style={{ fontSize: 13 }}>
757+
<div style={{ padding: "0 6px 8px" }}>
758+
<span
759+
style={{
760+
fontSize: 11,
761+
fontWeight: 600,
762+
textTransform: "uppercase",
763+
letterSpacing: "0.06em",
764+
color: "var(--text-primary)",
765+
}}
766+
>
767+
Files
768+
</span>
769+
</div>
770+
{sortedChildren(root).map((node) => (
771+
<FileTreeRow
772+
key={node.name}
773+
node={node}
774+
depth={0}
775+
selectedFileIndex={selectedFileIndex}
776+
onSelect={onSelect}
777+
/>
778+
))}
779+
</div>
780+
);
781+
}
782+
783+
function FileTreeRow({
784+
node,
785+
depth,
786+
selectedFileIndex,
787+
onSelect,
788+
}: {
789+
node: FileTreeNode;
790+
depth: number;
791+
selectedFileIndex: number;
792+
onSelect: (idx: number) => void;
793+
}) {
794+
const isFolder = node.fileIndex === null;
795+
const isSelected = !isFolder && node.fileIndex === selectedFileIndex;
796+
const padLeft = 8 + depth * 12;
797+
if (isFolder) {
798+
return (
799+
<div>
800+
<div
801+
style={{
802+
display: "flex",
803+
alignItems: "center",
804+
gap: 4,
805+
padding: `2px 6px 2px ${padLeft}px`,
806+
color: "var(--text-secondary)",
807+
fontFamily:
808+
"'Spline Sans Mono', ui-monospace, SFMono-Regular, monospace",
809+
fontSize: 12.5,
810+
lineHeight: 1.6,
811+
userSelect: "none",
812+
}}
813+
>
814+
<span style={{ opacity: 0.6, fontSize: 11 }}></span>
815+
<span>{node.name}</span>
816+
</div>
817+
{sortedChildren(node).map((child) => (
818+
<FileTreeRow
819+
key={child.name}
820+
node={child}
821+
depth={depth + 1}
822+
selectedFileIndex={selectedFileIndex}
823+
onSelect={onSelect}
824+
/>
825+
))}
826+
</div>
827+
);
828+
}
829+
return (
830+
<button
831+
type="button"
832+
onClick={() => onSelect(node.fileIndex!)}
833+
style={{
834+
display: "flex",
835+
alignItems: "center",
836+
gap: 4,
837+
width: "100%",
838+
padding: `2px 6px 2px ${padLeft + 14}px`,
839+
border: "none",
840+
background: isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent",
841+
color: isSelected ? "var(--text-primary)" : "var(--text-secondary)",
842+
fontWeight: isSelected ? 500 : 400,
843+
textAlign: "left",
844+
cursor: "pointer",
845+
borderRadius: 4,
846+
fontFamily:
847+
"'Spline Sans Mono', ui-monospace, SFMono-Regular, monospace",
848+
fontSize: 12.5,
849+
lineHeight: 1.6,
850+
}}
851+
onMouseEnter={(e) => {
852+
if (!isSelected)
853+
e.currentTarget.style.background = "rgba(0, 0, 0, 0.03)";
854+
}}
855+
onMouseLeave={(e) => {
856+
if (!isSelected) e.currentTarget.style.background = "transparent";
857+
}}
858+
>
859+
<span
860+
style={{
861+
whiteSpace: "nowrap",
862+
overflow: "hidden",
863+
textOverflow: "ellipsis",
864+
}}
865+
>
866+
{node.name}
867+
</span>
868+
</button>
869+
);
870+
}
871+
680872
function SectionTitle({ title }: { title: string }) {
681873
return (
682874
<div

0 commit comments

Comments
 (0)