Context is Everything logo

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 main branch of /Users/lindsay/Documents/work/sasha.
  • Local dev stack (npm run dev from claudecodeui/) runs cleanly on http://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.jsx

  • Create: src/components/studio-v2/index.js

  • Modify: 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:

  1. browser_navigate β†’ http://localhost:3007/
  2. Log in as lindsay / password.
  3. browser_navigate β†’ http://localhost:3007/studio/v2
  4. browser_snapshot β€” expect to see "Skeleton scaffold" heading and the "Studio v2 β€” preview" eyebrow.
  5. 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.jsx with 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.
  1. Dev server running on :3007.
  2. browser_navigate β†’ /studio/v2 (after login).
  3. 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.
  4. Click the Sasha mark β€” 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.jsx

  • Create: src/components/studio-v2/ProjectsTreeRoot.jsx

  • Create: src/components/studio-v2/StudioV2Tree.jsx

  • Modify: src/components/studio-v2/StudioV2.jsx

  • Step 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.jsx that 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.
  1. Navigate to /studio/v2.
  2. Assert studio-v2-projects-root-node is visible.
  3. Click any studio-v2-project-* node's caret β€” expect its chats to render (or "No chats yet.").
  4. Click a chat leaf β€” expect studio-v2-breadcrumb to appear with two segments (project name, chat title) and stage placeholder text to update to "Select a file or chat inside this project."
  5. 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.
  1. Navigate to /studio/v2.
  2. Assert studio-v2-knowledge-root-node renders.
  3. Wait for either studio-v2-knowledge-loading to disappear or files to appear.
  4. If knowledge files exist, click one β€” expect studio-v2-breadcrumb populates with the file's path segments.
  5. 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.js

  • Modify: src/components/studio-v2/StudioV2.jsx

  • Step 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.jsx to 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.
  1. browser_resize β†’ 375 x 800 (mobile).
  2. Navigate to /studio/v2 β€” expect studio-v2-mobile-notice.
  3. browser_resize β†’ 1440 x 900 (desktop).
  4. 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:
  1. Navigate to / β€” classic UI loads, classic sidebar visible.
  2. 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."
  3. Expand a project β€” chats appear (or "No chats yet.").
  4. Click a chat β€” breadcrumb populates, stage swaps to the project-selected placeholder.
  5. Click the first breadcrumb segment β€” selection truncates to the project.
  6. Expand Knowledge β€” files/folders render (or error + Retry, or "No knowledge files.").
  7. Click a knowledge file β€” breadcrumb reflects the full path.
  8. Click "← Back to classic" β€” navigates to /, classic UI intact.
  9. 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.js and used consistently across Breadcrumb.jsx, ProjectsTreeRoot.jsx, KnowledgeTreeRoot.jsx.
  • One signature: onSelect(node) everywhere; onSegmentClick(index) for the breadcrumb; onToggle() for tree expand. No drift.