Studio v2 Skeleton Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship a prototype workspace at /studio/v2 β new replacement shell with a unified Tree (Projects + Knowledge), a persistent Breadcrumb, an empty Stage, and an empty Context panel. Classic UI untouched.
Architecture: All new code lives under claudecodeui/src/components/studio-v2/. A single <Route> is added to App.jsx inside the existing <ProjectProvider> + <ProtectedRoute> tree so auth and project state are inherited. The Projects tree reads from useProjects(). The Knowledge tree calls the existing /api/files endpoint. No backend changes. No unit-test framework exists for the frontend, so verification is npx vite build + Playwright Chrome smoke check per commit.
Tech Stack: React 18, React Router v6, Vite, Tailwind (existing HSL tokens), ProjectContextV2, /api/files (returns nested file/folder tree of the shared docs directory).
Spec: docs/superpowers/specs/2026-04-16-studio-v2-skeleton-design.md
Pre-flight
Before starting, confirm:
- Working from the
mainbranch of/Users/lindsay/Documents/work/sasha. - Local dev stack (
npm run devfromclaudecodeui/) runs cleanly onhttp://localhost:3007. - Playwright MCP (
mcp__plugin_playwright_playwright__*) tools are available. - Login works as user
lindsay/password.
Chrome troubleshooting: If Playwright fails to launch Chrome, run pkill -f "Google Chrome" and retry once. Do not loop.
Task 1: Create the worktree and branch
Files: none yet.
- Step 1: From the repo root, create an isolated worktree.
cd /Users/lindsay/Documents/work/sasha
git worktree add ../sasha-studio-v2 -b studio-v2-skeleton main
- Step 2: Move into the worktree and confirm.
cd ../sasha-studio-v2
git status
git branch --show-current
Expected: clean working tree; current branch studio-v2-skeleton.
- Step 3: Install deps (in case lockfile pulled anything missing for this checkout).
cd claudecodeui
npm install --no-audit --no-fund
Expected: completes without errors. "up to date" is fine.
- Step 4: Verify baseline build passes before any changes.
npx vite build
Expected: completes successfully. Note existing warnings; don't fix them.
Note: All subsequent tasks assume cwd is /Users/lindsay/Documents/work/sasha-studio-v2/claudecodeui unless otherwise specified. Commit with git from within the worktree.
Task 2: Scaffold route and empty StudioV2 component
Files:
Create:
src/components/studio-v2/StudioV2.jsxCreate:
src/components/studio-v2/index.jsModify:
src/App.jsx(add one import + one<Route>line)Step 1: Create the empty component.
File: src/components/studio-v2/StudioV2.jsx
import React from 'react';
export default function StudioV2() {
return (
<div
data-testid="studio-v2-root"
className="h-screen w-screen flex items-center justify-center bg-background text-foreground"
>
<div className="text-center">
<div className="text-xs uppercase tracking-widest text-muted-foreground mb-2">
Studio v2 β preview
</div>
<h1 className="text-2xl font-medium">Skeleton scaffold</h1>
</div>
</div>
);
}
- Step 2: Create the barrel re-export.
File: src/components/studio-v2/index.js
export { default as StudioV2 } from './StudioV2.jsx';
- Step 3: Register the route.
In src/App.jsx, add this import near the other component imports (around line 7β30):
import { StudioV2 } from './components/studio-v2';
Then add this line inside the inner <Routes> block that starts at line 1805, at the top of that block (before the existing <Route path="/" ...> line):
<Route path="/studio/v2" element={<StudioV2 />} />
- Step 4: Run the build.
npx vite build
Expected: build succeeds.
- Step 5: Smoke-check with Playwright.
Start the dev server in a separate terminal (npm run dev), then via Playwright MCP:
browser_navigateβhttp://localhost:3007/- Log in as
lindsay/password. browser_navigateβhttp://localhost:3007/studio/v2browser_snapshotβ expect to see "Skeleton scaffold" heading and the "Studio v2 β preview" eyebrow.browser_navigateβhttp://localhost:3007/β expect classic UI still renders normally.
- Step 6: Commit.
git add src/components/studio-v2/ src/App.jsx
git commit -m "feat(studio-v2): scaffold route and empty StudioV2 component
Adds /studio/v2 route inside ProtectedRoute + ProjectProvider.
Renders a minimal placeholder. No changes to classic UI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 3: Build the shell layout (TopBar + three columns + empty placeholders)
Files:
- Create:
src/components/studio-v2/StudioV2TopBar.jsx - Create:
src/components/studio-v2/StudioV2Layout.jsx - Modify:
src/components/studio-v2/StudioV2.jsx
This task lays down the entire frame with empty contents. No data fetching yet.
- Step 1: Create the top bar.
File: src/components/studio-v2/StudioV2TopBar.jsx
import React from 'react';
import { Link } from 'react-router-dom';
export default function StudioV2TopBar() {
return (
<div
data-testid="studio-v2-topbar"
className="h-10 border-b border-border flex items-center justify-between px-4 bg-background"
>
<Link
to="/"
className="text-sm font-medium tracking-tight hover:opacity-70 transition-opacity"
title="Back to classic Sasha"
>
Sasha
</Link>
<div className="text-[10px] uppercase tracking-widest text-muted-foreground">
Studio v2 β preview
</div>
<div className="flex items-center gap-3">
<button
type="button"
disabled
title="Studio drawer (coming soon)"
className="text-xs text-muted-foreground opacity-50 cursor-not-allowed"
data-testid="studio-v2-drawer-icon"
>
β«
</button>
<Link
to="/"
className="text-xs text-muted-foreground hover:text-foreground"
data-testid="studio-v2-back-to-classic"
>
β Back to classic
</Link>
</div>
</div>
);
}
- Step 2: Create the layout component with three columns + breadcrumb strip.
File: src/components/studio-v2/StudioV2Layout.jsx
import React from 'react';
export default function StudioV2Layout({ topBar, tree, breadcrumb, stage, context }) {
return (
<div className="h-screen w-screen flex flex-col bg-background text-foreground overflow-hidden">
{topBar}
<div className="flex-1 flex min-h-0">
<aside
data-testid="studio-v2-tree-rail"
className="w-[280px] shrink-0 border-r border-border overflow-y-auto"
>
{tree}
</aside>
<main
data-testid="studio-v2-stage"
className="flex-1 flex flex-col min-w-0"
>
<div
data-testid="studio-v2-breadcrumb-strip"
className="h-12 border-b border-border flex items-center px-6"
>
{breadcrumb}
</div>
<div className="flex-1 overflow-y-auto p-8">
{stage}
</div>
</main>
<aside
data-testid="studio-v2-context-rail"
className="w-[320px] shrink-0 border-l border-border overflow-y-auto"
>
{context}
</aside>
</div>
</div>
);
}
- Step 3: Wire the layout into
StudioV2.jsxwith inline empty placeholders for now.
Replace src/components/studio-v2/StudioV2.jsx with:
import React from 'react';
import StudioV2Layout from './StudioV2Layout.jsx';
import StudioV2TopBar from './StudioV2TopBar.jsx';
export default function StudioV2() {
return (
<StudioV2Layout
topBar={<StudioV2TopBar />}
tree={
<div data-testid="studio-v2-tree-placeholder" className="p-4 text-sm text-muted-foreground">
Tree loadingβ¦
</div>
}
breadcrumb={
<div data-testid="studio-v2-breadcrumb-empty" className="text-sm text-muted-foreground">
Studio v2
</div>
}
stage={
<div data-testid="studio-v2-stage-empty" className="text-sm text-muted-foreground">
Select a file or chat to begin.
</div>
}
context={
<div data-testid="studio-v2-context-placeholder" className="p-4">
<div className="text-xs uppercase tracking-widest text-muted-foreground mb-2">
In this context.
</div>
<div className="text-sm text-muted-foreground">Nothing selected.</div>
</div>
}
/>
);
}
- Step 4: Build.
npx vite build
Expected: succeeds.
- Step 5: Playwright smoke.
- Dev server running on
:3007. browser_navigateβ/studio/v2(after login).browser_snapshotβ verify all five testids render:studio-v2-topbar,studio-v2-tree-rail,studio-v2-breadcrumb-strip,studio-v2-stage,studio-v2-context-rail.- Click the
Sashamark β expect navigation to/(classic UI).
- Step 6: Commit.
git add src/components/studio-v2/
git commit -m "feat(studio-v2): three-column layout with top bar
Adds StudioV2Layout (tree rail, stage with breadcrumb strip, context
rail) and StudioV2TopBar. Stage and panels use placeholders β data
wiring comes next.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 4: Selection state and the Breadcrumb component
Files:
- Create:
src/components/studio-v2/useStudioV2Selection.js - Create:
src/components/studio-v2/Breadcrumb.jsx - Modify:
src/components/studio-v2/StudioV2.jsx
Selection state is owned at the top level. The Breadcrumb is a pure function of the selected node's path.
- Step 1: Create the selection hook.
File: src/components/studio-v2/useStudioV2Selection.js
import { useCallback, useState } from 'react';
/**
* Selection shape:
* null
* | { kind: 'project', id, path: string[] }
* | { kind: 'chat', id, projectId, path: string[] }
* | { kind: 'folder', id, root: 'knowledge', path: string[] }
* | { kind: 'file', id, root: 'knowledge', path: string[] }
*/
export default function useStudioV2Selection() {
const [selected, setSelected] = useState(null);
const select = useCallback((node) => {
setSelected(node);
}, []);
const clear = useCallback(() => setSelected(null), []);
// Given a path segment index, truncate the selection back to that ancestor.
// Does not change `kind` β returns a generic ancestor marker.
const selectAncestor = useCallback((selectedNode, index) => {
if (!selectedNode) return;
const truncated = selectedNode.path.slice(0, index + 1);
// Ancestor of a project-scoped node is a project; ancestor of a knowledge
// node is a folder (or root).
if (index === 0 && selectedNode.kind !== 'project') {
if (selectedNode.projectId) {
setSelected({ kind: 'project', id: selectedNode.projectId, path: truncated });
return;
}
if (selectedNode.root === 'knowledge') {
setSelected({ kind: 'folder', id: 'knowledge-root', root: 'knowledge', path: truncated });
return;
}
}
setSelected({ ...selectedNode, path: truncated });
}, []);
return { selected, select, clear, selectAncestor };
}
- Step 2: Create the Breadcrumb component.
File: src/components/studio-v2/Breadcrumb.jsx
import React from 'react';
export default function Breadcrumb({ selected, onSegmentClick }) {
if (!selected || !selected.path || selected.path.length === 0) {
return (
<div data-testid="studio-v2-breadcrumb-empty" className="text-sm text-muted-foreground">
Studio v2
</div>
);
}
return (
<nav
data-testid="studio-v2-breadcrumb"
aria-label="Breadcrumb"
className="text-sm flex items-center gap-1 min-w-0"
>
{selected.path.map((segment, i) => {
const isLast = i === selected.path.length - 1;
return (
<React.Fragment key={`${segment}-${i}`}>
{i > 0 && (
<span className="text-muted-foreground/60 select-none" aria-hidden="true">
βΊ
</span>
)}
{isLast ? (
<span
className="font-medium truncate"
data-testid={`studio-v2-breadcrumb-segment-${i}`}
>
{segment}
</span>
) : (
<button
type="button"
onClick={() => onSegmentClick(i)}
className="text-muted-foreground hover:text-foreground truncate"
data-testid={`studio-v2-breadcrumb-segment-${i}`}
>
{segment}
</button>
)}
</React.Fragment>
);
})}
</nav>
);
}
- Step 3: Wire into
StudioV2.jsx.
Replace the contents of src/components/studio-v2/StudioV2.jsx with:
import React from 'react';
import StudioV2Layout from './StudioV2Layout.jsx';
import StudioV2TopBar from './StudioV2TopBar.jsx';
import Breadcrumb from './Breadcrumb.jsx';
import useStudioV2Selection from './useStudioV2Selection.js';
export default function StudioV2() {
const { selected, selectAncestor } = useStudioV2Selection();
return (
<StudioV2Layout
topBar={<StudioV2TopBar />}
tree={
<div data-testid="studio-v2-tree-placeholder" className="p-4 text-sm text-muted-foreground">
Tree loadingβ¦
</div>
}
breadcrumb={
<Breadcrumb
selected={selected}
onSegmentClick={(i) => selectAncestor(selected, i)}
/>
}
stage={
<div data-testid="studio-v2-stage-empty" className="text-sm text-muted-foreground">
Select a file or chat to begin.
</div>
}
context={
<div data-testid="studio-v2-context-placeholder" className="p-4">
<div className="text-xs uppercase tracking-widest text-muted-foreground mb-2">
In this context.
</div>
<div className="text-sm text-muted-foreground">Nothing selected.</div>
</div>
}
/>
);
}
- Step 4: Build.
npx vite build
Expected: succeeds.
Step 5: Playwright smoke. No visible change yet (selection is null).
browser_navigateβ/studio/v2; breadcrumb should show "Studio v2" text.Step 6: Commit.
git add src/components/studio-v2/
git commit -m "feat(studio-v2): selection state and breadcrumb component
Introduces useStudioV2Selection hook and a pure Breadcrumb that renders
clickable ancestor segments. Breadcrumb falls back to 'Studio v2' when
no selection exists.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 5: Projects tree root (reads useProjects, renders projects + chats)
Files:
Create:
src/components/studio-v2/TreeNode.jsxCreate:
src/components/studio-v2/ProjectsTreeRoot.jsxCreate:
src/components/studio-v2/StudioV2Tree.jsxModify:
src/components/studio-v2/StudioV2.jsxStep 1: Create a reusable TreeNode presentational component.
File: src/components/studio-v2/TreeNode.jsx
import React from 'react';
/**
* Generic tree node row. Expandability is controlled by the parent.
*
* Props:
* label string
* icon React node (e.g. 'β', 'π', 'βΈ', 'βΎ')
* depth number (indent level, 0 = root)
* active boolean
* onClick () => void
* onToggle () => void (optional; if present, an expand caret is drawn)
* isExpanded boolean
* testId string
*/
export default function TreeNode({
label,
icon,
depth = 0,
active = false,
onClick,
onToggle,
isExpanded,
testId,
}) {
return (
<div
data-testid={testId}
className={`flex items-center gap-1 h-7 pr-2 text-sm cursor-pointer select-none ${
active ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'
}`}
style={{ paddingLeft: `${8 + depth * 12}px` }}
onClick={onClick}
>
{onToggle ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
className="w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={isExpanded ? 'Collapse' : 'Expand'}
>
{isExpanded ? 'βΎ' : 'βΈ'}
</button>
) : (
<span className="w-4" aria-hidden="true" />
)}
<span className="w-4 text-center text-muted-foreground" aria-hidden="true">
{icon}
</span>
<span className="truncate">{label}</span>
</div>
);
}
- Step 2: Create ProjectsTreeRoot.
File: src/components/studio-v2/ProjectsTreeRoot.jsx
import React, { useState } from 'react';
import TreeNode from './TreeNode.jsx';
import { useProjects } from '../../contexts/ProjectContextV2';
export default function ProjectsTreeRoot({ selected, onSelect }) {
const { projects, isLoadingProjects } = useProjects();
const [rootExpanded, setRootExpanded] = useState(true);
const [expandedProjects, setExpandedProjects] = useState(() => new Set());
const toggleProject = (projectId) => {
setExpandedProjects((prev) => {
const next = new Set(prev);
if (next.has(projectId)) next.delete(projectId);
else next.add(projectId);
return next;
});
};
return (
<div data-testid="studio-v2-projects-root">
<TreeNode
label="Projects"
icon="β£"
depth={0}
onToggle={() => setRootExpanded((v) => !v)}
isExpanded={rootExpanded}
testId="studio-v2-projects-root-node"
/>
{rootExpanded && (
<>
{isLoadingProjects && (
<div className="pl-8 py-2 text-xs text-muted-foreground" data-testid="studio-v2-projects-loading">
Loading projectsβ¦
</div>
)}
{!isLoadingProjects && projects.length === 0 && (
<div className="pl-8 py-2 text-xs text-muted-foreground" data-testid="studio-v2-projects-empty">
No projects yet.
</div>
)}
{projects.map((project) => {
const isExpanded = expandedProjects.has(project.id);
const isActive =
selected?.kind === 'project' && selected.id === project.id;
const sessions = project.sessions || [];
return (
<React.Fragment key={project.id}>
<TreeNode
label={project.displayName || project.name || project.id}
icon="β°"
depth={1}
active={isActive}
onClick={() =>
onSelect({
kind: 'project',
id: project.id,
path: [project.displayName || project.name || project.id],
})
}
onToggle={() => toggleProject(project.id)}
isExpanded={isExpanded}
testId={`studio-v2-project-${project.id}`}
/>
{isExpanded &&
sessions.map((session) => {
const sessionLabel =
session.summary || session.title || session.id;
const isSessionActive =
selected?.kind === 'chat' && selected.id === session.id;
return (
<TreeNode
key={session.id}
label={sessionLabel}
icon="β"
depth={2}
active={isSessionActive}
onClick={() =>
onSelect({
kind: 'chat',
id: session.id,
projectId: project.id,
path: [
project.displayName || project.name || project.id,
sessionLabel,
],
})
}
testId={`studio-v2-chat-${session.id}`}
/>
);
})}
{isExpanded && sessions.length === 0 && (
<div
className="pl-12 py-1 text-xs text-muted-foreground"
data-testid={`studio-v2-project-${project.id}-empty-chats`}
>
No chats yet.
</div>
)}
</React.Fragment>
);
})}
</>
)}
</div>
);
}
- Step 3: Create a placeholder
StudioV2Tree.jsxthat composes the roots. Knowledge root is a stub for now β Task 6 replaces it with the real component.
File: src/components/studio-v2/StudioV2Tree.jsx
import React from 'react';
import ProjectsTreeRoot from './ProjectsTreeRoot.jsx';
export default function StudioV2Tree({ selected, onSelect }) {
return (
<div className="py-2" data-testid="studio-v2-tree">
<div className="px-2 pb-2">
<input
type="search"
placeholder="Search (coming soon)"
disabled
className="w-full h-8 px-2 text-xs bg-muted/40 border border-border rounded text-muted-foreground placeholder:text-muted-foreground/70 cursor-not-allowed"
data-testid="studio-v2-tree-search"
/>
</div>
<ProjectsTreeRoot selected={selected} onSelect={onSelect} />
<div className="py-2" />
<div
data-testid="studio-v2-knowledge-root-placeholder"
className="px-3 py-1 text-xs text-muted-foreground"
>
Knowledge (coming next)
</div>
</div>
);
}
- Step 4: Wire into
StudioV2.jsx. Replace tree placeholder with<StudioV2Tree selected={selected} onSelect={select} />.
Full replacement of src/components/studio-v2/StudioV2.jsx:
import React from 'react';
import StudioV2Layout from './StudioV2Layout.jsx';
import StudioV2TopBar from './StudioV2TopBar.jsx';
import StudioV2Tree from './StudioV2Tree.jsx';
import Breadcrumb from './Breadcrumb.jsx';
import useStudioV2Selection from './useStudioV2Selection.js';
export default function StudioV2() {
const { selected, select, selectAncestor } = useStudioV2Selection();
return (
<StudioV2Layout
topBar={<StudioV2TopBar />}
tree={<StudioV2Tree selected={selected} onSelect={select} />}
breadcrumb={
<Breadcrumb
selected={selected}
onSegmentClick={(i) => selectAncestor(selected, i)}
/>
}
stage={
selected ? (
<div
data-testid="studio-v2-stage-selected-placeholder"
className="text-sm text-muted-foreground"
>
Select a file or chat inside this project.
</div>
) : (
<div
data-testid="studio-v2-stage-empty"
className="text-sm text-muted-foreground"
>
Select a file or chat to begin.
</div>
)
}
context={
<div data-testid="studio-v2-context-placeholder" className="p-4">
<div className="text-xs uppercase tracking-widest text-muted-foreground mb-2">
In this context.
</div>
<div className="text-sm text-muted-foreground">Nothing selected.</div>
</div>
}
/>
);
}
- Step 5: Build.
npx vite build
Expected: succeeds.
- Step 6: Playwright smoke.
- Navigate to
/studio/v2. - Assert
studio-v2-projects-root-nodeis visible. - Click any
studio-v2-project-*node's caret β expect its chats to render (or "No chats yet."). - Click a chat leaf β expect
studio-v2-breadcrumbto appear with two segments (project name, chat title) and stage placeholder text to update to "Select a file or chat inside this project." - Click the first breadcrumb segment β expect selection truncates to the project (active highlight moves).
- Step 7: Commit.
git add src/components/studio-v2/
git commit -m "feat(studio-v2): projects tree root with chats as leaves
ProjectsTreeRoot reads useProjects() and renders each project with its
sessions (chats) as flat leaves. Selecting a chat updates the
breadcrumb. Knowledge root still a placeholder.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 6: Knowledge tree root (fetches /api/files, renders folder tree)
Files:
- Create:
src/components/studio-v2/KnowledgeTreeRoot.jsx - Modify:
src/components/studio-v2/StudioV2Tree.jsx
The /api/files endpoint (see server/index.js:2580) returns a nested tree of the shared docs directory. Each node has { name, path, type: 'file' | 'directory', children? }. We render it with the same TreeNode component.
- Step 1: Create KnowledgeTreeRoot.
File: src/components/studio-v2/KnowledgeTreeRoot.jsx
import React, { useEffect, useState } from 'react';
import TreeNode from './TreeNode.jsx';
import { authenticatedFetch } from '../../utils/api';
function KnowledgeNode({ node, depth, selected, onSelect, parentPath }) {
const [isExpanded, setIsExpanded] = useState(false);
const isFolder = node.type === 'directory';
const label = node.name;
const path = [...parentPath, label];
const id = node.path || path.join('/');
const isActive =
selected?.root === 'knowledge' &&
selected.id === id;
return (
<>
<TreeNode
label={label}
icon={isFolder ? 'β€' : 'π'}
depth={depth}
active={isActive}
onClick={() =>
onSelect({
kind: isFolder ? 'folder' : 'file',
id,
root: 'knowledge',
path,
})
}
onToggle={isFolder ? () => setIsExpanded((v) => !v) : undefined}
isExpanded={isExpanded}
testId={`studio-v2-knowledge-${id}`}
/>
{isFolder && isExpanded && Array.isArray(node.children) &&
node.children.map((child) => (
<KnowledgeNode
key={child.path || child.name}
node={child}
depth={depth + 1}
selected={selected}
onSelect={onSelect}
parentPath={path}
/>
))}
</>
);
}
export default function KnowledgeTreeRoot({ selected, onSelect }) {
const [status, setStatus] = useState('loading'); // 'loading' | 'ok' | 'error'
const [tree, setTree] = useState([]);
const [error, setError] = useState(null);
const [rootExpanded, setRootExpanded] = useState(true);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setStatus('loading');
setError(null);
authenticatedFetch('/api/files')
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data) => {
if (cancelled) return;
// /api/files returns an array of top-level entries (directory tree).
const items = Array.isArray(data) ? data : data.files || data.children || [];
setTree(items);
setStatus('ok');
})
.catch((err) => {
if (cancelled) return;
setError(err.message || 'Failed to load');
setStatus('error');
});
return () => {
cancelled = true;
};
}, [attempt]);
return (
<div data-testid="studio-v2-knowledge-root">
<TreeNode
label="Knowledge"
icon="β€"
depth={0}
onToggle={() => setRootExpanded((v) => !v)}
isExpanded={rootExpanded}
testId="studio-v2-knowledge-root-node"
/>
{rootExpanded && (
<>
{status === 'loading' && (
<div className="pl-8 py-2 text-xs text-muted-foreground" data-testid="studio-v2-knowledge-loading">
Loading knowledgeβ¦
</div>
)}
{status === 'error' && (
<div className="pl-8 py-2 text-xs" data-testid="studio-v2-knowledge-error">
<div className="text-destructive">Couldn't load knowledge: {error}</div>
<button
type="button"
onClick={() => setAttempt((n) => n + 1)}
className="mt-1 underline text-muted-foreground hover:text-foreground"
data-testid="studio-v2-knowledge-retry"
>
Retry
</button>
</div>
)}
{status === 'ok' && tree.length === 0 && (
<div className="pl-8 py-2 text-xs text-muted-foreground" data-testid="studio-v2-knowledge-empty">
No knowledge files.
</div>
)}
{status === 'ok' &&
tree.map((node) => (
<KnowledgeNode
key={node.path || node.name}
node={node}
depth={1}
selected={selected}
onSelect={onSelect}
parentPath={[]}
/>
))}
</>
)}
</div>
);
}
- Step 2: Replace the Knowledge placeholder in
StudioV2Tree.jsx.
Replace the placeholder <div data-testid="studio-v2-knowledge-root-placeholder"...> with <KnowledgeTreeRoot selected={selected} onSelect={onSelect} />. Add the import at the top.
Full replacement of src/components/studio-v2/StudioV2Tree.jsx:
import React from 'react';
import ProjectsTreeRoot from './ProjectsTreeRoot.jsx';
import KnowledgeTreeRoot from './KnowledgeTreeRoot.jsx';
export default function StudioV2Tree({ selected, onSelect }) {
return (
<div className="py-2" data-testid="studio-v2-tree">
<div className="px-2 pb-2">
<input
type="search"
placeholder="Search (coming soon)"
disabled
className="w-full h-8 px-2 text-xs bg-muted/40 border border-border rounded text-muted-foreground placeholder:text-muted-foreground/70 cursor-not-allowed"
data-testid="studio-v2-tree-search"
/>
</div>
<ProjectsTreeRoot selected={selected} onSelect={onSelect} />
<div className="py-2" />
<KnowledgeTreeRoot selected={selected} onSelect={onSelect} />
</div>
);
}
- Step 3: Build.
npx vite build
Expected: succeeds.
- Step 4: Playwright smoke.
- Navigate to
/studio/v2. - Assert
studio-v2-knowledge-root-noderenders. - Wait for either
studio-v2-knowledge-loadingto disappear or files to appear. - If knowledge files exist, click one β expect
studio-v2-breadcrumbpopulates with the file's path segments. - Clicking a Knowledge folder's caret should expand it without changing selection.
- Step 5: Commit.
git add src/components/studio-v2/
git commit -m "feat(studio-v2): knowledge tree root from /api/files
Fetches the shared docs tree on mount and renders it as a recursive
folder tree. Loading, error (with Retry), and empty states inline.
Selecting a knowledge file updates the breadcrumb.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 7: Mobile guard
Files:
Create:
src/components/studio-v2/useIsDesktop.jsModify:
src/components/studio-v2/StudioV2.jsxStep 1: Create the hook.
File: src/components/studio-v2/useIsDesktop.js
import { useEffect, useState } from 'react';
const QUERY = '(min-width: 768px)';
export default function useIsDesktop() {
const [isDesktop, setIsDesktop] = useState(() =>
typeof window === 'undefined' ? true : window.matchMedia(QUERY).matches
);
useEffect(() => {
const mql = window.matchMedia(QUERY);
const handler = (e) => setIsDesktop(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
return isDesktop;
}
- Step 2: Modify
StudioV2.jsxto render a notice at mobile widths.
Replace the top of StudioV2.jsx's function body with:
export default function StudioV2() {
const isDesktop = useIsDesktop();
const { selected, select, selectAncestor } = useStudioV2Selection();
if (!isDesktop) {
return (
<div
data-testid="studio-v2-mobile-notice"
className="h-screen w-screen flex items-center justify-center p-6 bg-background text-foreground text-center"
>
<div>
<div className="text-xs uppercase tracking-widest text-muted-foreground mb-2">
Studio v2 β preview
</div>
<p className="text-sm text-foreground/80">
Studio v2 is desktop-only during preview.
</p>
</div>
</div>
);
}
return (
// existing <StudioV2Layout .../>
Add import useIsDesktop from './useIsDesktop.js'; to the imports.
- Step 3: Build.
npx vite build
Expected: succeeds.
- Step 4: Playwright smoke.
browser_resizeβ375 x 800(mobile).- Navigate to
/studio/v2β expectstudio-v2-mobile-notice. browser_resizeβ1440 x 900(desktop).- Reload
/studio/v2β expect the full layout.
- Step 5: Commit.
git add src/components/studio-v2/
git commit -m "feat(studio-v2): desktop-only notice at mobile widths
Below 768px Studio v2 shows a centered 'desktop-only during preview'
notice rather than a broken layout. Layout renders normally on desktop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
Task 8: Full smoke verification pass and PR prep
Files: none created; verification only.
This task is a final sweep before pushing the branch.
- Step 1: Fresh build.
cd /Users/lindsay/Documents/work/sasha-studio-v2/claudecodeui
npx vite build
Expected: passes.
- Step 2: Lint (even though it's server-only it must still pass).
npm run lint
Expected: passes.
- Step 3: Full Playwright regression. With dev server running:
- Navigate to
/β classic UI loads, classic sidebar visible. - Navigate to
/studio/v2. Assert:- Top bar visible with "Sasha" link, "Studio v2 β preview" eyebrow, and "β Back to classic" link.
- Tree rail visible, with both "Projects" and "Knowledge" roots.
- Breadcrumb strip visible (showing "Studio v2" if no selection).
- Stage shows "Select a file or chat to begin."
- Context rail shows "In this context." + "Nothing selected."
- Expand a project β chats appear (or "No chats yet.").
- Click a chat β breadcrumb populates, stage swaps to the project-selected placeholder.
- Click the first breadcrumb segment β selection truncates to the project.
- Expand Knowledge β files/folders render (or error + Retry, or "No knowledge files.").
- Click a knowledge file β breadcrumb reflects the full path.
- Click "β Back to classic" β navigates to
/, classic UI intact. - Navigate to
/session/<any>via classic β chat still opens normally (spot-check that the classic app is unharmed).
- Step 4: Commit any residual uncommitted items (there shouldn't be any; this is a belt-and-braces step).
git status
# If clean, skip. Otherwise:
# git add -p && git commit -m "..."
- Step 5: Push branch.
git push -u origin studio-v2-skeleton
- Step 6: Open PR via
gh.
gh pr create --title "feat(studio-v2): skeleton prototype at /studio/v2" --body "$(cat <<'EOF'
## Summary
- Prototype workspace at `/studio/v2` with new replacement shell.
- Unified Tree with two roots: **Projects** (from `useProjects()` β projects with chats as flat leaves) and **Knowledge** (from `/api/files`).
- Persistent Breadcrumb driven from selection state.
- Empty Stage and Context panel β viewers and drawer come in later slices.
- Mobile viewport shows a desktop-only notice.
- Classic UI untouched; no backend changes.
First slice of the Sasha Studio redesign. See `docs/superpowers/specs/2026-04-16-studio-v2-skeleton-design.md` for the full design context and `docs/superpowers/plans/2026-04-16-studio-v2-skeleton.md` for the implementation plan.
## Test plan
- [ ] `npx vite build` passes
- [ ] `npm run lint` passes
- [ ] `/studio/v2` renders top bar, tree, breadcrumb strip, empty stage, context panel
- [ ] Projects root expands; chats appear as leaves
- [ ] Knowledge root expands; folders/files render (or Retry on error)
- [ ] Selecting a node populates the breadcrumb with the full path
- [ ] Clicking a breadcrumb segment truncates selection to that ancestor
- [ ] Mobile viewport shows the desktop-only notice
- [ ] Classic UI at `/` is visually and functionally unchanged
π€ Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
Expected: PR URL printed.
Task 9: Merge the worktree back (user confirms first)
Files: none.
Step 1: Ask the user to confirm the PR looks right before any merge.
Step 2: After the user approves and the PR is merged (or chooses to keep it open), clean up the worktree.
cd /Users/lindsay/Documents/work/sasha
git worktree remove ../sasha-studio-v2
# If the branch is merged:
# git branch -d studio-v2-skeleton
Expected: the worktree directory is gone; local main is up to date.
Self-Review Notes (done after writing)
- Spec coverage: every section of the spec maps to a task:
- Route + shell replacement β Task 2 & 3
- Unified Tree with Projects + Knowledge roots β Tasks 5 & 6
- Persistent Breadcrumb β Task 4 (wired in Task 5)
- Empty Stage & Context placeholders β Task 3 (refined in Task 5)
- Mobile guard β Task 7
- Desktop-only notice, retry button, empty states β Tasks 6 & 7
- No classic UI changes β verified in Task 8 smoke step 9
- Verification loop (
vite build+ Playwright) β present in every task - Worktree & branch β Task 1
- Placeholder scan: no TBDs; every code step contains the actual code.
- Type consistency: selection shape is defined once in
useStudioV2Selection.jsand used consistently acrossBreadcrumb.jsx,ProjectsTreeRoot.jsx,KnowledgeTreeRoot.jsx. - One signature:
onSelect(node)everywhere;onSegmentClick(index)for the breadcrumb;onToggle()for tree expand. No drift.
