Catalog

Edit Props (live)

TEXT
TEXT
TEXT
config
config
config
config
config
config
config
config
config
config

Source

691 lines
import React from "react";
import {
  AbsoluteFill,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  Easing,
  spring,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";

loadInter("normal", { weights: ["400", "500", "600", "700", "800"] });
loadMono("normal", { weights: ["400", "500"] });

const FONT = "'Inter', sans-serif";
const MONO = "'JetBrains Mono', monospace";
const ACCENT = "#D97757";

interface Tab { label: string; active?: boolean }
interface FileRow {
  name: string;
  kind?: "folder" | "file";
  depth?: number;
  active?: boolean;
  open?: boolean;        // folder open chevron
  ext?: string;          // file extension for icon color
}

type ChatMode = "conversation" | "typing";

interface ClaudeCodeChatProps {
  // Conversation-mode fields
  userMessage: string;
  aiHeading?: string;
  aiResponse: string;
  thoughtSeconds?: number;

  // IDE chrome
  tabs?: Tab[];
  windowTitle?: string;
  fileTree?: FileRow[];
  showSidebar?: boolean;
  placeholder?: string;
  bypassLabel?: string;

  // Animation
  typingCps?: number;
  mode?: ChatMode;       // "conversation" (default) or "typing" (cursor → click → zoom → type)

  backgroundColor?: string; // ignored — fixed dark
}

const DEFAULT_TABS: Tab[] = [
  { label: "tokens.css" },
  { label: "landing.tsx" },
  { label: "landing-v2.tsx", active: true },
  { label: "README.md" },
];

const DEFAULT_TREE: FileRow[] = [
  { name: "pipeline",       kind: "folder", depth: 0, open: true },
  { name: "src",            kind: "folder", depth: 1, open: true },
  { name: "keywords.ts",    kind: "file",   depth: 2, ext: "ts" },
  { name: "seo.ts",         kind: "file",   depth: 2, ext: "ts" },
  { name: "publish.ts",     kind: "file",   depth: 2, ext: "ts", active: true },
  { name: "package.json",   kind: "file",   depth: 1, ext: "json" },
  { name: "content",        kind: "folder", depth: 0, open: true },
  { name: "drafts",         kind: "folder", depth: 1 },
  { name: "published",      kind: "folder", depth: 1 },
  { name: "README.md",      kind: "file",   depth: 0, ext: "md" },
];

const EXT_COLOR: Record<string, string> = {
  ts:   "#3178c6",
  tsx:  "#3178c6",
  js:   "#f7df1e",
  json: "#facc15",
  md:   "#9ca3af",
  css:  "#38bdf8",
  py:   "#3776ab",
  html: "#e34f26",
};

export const ClaudeCodeChat: React.FC<ClaudeCodeChatProps> = ({
  userMessage,
  aiHeading,
  aiResponse,
  thoughtSeconds,
  tabs,
  windowTitle = "claude-code — vscode",
  fileTree,
  showSidebar = true,
  placeholder = "ctrl esc to focus or unfocus Claude",
  bypassLabel = "Bypass permissions",
  typingCps = 90,
  mode = "conversation",
  backgroundColor,
}) => {
  void backgroundColor;
  const frame = useCurrentFrame();
  const { fps, width, height, durationInFrames } = useVideoConfig();
  const t = frame / fps;

  // 16:9 panel parked so it ends ~10px above the centered body-caption band.
  const panelW = width;
  const panelH = Math.round(panelW * 9 / 16);
  const panelTop = Math.round(height * 0.15);

  // Conversation timings (mode = conversation)
  const userSpring = spring({ frame: frame - 6, fps, config: { mass: 0.6, damping: 14, stiffness: 200 } });
  const userOp = interpolate(userSpring, [0, 1], [0, 1]);
  const userY = interpolate(userSpring, [0, 1], [18, 0]);
  const thoughtOp = interpolate(frame, [16, 24], [0, 1], { extrapolateRight: "clamp" });
  const headingOp = interpolate(frame, [22, 30], [0, 1], { extrapolateRight: "clamp" });
  const headingY = interpolate(frame, [22, 30], [12, 0], { extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) });
  const streamStart = 26;
  const revealCount = Math.max(0, Math.floor(((frame - streamStart) / fps) * typingCps));
  const streamed = aiResponse.slice(0, revealCount);
  const isStreaming = revealCount < aiResponse.length;
  const cursorBlink = isStreaming && frame % 18 < 9;

  // Typing-mode timings: scale to the scene's actual duration so even a
  // ~1.5s scene shows the cursor land + at least the first half of the typed
  // text. Fractions of durationInFrames; typingCps auto-derives so the
  // message finishes ~95% of the way through the scene.
  const D = durationInFrames;
  const T_CURSOR_IN_F = Math.round(D * 0.04);
  const T_CURSOR_AT_F = Math.round(D * 0.22);
  const T_CLICK_F = Math.round(D * 0.27);
  const T_ZOOM_START_F = Math.round(D * 0.30);
  const T_ZOOM_END_F = Math.round(D * 0.50);
  const T_TYPE_START_F = Math.round(D * 0.32);   // start typing as soon as click fires
  const T_TYPE_END_F = Math.round(D * 0.95);
  const typingWindowFrames = Math.max(1, T_TYPE_END_F - T_TYPE_START_F);
  const charsPerFrame = Math.max(0.4, userMessage.length / typingWindowFrames);
  const typingChars = Math.max(0, Math.floor((frame - T_TYPE_START_F) * charsPerFrame));
  const typedText = userMessage.slice(0, typingChars);
  const typingCaret = typingChars < userMessage.length && frame % 18 < 9;

  // Camera zoom for typing mode — zooms toward the composer's position.
  // Composer sits at the bottom of the panel; transform-origin pulls toward it.
  const zoomT = interpolate(frame, [T_ZOOM_START_F, T_ZOOM_END_F], [0, 1], {
    extrapolateLeft: "clamp", extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });
  const zoomScale = mode === "typing" ? interpolate(zoomT, [0, 1], [1, 1.6]) : 1;

  const chromeOp = interpolate(t, [0, 0.25], [0, 1], { extrapolateRight: "clamp" });
  const chromeScale = interpolate(t, [0, 0.25], [0.97, 1], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  const tabList = tabs && tabs.length ? tabs : DEFAULT_TABS;
  const tree = fileTree && fileTree.length ? fileTree : DEFAULT_TREE;
  const sidebarW = showSidebar ? Math.round(panelW * 0.22) : 0;

  return (
    <AbsoluteFill style={{ backgroundColor: "#0d0d0f", overflow: "hidden", fontFamily: FONT }}>
      {/* 16:9 IDE panel anchored upper-third */}
      <div
        style={{
          position: "absolute",
          top: panelTop,
          left: 0,
          width: panelW,
          height: panelH,
          opacity: chromeOp,
          transform: `scale(${chromeScale * zoomScale})`,
          transformOrigin: "50% 100%", // anchor zoom to composer (bottom)
        }}
      >
        <div
          style={{
            position: "absolute",
            inset: 0,
            background: "#1e1e1e",
            borderRadius: 16,
            overflow: "hidden",
            boxShadow: "0 30px 80px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.06)",
            border: "1px solid rgba(255,255,255,0.08)",
            display: "flex",
            flexDirection: "column",
          }}
        >
          {/* Title bar */}
          <div
            style={{
              height: 36,
              background: "linear-gradient(to bottom, #2a2a2a, #1f1f1f)",
              borderBottom: "1px solid rgba(255,255,255,0.06)",
              display: "flex",
              alignItems: "center",
              padding: "0 14px",
              gap: 8,
              flexShrink: 0,
            }}
          >
            <div style={{ width: 11, height: 11, borderRadius: 6, background: "#ff5f57" }} />
            <div style={{ width: 11, height: 11, borderRadius: 6, background: "#febc2e" }} />
            <div style={{ width: 11, height: 11, borderRadius: 6, background: "#28c840" }} />
            <div style={{ flex: 1, textAlign: "center", fontFamily: MONO, fontSize: 14, color: "rgba(255,255,255,0.55)" }}>
              {windowTitle}
            </div>
            <div style={{ width: 50 }} />
          </div>

          {/* Main row: sidebar + (tabs + body + composer) */}
          <div style={{ flex: 1, display: "flex", overflow: "hidden", minHeight: 0 }}>
            {/* Sidebar */}
            {showSidebar && (
              <Sidebar width={sidebarW} tree={tree} />
            )}

            {/* Right column */}
            <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "#1e1e1e" }}>
              {/* Tab bar */}
              <div
                style={{
                  display: "flex",
                  background: "#1f1f1f",
                  borderBottom: "1px solid rgba(255,255,255,0.06)",
                  height: 30,
                  flexShrink: 0,
                }}
              >
                {tabList.slice(0, 4).map((tab, i) => {
                  const active = tab.active || (i === Math.min(2, tabList.length - 1) && !tabList.some(x => x.active));
                  return (
                    <div
                      key={i}
                      style={{
                        display: "flex",
                        alignItems: "center",
                        gap: 6,
                        padding: "0 12px",
                        background: active ? "#1e1e1e" : "transparent",
                        borderRight: "1px solid rgba(255,255,255,0.05)",
                        borderTop: active ? `2px solid ${ACCENT}` : "2px solid transparent",
                        fontSize: 12,
                        color: active ? "#d4d4d4" : "rgba(255,255,255,0.45)",
                        fontFamily: MONO,
                        maxWidth: 200,
                        whiteSpace: "nowrap",
                        overflow: "hidden",
                        textOverflow: "ellipsis",
                      }}
                    >
                      <span style={{ width: 7, height: 7, borderRadius: 2, background: ACCENT, flexShrink: 0 }} />
                      {tab.label}
                      {active && <span style={{ marginLeft: 4, color: "rgba(255,255,255,0.45)" }}>×</span>}
                    </div>
                  );
                })}
                <div style={{ flex: 1 }} />
              </div>

              {/* Body */}
              <div
                style={{
                  flex: 1,
                  padding: "16px 22px 12px 22px",
                  color: "#d4d4d4",
                  display: "flex",
                  flexDirection: "column",
                  gap: 12,
                  overflow: "hidden",
                  fontSize: 14,
                  lineHeight: 1.45,
                  minHeight: 0,
                }}
              >
                {mode === "conversation" ? (
                  <ConversationBody
                    userMessage={userMessage}
                    userOp={userOp}
                    userY={userY}
                    thoughtSeconds={thoughtSeconds}
                    thoughtOp={thoughtOp}
                    aiHeading={aiHeading}
                    headingOp={headingOp}
                    headingY={headingY}
                    streamed={streamed}
                    cursorBlink={cursorBlink}
                  />
                ) : (
                  // Typing-mode body: keep it sparse so the composer is the focus
                  <div style={{ opacity: 0.5, color: "rgba(255,255,255,0.35)", fontSize: 13 }}>
                    No conversation yet — type a prompt to begin.
                  </div>
                )}
              </div>

              {/* Composer */}
              <Composer
                placeholder={placeholder}
                bypassLabel={bypassLabel}
                typedText={typedText}
                typingCaret={typingCaret}
                mode={mode}
                clickFlash={mode === "typing" ? interpolate(frame, [T_CLICK_F - 2, T_CLICK_F + 4, T_CLICK_F + 12], [0, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }) : 0}
              />
            </div>
          </div>
        </div>
      </div>

      {/* Typing-mode cursor — drawn ABOVE the IDE in screen space so it isn't
          affected by the panel's zoom transform. */}
      {mode === "typing" && (
        <CursorOverlay
          frame={frame}
          fps={fps}
          enterFrame={T_CURSOR_IN_F}
          arriveFrame={T_CURSOR_AT_F}
          clickFrame={T_CLICK_F}
          fadeOutFrame={T_ZOOM_END_F + 4}
          panelTop={panelTop}
          panelW={panelW}
          panelH={panelH}
          sidebarW={sidebarW}
        />
      )}
    </AbsoluteFill>
  );
};

// ─── Sidebar ─────────────────────────────────────────────────────────────────

const Sidebar: React.FC<{ width: number; tree: FileRow[] }> = ({ width, tree }) => (
  <div
    style={{
      width,
      background: "#181818",
      borderRight: "1px solid rgba(255,255,255,0.06)",
      padding: "10px 0",
      display: "flex",
      flexDirection: "column",
      flexShrink: 0,
      overflow: "hidden",
    }}
  >
    <div
      style={{
        fontSize: 11, color: "rgba(255,255,255,0.55)",
        fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase",
        padding: "0 14px 8px 14px", fontFamily: FONT,
      }}
    >
      Explorer
    </div>
    {tree.map((row, i) => (
      <FileTreeRow key={i} row={row} />
    ))}
  </div>
);

const FileTreeRow: React.FC<{ row: FileRow }> = ({ row }) => {
  const isFolder = row.kind === "folder";
  const indent = (row.depth ?? 0) * 14;
  const color = row.active
    ? "#ffffff"
    : isFolder
      ? "rgba(255,255,255,0.78)"
      : "rgba(255,255,255,0.65)";
  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        gap: 6,
        padding: `3px 10px 3px ${10 + indent}px`,
        background: row.active ? "rgba(255,255,255,0.06)" : "transparent",
        borderLeft: row.active ? `2px solid ${ACCENT}` : "2px solid transparent",
        fontFamily: MONO,
        fontSize: 12,
        color,
      }}
    >
      {isFolder && (
        <span style={{ width: 10, color: "rgba(255,255,255,0.5)", textAlign: "center", fontSize: 10 }}>
          {row.open ? "▾" : "▸"}
        </span>
      )}
      {!isFolder && (
        <span
          style={{
            width: 10, height: 10, borderRadius: 2,
            background: EXT_COLOR[row.ext || "ts"] || "#9ca3af",
            flexShrink: 0,
          }}
        />
      )}
      <span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
        {row.name}
      </span>
    </div>
  );
};

// ─── Conversation body ──────────────────────────────────────────────────────

const ConversationBody: React.FC<{
  userMessage: string;
  userOp: number;
  userY: number;
  thoughtSeconds?: number;
  thoughtOp: number;
  aiHeading?: string;
  headingOp: number;
  headingY: number;
  streamed: string;
  cursorBlink: boolean;
}> = ({ userMessage, userOp, userY, thoughtSeconds, thoughtOp, aiHeading, headingOp, headingY, streamed, cursorBlink }) => (
  <>
    {/* User message */}
    <div
      style={{
        opacity: userOp,
        transform: `translateY(${userY}px)`,
        border: "1px solid rgba(255,255,255,0.10)",
        borderRadius: 8,
        background: "#252525",
        padding: "10px 12px",
        fontSize: 14,
        color: "#d4d4d4",
        lineHeight: 1.4,
      }}
    >
      {userMessage}
    </div>

    {/* AI block */}
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      {thoughtSeconds != null && (
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 6,
            opacity: thoughtOp,
            fontSize: 11,
            color: "rgba(255,255,255,0.55)",
          }}
        >
          <span style={{ width: 6, height: 6, borderRadius: "50%", background: "rgba(255,255,255,0.35)" }} />
          Thought for {thoughtSeconds}s
        </div>
      )}
      {aiHeading && (
        <div
          style={{
            fontWeight: 800,
            fontSize: 14,
            letterSpacing: 0.4,
            color: "#ffffff",
            opacity: headingOp,
            transform: `translateY(${headingY}px)`,
          }}
        >
          {aiHeading}
        </div>
      )}
      <div style={{ fontSize: 14, color: "#d4d4d4", lineHeight: 1.5 }}>
        {parseSimpleMarkdown(streamed)}
        {cursorBlink && <span style={{ color: ACCENT, marginLeft: 2 }}>▊</span>}
      </div>
    </div>
  </>
);

// ─── Composer ────────────────────────────────────────────────────────────────

const Composer: React.FC<{
  placeholder: string;
  bypassLabel: string;
  typedText: string;
  typingCaret: boolean;
  mode: ChatMode;
  clickFlash: number;
}> = ({ placeholder, bypassLabel, typedText, typingCaret, mode, clickFlash }) => (
  <div
    style={{
      borderTop: "1px solid rgba(255,255,255,0.06)",
      padding: "10px 14px 12px 14px",
      display: "flex",
      flexDirection: "column",
      gap: 8,
      flexShrink: 0,
      position: "relative",
    }}
  >
    {/* Click flash ring */}
    {clickFlash > 0 && (
      <div
        style={{
          position: "absolute",
          inset: 6,
          borderRadius: 14,
          border: `2px solid ${ACCENT}`,
          opacity: clickFlash,
          pointerEvents: "none",
        }}
      />
    )}
    <div
      style={{
        border: `1px solid ${ACCENT}77`,
        boxShadow: `0 0 0 2px ${ACCENT}22 inset`,
        borderRadius: 12,
        padding: "12px 14px",
        fontSize: 13,
        color: typedText ? "#ffffff" : "rgba(255,255,255,0.45)",
        minHeight: 18,
        background: "rgba(255,255,255,0.03)",
      }}
    >
      {mode === "typing" && typedText ? (
        <>
          {typedText}
          {typingCaret && <span style={{ color: ACCENT, marginLeft: 1 }}>▊</span>}
        </>
      ) : (
        placeholder
      )}
    </div>
    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
      <CornerBtn glyph="+" />
      <CornerBtn glyph="/" />
      <div style={{ flex: 1 }} />
      <div
        style={{
          fontSize: 11,
          color: "rgba(255,255,255,0.55)",
          background: "rgba(255,255,255,0.06)",
          border: "1px solid rgba(255,255,255,0.10)",
          borderRadius: 6,
          padding: "5px 10px",
          display: "flex",
          alignItems: "center",
          gap: 6,
        }}
      >
        <span style={{ width: 8, height: 8, borderRadius: "50%", background: ACCENT }} />
        {bypassLabel}
      </div>
      <div
        style={{
          width: 28, height: 28,
          borderRadius: 6,
          background: `${ACCENT}33`,
          border: `1px solid ${ACCENT}88`,
          color: ACCENT,
          display: "flex", alignItems: "center", justifyContent: "center",
          fontSize: 14,
        }}
      >
        ↑
      </div>
    </div>
  </div>
);

const CornerBtn: React.FC<{ glyph: string }> = ({ glyph }) => (
  <div
    style={{
      width: 26, height: 26, borderRadius: 6,
      border: "1px solid rgba(255,255,255,0.10)",
      background: "rgba(255,255,255,0.04)",
      color: "rgba(255,255,255,0.55)",
      display: "flex", alignItems: "center", justifyContent: "center",
      fontSize: 14,
    }}
  >
    {glyph}
  </div>
);

// ─── Cursor overlay ──────────────────────────────────────────────────────────

const CursorOverlay: React.FC<{
  frame: number;
  fps: number;
  enterFrame: number;
  arriveFrame: number;
  clickFrame: number;
  fadeOutFrame: number;
  panelTop: number;
  panelW: number;
  panelH: number;
  sidebarW: number;
}> = ({ frame, enterFrame, arriveFrame, clickFrame, fadeOutFrame, panelTop, panelW, panelH, sidebarW }) => {
  // Composer sits at the bottom of the panel; aim for ~60% width of the
  // composer pill, ~44px from the panel bottom.
  const composerCenterX = sidebarW + (panelW - sidebarW) * 0.45;
  const composerCenterY = panelTop + panelH - 60;
  const startX = panelW + 80;
  const startY = panelTop + panelH * 0.35;

  const t = interpolate(frame, [enterFrame, arriveFrame], [0, 1], {
    extrapolateLeft: "clamp", extrapolateRight: "clamp",
    easing: Easing.inOut(Easing.cubic),
  });
  const x = startX + (composerCenterX - startX) * t;
  const y = startY + (composerCenterY - startY) * t;

  // Click squish at click frame
  const clickT = interpolate(frame, [clickFrame - 2, clickFrame, clickFrame + 6], [1, 0.82, 1], {
    extrapolateLeft: "clamp", extrapolateRight: "clamp",
  });

  const opIn = interpolate(frame, [enterFrame - 2, enterFrame + 4], [0, 1], { extrapolateRight: "clamp" });
  const opOut = interpolate(frame, [fadeOutFrame, fadeOutFrame + 8], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const op = Math.min(opIn, opOut);

  return (
    <div
      style={{
        position: "absolute",
        left: x - 16,
        top: y - 8,
        opacity: op,
        transform: `scale(${clickT})`,
        transformOrigin: "8px 4px",
        pointerEvents: "none",
        filter: "drop-shadow(0 4px 8px rgba(0,0,0,0.45))",
      }}
    >
      {/* Mac-style arrow cursor */}
      <svg width="32" height="44" viewBox="0 0 32 44" fill="none">
        <path
          d="M3 2 L3 32 L11 24 L16 38 L21 36 L16 22 L26 22 Z"
          fill="#ffffff"
          stroke="#000000"
          strokeWidth="1.4"
          strokeLinejoin="round"
        />
      </svg>
    </div>
  );
};

// ─── Markdown helpers ───────────────────────────────────────────────────────

function parseSimpleMarkdown(text: string): React.ReactNode {
  if (!text) return null;
  const lines = text.split("\n");
  const blocks: React.ReactNode[] = [];
  let bullets: string[] = [];
  const flushBullets = () => {
    if (!bullets.length) return;
    blocks.push(
      <ul key={`b${blocks.length}`} style={{ margin: "4px 0 0 0", paddingLeft: 20 }}>
        {bullets.map((b, i) => (
          <li key={i} style={{ marginBottom: 3 }}>{renderInline(b)}</li>
        ))}
      </ul>,
    );
    bullets = [];
  };
  for (const raw of lines) {
    const line = raw.trimEnd();
    const m = line.match(/^\s*[-*]\s+(.*)$/);
    if (m) {
      bullets.push(m[1]);
    } else {
      flushBullets();
      if (line) {
        blocks.push(
          <div key={`p${blocks.length}`} style={{ marginBottom: 4 }}>{renderInline(line)}</div>,
        );
      } else {
        blocks.push(<div key={`g${blocks.length}`} style={{ height: 4 }} />);
      }
    }
  }
  flushBullets();
  return blocks;
}

function renderInline(text: string): React.ReactNode {
  const parts = text.split(/(\*\*[^*]+\*\*)/g);
  return parts.map((p, i) => {
    if (p.startsWith("**") && p.endsWith("**")) {
      return <strong key={i} style={{ color: "#ffffff" }}>{p.slice(2, -2)}</strong>;
    }
    return <span key={i}>{p}</span>;
  });
}

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: ClaudeCodeChat
// 13 props detected · 3 port(s)

Claude Code Chat

claude_code_chat

Claude Code Chat — Andy's hand-crafted Remotion scene. 13 props. userMessage:TEXT, aiResponse:TEXT, windowTitle:TEXT.

Workflow Inputs (3)

  • User Message
    userMessage
    TEXTRequired
  • Ai Response
    aiResponse
    TEXTRequired
  • Window Title
    windowTitle
    TEXT

Config Fields (10)

  • mode
  • tabs
  • fileTree
  • aiHeading
  • typingCps
  • bypassLabel
  • placeholder
  • showSidebar
  • thoughtSeconds
  • backgroundColor

Meta

Updated
4/21/2026