Catalog

Edit Props (live)

TEXT
IMAGE
config
config
config
config
config
config
config
config
config

Source

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

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

interface PromptResponseProps {
  userPrompt: string;
  aiResponse: string;
  userName?: string;             // "You"
  aiName?: string;               // "Claude" / "ChatGPT"
  aiAvatar?: string;             // (unused — Claude sun mark drawn inline)
  aiAccent?: string;             // accent for the send button + spark icon
  typingCps?: number;            // chars per second for streamed reply (default 110)
  backgroundColor?: string;      // chat surface color (default Claude cream)
  toolLabel?: string;            // text inside the tool-call pill (default "Fetching page, Writing")
  bubbleUserBg?: string;         // accepted for back-compat, ignored in new layout
  bubbleAiBg?: string;           // accepted for back-compat, ignored in new layout
}

const FONT = "'Inter', sans-serif";

// Approximation of the actual Claude product UI: cream surface, "Chat / Comments"
// tab header, plain "You" + "Claude" labelled paragraphs, a tool-call pill with the
// orange spark mark, and a bottom composer with the orange send button.
export const PromptResponse: React.FC<PromptResponseProps> = ({
  userPrompt,
  aiResponse,
  userName = "You",
  aiName = "Claude",
  aiAccent = "#D97757",
  typingCps = 110,
  backgroundColor,  // accepted but ignored — we always render the Claude cream surface for visual consistency
  toolLabel = "Fetching page, Writing",
}) => {
  void backgroundColor;
  const surfaceBg = "#F5F0E8";
  const outerBg = "#E5E0D6";
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Sequence:
  //   0–6f  : surface fade-in
  //   2f+   : "You" block springs in
  //  10f+   : "Claude" label appears, tool pill slides in
  //  14f+   : reply text streams character-by-character
  const surfaceOp = interpolate(frame, [0, 6], [0, 1], { extrapolateRight: "clamp" });

  const youSpring = spring({ frame: frame - 2, fps, config: { mass: 0.6, damping: 14, stiffness: 220 } });
  const youOp = interpolate(youSpring, [0, 1], [0, 1]);
  const youY = interpolate(youSpring, [0, 1], [22, 0]);

  const claudeOp = interpolate(frame, [10, 16], [0, 1], { extrapolateRight: "clamp" });
  const claudeY = interpolate(frame, [10, 16], [16, 0], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  const replyStart = 14;
  const revealCount = Math.max(
    0,
    Math.floor(((frame - replyStart) / fps) * typingCps),
  );
  const streamedReply = aiResponse.slice(0, revealCount);
  const isStreaming = revealCount < aiResponse.length;
  const cursorBlink = isStreaming && frame % 18 < 9;

  // Lines starting with "- " or "* " render as bullets; everything else is a paragraph.
  const renderedBlocks = parseSimpleMarkdown(streamedReply);

  return (
    <AbsoluteFill style={{ backgroundColor: outerBg, overflow: "hidden", fontFamily: FONT }}>
      {/* Chat surface — vertically biased UPWARD so the captions don't cover it. */}
      <div
        style={{
          position: "absolute",
          left: 36,
          right: 36,
          top: 64,
          bottom: 360, // leave generous room at bottom for kinetic captions
          background: surfaceBg,
          borderRadius: 28,
          boxShadow: "0 36px 80px rgba(0,0,0,0.18), 0 8px 24px rgba(0,0,0,0.10)",
          opacity: surfaceOp,
          overflow: "hidden",
          display: "flex",
          flexDirection: "column",
        }}
      >
        {/* Tab header */}
        <ChatHeader />

        {/* Conversation body */}
        <div
          style={{
            flex: 1,
            padding: "26px 38px 18px 38px",
            display: "flex",
            flexDirection: "column",
            gap: 24,
            overflow: "hidden",
          }}
        >
          {/* You */}
          <div style={{ opacity: youOp, transform: `translateY(${youY}px)` }}>
            <Label>{userName}</Label>
            <div style={messageStyle}>{userPrompt}</div>
          </div>

          {/* Claude */}
          <div style={{ opacity: claudeOp, transform: `translateY(${claudeY}px)` }}>
            <Label>{aiName}</Label>
            <ToolPill label={toolLabel} accent={aiAccent} frame={frame} fps={fps} />
            <div style={{ ...messageStyle, marginTop: 14 }}>
              {renderedBlocks}
              {cursorBlink && <span style={{ color: aiAccent, marginLeft: 2 }}>▊</span>}
            </div>
          </div>
        </div>

        {/* Composer */}
        <Composer accent={aiAccent} />
      </div>
    </AbsoluteFill>
  );
};

// ─── Subcomponents ───────────────────────────────────────────────────────────

const Label: React.FC<{ children: React.ReactNode }> = ({ children }) => (
  <div style={{ fontSize: 30, fontWeight: 700, color: "#1f1f1f", marginBottom: 10 }}>
    {children}
  </div>
);

const messageStyle: React.CSSProperties = {
  fontSize: 32,
  fontWeight: 400,
  color: "#1f1f1f",
  lineHeight: 1.42,
  letterSpacing: -0.2,
  whiteSpace: "pre-wrap",
};

const ChatHeader: React.FC = () => (
  <div
    style={{
      height: 76,
      display: "flex",
      alignItems: "stretch",
      borderBottom: "1px solid rgba(0,0,0,0.08)",
      paddingLeft: 24,
      paddingRight: 24,
      gap: 0,
    }}
  >
    <Tab label="Chat" active />
    <Tab label="Comments" />
    <div style={{ flex: 1 }} />
    <div
      style={{
        alignSelf: "center",
        width: 40, height: 40, borderRadius: 10,
        display: "flex", alignItems: "center", justifyContent: "center",
        color: "#777", fontSize: 26, fontWeight: 600,
      }}
    >
      +
    </div>
  </div>
);

const Tab: React.FC<{ label: string; active?: boolean }> = ({ label, active }) => (
  <div
    style={{
      display: "flex",
      alignItems: "center",
      padding: "0 22px",
      fontSize: 24,
      fontWeight: active ? 700 : 500,
      color: active ? "#1f1f1f" : "#888",
      borderBottom: active ? "3px solid #1f1f1f" : "3px solid transparent",
      marginBottom: -1,
    }}
  >
    {label}
  </div>
);

const ToolPill: React.FC<{ label: string; accent: string; frame: number; fps: number }> = ({
  label, accent, frame, fps,
}) => {
  const enter = spring({ frame: frame - 12, fps, config: { mass: 0.6, damping: 14, stiffness: 220 } });
  const op = interpolate(enter, [0, 1], [0, 1]);
  const x = interpolate(enter, [0, 1], [-30, 0]);
  return (
    <div
      style={{
        display: "inline-flex",
        alignItems: "center",
        gap: 12,
        background: "#FFFFFF",
        border: "1px solid rgba(0,0,0,0.08)",
        borderRadius: 14,
        padding: "12px 18px",
        opacity: op,
        transform: `translateX(${x}px)`,
        boxShadow: "0 1px 0 rgba(0,0,0,0.03)",
      }}
    >
      <ClaudeSpark color={accent} size={22} />
      <span style={{ fontSize: 24, color: "#333", fontWeight: 500 }}>{label}</span>
      <span style={{ fontSize: 22, color: "#888", marginLeft: 4 }}>⌄</span>
    </div>
  );
};

// Simplified Claude wordmark spark — orange sun-burst icon.
const ClaudeSpark: React.FC<{ color: string; size: number }> = ({ color, size }) => {
  const rays = 8;
  const cx = size / 2, cy = size / 2;
  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
      {Array.from({ length: rays }).map((_, i) => {
        const angle = (i / rays) * Math.PI * 2;
        const x1 = cx + Math.cos(angle) * (size * 0.18);
        const y1 = cy + Math.sin(angle) * (size * 0.18);
        const x2 = cx + Math.cos(angle) * (size * 0.46);
        const y2 = cy + Math.sin(angle) * (size * 0.46);
        return (
          <line
            key={i}
            x1={x1} y1={y1} x2={x2} y2={y2}
            stroke={color}
            strokeWidth={size * 0.12}
            strokeLinecap="round"
          />
        );
      })}
    </svg>
  );
};

const Composer: React.FC<{ accent: string }> = ({ accent }) => (
  <div
    style={{
      borderTop: "1px solid rgba(0,0,0,0.08)",
      padding: "18px 22px 22px 22px",
      display: "flex",
      flexDirection: "column",
      gap: 14,
    }}
  >
    <div
      style={{
        background: "#FFFFFF",
        border: "1px solid rgba(0,0,0,0.10)",
        borderRadius: 22,
        padding: "20px 24px",
        fontSize: 28,
        color: "#888",
        minHeight: 30,
      }}
    >
      Reply…
    </div>
    <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
      <CircleIcon glyph="⚙" />
      <CircleIcon glyph="📎" />
      <CircleIcon glyph="🎙" />
      <div style={{ flex: 1 }} />
      <div
        style={{
          background: accent,
          color: "#fff",
          fontSize: 24,
          fontWeight: 600,
          padding: "14px 28px",
          borderRadius: 999,
          display: "flex",
          alignItems: "center",
          gap: 8,
        }}
      >
        <span style={{ fontSize: 20 }}>▶</span> Send
      </div>
    </div>
  </div>
);

const CircleIcon: React.FC<{ glyph: string }> = ({ glyph }) => (
  <div
    style={{
      width: 48, height: 48, borderRadius: 12,
      border: "1px solid rgba(0,0,0,0.10)",
      display: "flex", alignItems: "center", justifyContent: "center",
      fontSize: 22, color: "#555",
      background: "#fff",
    }}
  >
    {glyph}
  </div>
);

// ─── Markdown helper ─────────────────────────────────────────────────────────

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

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: PromptResponse
// 11 props detected · 2 port(s)

Prompt Response

prompt_response

Prompt Response — Andy's hand-crafted Remotion scene. 11 props. aiResponse:TEXT, aiAvatar:IMAGE.

Workflow Inputs (2)

  • Ai Response
    aiResponse
    TEXTRequired
  • Ai Avatar
    aiAvatar
    IMAGE

Config Fields (9)

  • aiName
  • aiAccent
  • userName
  • toolLabel
  • typingCps
  • bubbleAiBg
  • userPrompt
  • bubbleUserBg
  • backgroundColor

Meta

Updated
4/21/2026