Skip to content
Blocks

Chat Workspace

A complete local chat journey with stop, retry, and history.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/chat-workspace.json

Use it

Scroll horizontally for long lines
"use client";
import {ChatWorkspace} from '@/components/jez-ui/blocks/chat-workspace';

export default function Example(){

return <><ChatWorkspace/></>;
}

Props & types

Scroll horizontally for long lines
{
  className?: string;
  conversationId?: string;
}

Source

View 5 source files

registry/blocks/chat-workspace.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "../ui/utils";
import {
  ArrowUp,
  Feather,
  Lightbulb,
  ListChecks,
  PenLine,
  RotateCcw,
} from "lucide-react";
import { Button } from "../ui/button";
import { Textarea } from "../ui/textarea";
import { useDemoState } from "./demo-state";
type Message = { id: string; role: "user" | "assistant"; content: string };
const initial: Message[] = [];
export function ChatWorkspace({
  className,
  conversationId = "default",
}: {
  className?: string;
  conversationId?: string;
}) {
  const [messages, setMessages, reset] = useDemoState(
    "chat-" + conversationId,
    initial,
  );
  const [draft, setDraft] = React.useState("");
  const [running, setRunning] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setInterval> | null>(null);
  React.useEffect(
    () => () => {
      if (timer.current) clearInterval(timer.current);
    },
    [],
  );
  function stop() {
    if (timer.current) clearInterval(timer.current);
    setRunning(false);
  }
  function respond(prompt: string, retry = false) {
    stop();
    const id = crypto.randomUUID();
    const answer = `Let’s give “${prompt}” a clearer shape.\n\n1. Define what a good outcome looks like in one sentence.\n2. Identify the decision that would unlock the most progress.\n3. Make a small version you can put in front of someone.\n\nStart with the outcome. What should someone be able to do when the work is finished?`;
    setMessages((m) => [
      ...(retry
        ? m.filter((_, i) => i !== m.length - 1)
        : [
            ...m,
            { id: crypto.randomUUID(), role: "user" as const, content: prompt },
          ]),
      { id, role: "assistant", content: "" },
    ]);
    let i = 0;
    setRunning(true);
    timer.current = setInterval(() => {
      i += 5;
      setMessages((m) =>
        m.map((x) => (x.id === id ? { ...x, content: answer.slice(0, i) } : x)),
      );
      if (i >= answer.length) stop();
    }, 24);
  }
  return (
    <section
      className={cn(
        "mx-auto flex min-h-[620px] max-w-3xl flex-col gap-5",
        className,
      )}
    >
      <div className="flex items-center justify-between gap-3">
        <span className="flex items-center gap-2 text-sm font-medium">
          <Feather size={17} />
          Margin{" "}
          <span className="font-normal text-muted-foreground">/ Personal</span>
        </span>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => {
            stop();
            reset();
          }}
        >
          New conversation
        </Button>
      </div>
      <div
        className="flex-1 space-y-5"
        role="log"
        aria-label="Conversation"
        aria-live={running ? "off" : "polite"}
      >
        {!messages.length && (
          <div className="mx-auto max-w-xl py-10 sm:py-16">
            <span className="mb-6 inline-flex rounded-xl border border-border p-3">
              <Feather size={25} strokeWidth={1.4} />
            </span>
            <h2 className="font-display text-3xl tracking-tight sm:text-4xl">
              What are we working on?
            </h2>
            <p className="mb-8 mt-3 text-sm leading-relaxed text-muted-foreground">
              A rough draft. A difficult decision. The beginning of something.
            </p>
            <div className="grid gap-2 sm:grid-cols-3">
              {[
                {
                  title: "Find the words",
                  prompt: "Help me explain a complicated idea clearly",
                  icon: PenLine,
                },
                {
                  title: "Make a plan",
                  prompt: "Break a website launch into practical steps",
                  icon: ListChecks,
                },
                {
                  title: "Think it through",
                  prompt: "Help me weigh the tradeoffs in a decision",
                  icon: Lightbulb,
                },
              ].map((item) => (
                <button
                  key={item.title}
                  onClick={() => setDraft(item.prompt)}
                  className="rounded-xl border border-border p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40"
                >
                  <item.icon size={18} className="mb-4 text-muted-foreground" />
                  <span className="text-sm font-medium">{item.title}</span>
                  <span className="mt-2 block text-xs leading-relaxed text-muted-foreground">
                    {item.prompt}
                  </span>
                </button>
              ))}
            </div>
          </div>
        )}
        {messages.map((m) => (
          <article
            key={m.id}
            className={cn(
              "max-w-[90%] whitespace-pre-wrap rounded-xl p-4 text-sm leading-relaxed",
              m.role === "user" ? "ml-auto bg-muted" : "",
            )}
          >
            <p className="mb-2 text-xs font-medium text-muted-foreground">
              {m.role === "user" ? "You" : "Demo assistant"}
            </p>
            {m.content || "Thinking…"}
          </article>
        ))}
      </div>
      {running ? (
        <Button variant="outline" className="self-start" onClick={stop}>
          Stop response
        </Button>
      ) : (
        messages.length > 0 && (
          <Button
            variant="ghost"
            className="self-start"
            onClick={() =>
              respond(
                [...messages].reverse().find((m) => m.role === "user")
                  ?.content ?? "Hello",
                true,
              )
            }
          >
            <RotateCcw size={14} />
            Retry response
          </Button>
        )
      )}
      <form
        className="flex items-end gap-2 rounded-2xl border border-border bg-background p-2 shadow-sm focus-within:border-primary/40 focus-within:ring-3 focus-within:ring-primary/8"
        onSubmit={(e) => {
          e.preventDefault();
          if (draft.trim()) {
            respond(draft.trim());
            setDraft("");
          }
        }}
      >
        <Textarea
          aria-label="Message"
          placeholder="What’s on your mind?"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          className="min-h-20 resize-none border-0 bg-transparent shadow-none focus-visible:ring-0"
          required
        />
        <Button
          type="submit"
          aria-label="Send"
          className="mb-1 mr-1 size-9 shrink-0 p-0"
          disabled={running || !draft.trim()}
        >
          <ArrowUp size={18} />
          <span className="sr-only">Send</span>
        </Button>
      </form>
      <p className="text-xs text-muted-foreground">
        Simulated responses. Conversations stay on this device.
      </p>
    </section>
  );
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

registry/ui/button.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Slot } from "radix-ui";
import { LoaderCircle } from "lucide-react";
import { cn } from "./utils";
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: "primary" | "secondary" | "outline" | "ghost" | "danger";
  size?: "sm" | "md" | "lg";
  loading?: boolean;
  asChild?: boolean;
};
export function Button({
  variant = "primary",
  size = "md",
  loading,
  asChild,
  className,
  children,
  disabled,
  ...props
}: ButtonProps) {
  const Comp = asChild ? Slot.Root : "button";
  return (
    <Comp
      type={asChild ? undefined : "button"}
      disabled={asChild ? undefined : disabled || loading}
      aria-disabled={disabled || loading || undefined}
      aria-busy={loading || undefined}
      className={cn(
        "relative inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium leading-none transition-[background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:scale-[.98] disabled:pointer-events-none disabled:opacity-45 aria-disabled:pointer-events-none [&_svg]:shrink-0",
        {
          "bg-primary text-primary-foreground shadow-[inset_0_1px_0_#ffffff26,0_1px_2px_#00000012] hover:bg-primary/90":
            variant === "primary",
          "border-border/60 bg-muted text-foreground hover:bg-muted/70":
            variant === "secondary",
          "border-border bg-background text-foreground shadow-sm hover:bg-muted/60":
            variant === "outline",
          "text-muted-foreground hover:bg-muted hover:text-foreground":
            variant === "ghost",
          "bg-danger text-danger-foreground hover:bg-danger/90":
            variant === "danger",
        },
        {
          "h-8 px-3 text-xs": size === "sm",
          "h-10 px-4": size === "md",
          "h-12 px-6": size === "lg",
        },
        className,
      )}
      {...props}
    >
      {asChild ? (
        children
      ) : (
        <>
          <span
            className={cn(
              "inline-flex min-w-0 flex-1 items-center justify-center gap-2",
              loading && "invisible",
            )}
          >
            {children}
          </span>
          {loading && (
            <LoaderCircle
              aria-hidden="true"
              className="absolute size-4 animate-spin motion-reduce:animate-none"
            />
          )}
        </>
      )}
    </Comp>
  );
}

registry/ui/textarea.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
export function Textarea({
  className,
  ...props
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
  return (
    <textarea
      className={cn(
        "block min-h-32 w-full min-w-0 resize-y rounded-lg border border-border bg-background px-3.5 py-3 text-sm leading-relaxed shadow-[0_1px_2px_#00000004] transition-[border-color,box-shadow] placeholder:text-muted-foreground/70 hover:border-foreground/25 focus:border-primary focus:outline-none focus:ring-3 focus:ring-primary/10 aria-invalid:border-danger disabled:bg-muted/50 disabled:opacity-50",
        className,
      )}
      {...props}
    />
  );
}

registry/blocks/demo-state.ts

Scroll horizontally for long lines
"use client";
import { useState, useEffect, useCallback } from "react";
export function useDemoState<T>(key: string, initial: T) {
  const [state, setState] = useState(initial);
  const [loaded, setLoaded] = useState(false);
  useEffect(() => {
    try {
      const raw = localStorage.getItem("jez-demo:" + key);
      if (raw) setState(JSON.parse(raw));
    } catch {}
    setLoaded(true);
  }, [key]);
  useEffect(() => {
    if (loaded)
      try {
        localStorage.setItem("jez-demo:" + key, JSON.stringify(state));
      } catch {}
  }, [key, state, loaded]);
  const reset = useCallback(() => setState(initial), [initial]);
  return [state, setState, reset] as const;
}

Dependencies

lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0 · radix-ui@1.6.7

Accessibility & behaviour

Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.

Read the accessibility and performance guide →