Revert "feat(integrations): Intelligence threads — north-star foundation + batch 1 (7 examples on 1.59.1) [ENT-679] (#5151)"

This reverts commit f3ec5ddcec, reversing
changes made to be20a389cf.

# Conflicts:
#	examples/integrations/adk/src/app/layout.tsx
#	examples/integrations/adk/src/app/page.tsx
#	examples/integrations/agno/src/app/layout.tsx
#	examples/integrations/agno/src/app/page.tsx
#	examples/integrations/crewai-crews/src/app/layout.tsx
#	examples/integrations/llamaindex/src/app/api/copilotkit/[[...slug]]/route.ts
#	examples/integrations/llamaindex/src/app/layout.tsx
#	examples/integrations/llamaindex/src/app/page.tsx
#	examples/integrations/mastra/src/app/layout.tsx
#	examples/integrations/mastra/src/app/page.tsx
#	examples/integrations/ms-agent-framework-dotnet/src/app/layout.tsx
#	examples/integrations/ms-agent-framework-dotnet/src/app/page.tsx
#	examples/integrations/ms-agent-framework-python/src/app/layout.tsx
#	examples/integrations/ms-agent-framework-python/src/app/page.tsx
#	examples/integrations/pydantic-ai/src/app/layout.tsx
This commit is contained in:
Benjamin Taylor
2026-06-03 20:46:47 -05:00
parent 1ef370440b
commit 3721e7b36b
163 changed files with 1254 additions and 163732 deletions
@@ -1,5 +0,0 @@
# Appended by `copilotkit add-intelligence`
COPILOTKIT_LICENSE_TOKEN=
INTELLIGENCE_API_URL=http://localhost:4201
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
INTELLIGENCE_API_KEY=cpk_sPRVSEED_seed0privat0longtoken00
@@ -1,47 +0,0 @@
# `_intelligence/` — CopilotKit Intelligence Activation Overlay
This directory is the **framework-agnostic** overlay consumed by `copilotkit init -i` and
`copilotkit add-intelligence`. It contains everything needed to run the CopilotKit Intelligence
stack locally, independent of which framework template your project uses.
## Assets
### `docker-compose.yml`
Starts three services:
- **postgres** — relational store used by the Intelligence runtime
- **redis** — cache and pub/sub broker
- **`ghcr.io/copilotkit/intelligence/composite`** — the all-in-one Intelligence container (app-api
on 4201, realtime-gateway on 4401, thread-culler, and a db-migrations oneshot)
Bring the stack up with:
```bash
docker compose up -d --wait
```
### `.env.intelligence`
A fragment of environment variables appended to your project's `.env` when you run
`copilotkit add-intelligence`. It wires the scaffolded app to the local stack:
```
COPILOTKIT_LICENSE_TOKEN=
INTELLIGENCE_API_URL=http://localhost:4201
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
INTELLIGENCE_API_KEY=cpk_sPRVSEED_seed0privat0longtoken00
```
`INTELLIGENCE_API_KEY` is pre-seeded with the local-dev value the bundled composite
container expects. To activate Intelligence, set `COPILOTKIT_LICENSE_TOKEN` (server-side
secret, from your CopilotKit dashboard); each base template's runtime wires Intelligence
from that token (see the per-framework dormant wiring below). The stack runs locally once
the token is set.
## Framework independence
This overlay is **not tied to any specific framework template**. The per-framework dormant
runtime wiring (e.g. the `CopilotRuntime` provider, route handler, and hook configuration)
lives in each base template. The overlay only supplies the Docker stack and the env-key
fragment that activates it.
@@ -1,122 +0,0 @@
# Framework-agnostic CopilotKit Intelligence activation overlay.
#
# Starts postgres, redis, and the all-in-one intelligence composite
# container (runs app-api, realtime-gateway, thread-culler, and the
# db-migrations oneshot internally under s6-overlay).
#
# Usage:
# docker compose up -d --wait
#
# Then start the app services on the host:
# npm run dev
name: copilotkit-intelligence
services:
# ---------------------------------------------------------------------------
# Infrastructure
# ---------------------------------------------------------------------------
postgres:
image: postgres:16-alpine
ports:
- "${POSTGRES_HOST_PORT:-5432}:5432"
environment:
POSTGRES_USER: intelligence
POSTGRES_PASSWORD: intelligence
POSTGRES_DB: postgres
volumes:
- postgres-data:/var/lib/postgresql/data
- ./docker/init-db:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U intelligence -d postgres"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- intelligence
redis:
image: redis:7-alpine
ports:
- "${REDIS_HOST_PORT:-6379}:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
restart: unless-stopped
networks:
- intelligence
# ---------------------------------------------------------------------------
# Intelligence composite (app-api + realtime-gateway + thread-culler +
# db-migrations, all in one container under s6-overlay)
# ---------------------------------------------------------------------------
intelligence:
image: ghcr.io/copilotkit/intelligence/composite:0.2.0
ports:
- "${APP_API_HOST_PORT:-4201}:4201"
- "${REALTIME_GATEWAY_HOST_PORT:-4401}:4401"
environment:
DATABASE_URL: postgresql://intelligence:intelligence@postgres:5432/intelligence_app
REDIS_URL: redis://redis:6379
AUTH_SECRET: local-dev-secret-must-be-at-least-32-chars
RUNNER_AUTH_SECRET: dev-runner-secret
SECRET_KEY_BASE: local-realtime-gateway-secret-key-base-at-least-64-bytes-long-for-dev
DEFAULT_ORGANIZATION_ID: casa-de-erlang
# Local dev: the web app runs on http://localhost:<port>, not the gateway's
# configured https://localhost, so disable Phoenix origin checking on the
# realtime-gateway websocket (otherwise client WS connects get a 403).
CHECK_ORIGIN: "false"
COPILOTKIT_LICENSE_TOKEN: "${COPILOTKIT_LICENSE_TOKEN:-}"
THREAD_STALE_HOURS: "${THREAD_STALE_HOURS:-3}"
THREAD_CULL_BATCH_SIZE: "${THREAD_CULL_BATCH_SIZE:-1000}"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
networks:
- intelligence
# ---------------------------------------------------------------------------
# Demo user provisioning (one-shot)
# ---------------------------------------------------------------------------
# Ensures the runtime's identifyUser id (COPILOTKIT_DEMO_USER_ID, default
# "demo-user") exists in cpki.users so created threads can attach to it.
# Idempotent; mirrors the two-row pattern the composite's startup seed uses:
# a bare id (membership checks) plus a per-project scoped alias
# "<projectId>_<userId>" (the threads_user_id_fkey target). Runs once after the
# composite is healthy (schema + org/projects seeded), then exits.
provision-user:
image: postgres:16-alpine
depends_on:
intelligence:
condition: service_healthy
environment:
PGPASSWORD: intelligence
DEMO_USER_ID: "${COPILOTKIT_DEMO_USER_ID:-demo-user}"
ORG_ID: "${COPILOTKIT_ORGANIZATION_ID:-casa-de-erlang}"
entrypoint: ["sh", "-c"]
command:
- |
psql -h postgres -U intelligence -d intelligence_app -v ON_ERROR_STOP=1 \
-c "INSERT INTO cpki.users (id, organization_id) VALUES ('$$DEMO_USER_ID', '$$ORG_ID') ON CONFLICT (id) DO NOTHING;" \
-c "INSERT INTO cpki.users (id, organization_id) SELECT p.id || '_' || '$$DEMO_USER_ID', '$$ORG_ID' FROM cpki.projects p WHERE p.organization_id = '$$ORG_ID' AND p.deleted_at IS NULL ON CONFLICT (id) DO NOTHING;"
restart: "no"
networks:
- intelligence
volumes:
postgres-data:
redis-data:
networks:
intelligence:
driver: bridge
@@ -1,2 +0,0 @@
CREATE DATABASE intelligence_app;
CREATE DATABASE intelligence_app_shadow;
+3 -16
View File
@@ -33,10 +33,6 @@
"src/components/generative-ui/meeting-time-picker.tsx",
"src/components/headless-chat.tsx",
"src/components/tool-rendering.tsx",
"src/components/threads-drawer/index.tsx",
"src/components/threads-drawer/locked-state.tsx",
"src/components/threads-drawer/threads-drawer.module.css",
"src/components/threads-drawer/threads-drawer.tsx",
"src/components/ui/badge.tsx",
"src/components/ui/button.tsx",
"src/components/ui/card.tsx",
@@ -130,10 +126,7 @@
"docker-compose.test.yml",
"entrypoint.sh",
"serve.py",
"scripts/**",
"src/components/threads-drawer/**",
"src/app/page.tsx",
"next.config.ts"
"scripts/**"
],
"packageJsonOverrides": {
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\\\run-agent.bat",
@@ -151,10 +144,7 @@
"src/app/api/copilotkit/**",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"scripts/**",
"src/components/threads-drawer/**",
"src/app/page.tsx",
"next.config.ts"
"scripts/**"
],
"packageJsonOverrides": {
"scripts.dev:agent": "cd agent && uv run main.py",
@@ -174,10 +164,7 @@
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"serve.py",
"scripts/**",
"src/components/threads-drawer/**",
"src/app/page.tsx",
"next.config.ts"
"scripts/**"
],
"packageJsonOverrides": {
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\\\run-agent.bat",
-6
View File
@@ -1,8 +1,2 @@
GOOGLE_API_KEY=your-api-key-here
AGENT_URL=http://localhost:8000
# --- CopilotKit Intelligence (optional; set COPILOTKIT_LICENSE_TOKEN to enable Threads — server + UI) ---
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
# INTELLIGENCE_API_KEY= # local dev: see examples/integrations/_intelligence/.env.intelligence for the seed value
-13
View File
@@ -2,19 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@copilotkit/runtime"],
env: {
// The public Threads UI flag is DERIVED from the server-side license token.
// Set COPILOTKIT_LICENSE_TOKEN (only) to enable Threads — do not set this flag
// directly. NOTE: NEXT_PUBLIC_* resolves at BUILD time while the runtime reads
// the token per-request, so the UI gate and runtime agree only when the token is
// present at build time (the standard `next dev` / host-build flow). For a
// standalone/Docker image built without the token and injected at runtime, set
// COPILOTKIT_LICENSE_TOKEN at build time too (or gate the UI at runtime) so the
// baked flag reflects it.
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
typescript: {
// HttpAgent type mismatch with CopilotRuntime — pending upstream fix in @copilotkit/runtime
ignoreBuildErrors: true,
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -14,18 +14,14 @@
},
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/react-core": "1.56.4",
"@copilotkit/runtime": "1.56.4",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4",
"lucide-react": "^0.577.0",
"next": "16.1.1",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"shiki": "^3.19.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.24.4"
},
"devDependencies": {
@@ -1,6 +1,5 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
@@ -9,26 +8,11 @@ import { handle } from "hono/vercel";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
my_agent: new HttpAgent({
url: process.env.AGENT_URL || "http://localhost:8000/",
}),
},
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your real auth-derived user identity before any
// multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
runner: new InMemoryAgentRunner(),
});
const app = createCopilotEndpoint({
@@ -38,5 +22,3 @@ const app = createCopilotEndpoint({
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -22,66 +22,3 @@ body,
html {
height: 100%;
}
/*
* Threads panel theme — matched to adk's CopilotKit chat (CopilotSidebar /
* CopilotChat v2). That chat renders in the framework's default LIGHT look:
* white surfaces, soft gray-200 borders, gray-500/600 muted text, a system
* sans typeface, and a ~1rem rounded panel with smaller radii on controls.
*
* The threads panel is FORCED LIGHT on this `.threads-theme` wrapper so it
* reads as part of the same kit as the chat even when the OS is in dark mode
* (adk's chat is always light). These tokens drive the themeable drawer base
* + ui/card + ui/button (see threads-drawer/THEME.md) — never edit the drawer
* files; theme them here.
*/
.threads-theme {
color-scheme: light;
/* Surfaces — white chat-panel surface on a faint gray rail */
--background: #ffffff;
--foreground: #171717;
--card: #ffffff;
--card-foreground: #171717;
/* Borders / dividers — Tailwind gray-200, the chat's hairline color */
--border: #e5e7eb;
--input: #e5e7eb;
/* Primary action (New thread / dialog confirm) — neutral ink, like the
chat's send affordance, so the panel doesn't fight the page theme color */
--primary: #171717;
--primary-foreground: #ffffff;
/* Hover / track / skeleton — Tailwind gray-100 */
--secondary: #f3f4f6;
--secondary-foreground: #171717;
--muted: #f3f4f6;
/* Muted text — Tailwind gray-500 */
--muted-foreground: #6b7280;
/* Selected thread row — faint gray-50 wash */
--accent: #f9fafb;
--accent-foreground: #171717;
/* Focus ring — soft neutral, matching the chat's understated focus */
--ring: #9ca3af;
/* Destructive (delete) */
--destructive: #ef4444;
--destructive-foreground: #ffffff;
/* Radius — chat panel uses 1rem; controls inside the drawer derive smaller
radii from this token */
--radius: 0.75rem;
/* Typography — match the chat's system sans stack */
--font-body:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
/* Drawer-specific visuals: keep the surface flat to match the chat's quiet
panel rather than the north-star's pronounced drop shadow */
--threads-drawer-shadow: 1px 0 0 var(--border);
}
+1 -5
View File
@@ -17,11 +17,7 @@ export default function RootLayout({
return (
<html lang="en">
<body className={"antialiased"}>
{/* Force REST transport so runtime-info + threads both hit the multi-route endpoint (auto-detect races the lazily-compiled API route in next dev). */}
<CopilotKit
runtimeUrl="/api/copilotkit"
useSingleEndpoint={false}
>
<CopilotKit runtimeUrl="/api/copilotkit" agent="my_agent">
{children}
</CopilotKit>
</body>
+41 -69
View File
@@ -2,8 +2,6 @@
import { ProverbsCard } from "@/components/proverbs";
import { WeatherCard } from "@/components/weather";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import { AgentState } from "@/lib/types";
import {
useAgent,
@@ -12,20 +10,12 @@ import {
useHumanInTheLoop,
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import React, { useState } from "react";
import { z } from "zod";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
// The agent key registered in the runtime route (`agents: { default: ... }`)
// and the id passed to `useAgent({ agentId: "default" })` below.
const AGENT_ID = "default";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
// 🪁 Frontend Actions: https://docs.copilotkit.ai/adk/frontend-actions
useFrontendTool({
@@ -41,71 +31,53 @@ export default function CopilotKitPage() {
});
return (
// Share the active threadId with the chat + agent. `useAgent()` and the
// CopilotSidebar fall back to this provider's threadId when called without
// an explicit one, so selecting a thread in the drawer drives the chat.
<CopilotChatConfigurationProvider agentId={AGENT_ID} threadId={threadId}>
<div className={styles.layout}>
{/* In-flow left threads panel, themed to match adk's chat (see globals.css). */}
<div className={`threads-theme ${styles.threadsThemeRoot}`}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId={AGENT_ID}
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
</div>
{/* adk's demo content, verbatim, in the main panel. */}
<main
className={styles.mainPanel}
style={
{ "--copilot-kit-primary-color": themeColor } as React.CSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
defaultOpen={true}
labels={{
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
"👋 Hi, there! You're chatting with an agent.",
}}
suggestions={[
{
title: "Generative UI",
message: "Get the weather in San Francisco.",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Write Agent State",
message: "Add a proverb about AI.",
},
{
title: "Update Agent State",
message:
"Please remove 1 random proverb from the list if there are any.",
},
{
title: "Read Agent State",
message: "What are the proverbs?",
},
]}
/>
</main>
</div>
</CopilotChatConfigurationProvider>
<main
style={
{ "--copilot-kit-primary-color": themeColor } as React.CSSProperties
}
>
<CopilotSidebar
disableSystemMessage={true}
clickOutsideToClose={false}
defaultOpen={true}
labels={{
title: "Popup Assistant",
initial: "👋 Hi, there! You're chatting with an agent.",
}}
suggestions={[
{
title: "Generative UI",
message: "Get the weather in San Francisco.",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Write Agent State",
message: "Add a proverb about AI.",
},
{
title: "Update Agent State",
message:
"Please remove 1 random proverb from the list if there are any.",
},
{
title: "Read Agent State",
message: "What are the proverbs?",
},
]}
>
<YourMainContent themeColor={themeColor} />
</CopilotSidebar>
</main>
);
}
function YourMainContent({ themeColor }: { themeColor: string }) {
// 🪁 Shared State: https://docs.copilotkit.ai/adk/shared-state
const { agent } = useAgent({
agentId: AGENT_ID,
agentId: "my_agent",
});
const state = (agent.state ?? {
proverbs: [
@@ -6,8 +6,6 @@ export interface ProverbsCardProps {
}
export function ProverbsCard({ state, setState }: ProverbsCardProps) {
// `state` is undefined until the agent syncs (V2 useAgent), so guard it.
const proverbs = state?.proverbs ?? [];
return (
<div className="bg-white/20 backdrop-blur-md p-8 rounded-2xl shadow-xl max-w-2xl w-full">
<h1 className="text-4xl font-bold text-white mb-2 text-center">
@@ -18,7 +16,7 @@ export function ProverbsCard({ state, setState }: ProverbsCardProps) {
</p>
<hr className="border-white/20 my-6" />
<div className="flex flex-col gap-3">
{proverbs.map((proverb, index) => (
{state.proverbs?.map((proverb, index) => (
<div
key={index}
className="bg-white/15 p-4 rounded-xl text-white relative group hover:bg-white/20 transition-all"
@@ -28,7 +26,7 @@ export function ProverbsCard({ state, setState }: ProverbsCardProps) {
onClick={() =>
setState({
...state,
proverbs: proverbs.filter((_, i) => i !== index),
proverbs: state.proverbs?.filter((_, i) => i !== index),
})
}
className="absolute right-3 top-3 opacity-0 group-hover:opacity-100 transition-opacity
@@ -39,7 +37,7 @@ export function ProverbsCard({ state, setState }: ProverbsCardProps) {
</div>
))}
</div>
{proverbs.length === 0 && (
{state.proverbs?.length === 0 && (
<p className="text-center text-white/80 italic my-8">
No proverbs yet. Ask the assistant to add some!
</p>
@@ -1,53 +0,0 @@
# Threads Drawer — Theming Contract
The threads-drawer is a BASE component. It is fully driven by CSS variables and
contains no hardcoded colors, shadows, or surface radii. To theme it for an
example, (re)define the tokens below on any ancestor (e.g. `:root`, `body`, or a
wrapper element) — **never edit the drawer files**.
The drawer first consumes the shared design-system tokens (`--card`,
`--border`, `--radius`, …) that `ui/card.tsx` and `ui/button.tsx` also consume.
For a handful of drawer-specific visuals (scrim, shadows, delete-hover tint) it
exposes dedicated `--threads-*` tokens, each with a fallback to a shared token or
the original literal — so defining nothing reproduces the default look exactly.
## Shared design-system tokens consumed
| Token | Controls |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--background` | Tooltip text color (`color: var(--background)` on the dark tooltip body) |
| `--foreground` | Drawer/dialog title + body text, active segment text, tooltip surface bg |
| `--card` | Drawer surface bg (via `--threads-drawer-bg`), active segment bg, empty/dialog/load-more bg |
| `--border` | Drawer + header + filter + dialog borders, thread accent (idle), selected-row inset ring, secondary-button hover bg, tooltip border |
| `--radius` | Drawer/dialog/button/segment/thread/empty-card radii; tooltip radius derives from it |
| `--primary` | New-thread button bg, primary dialog button bg, selected thread accent |
| `--primary-foreground` | New-thread button text, primary dialog button text |
| `--secondary` | Icon-button + thread-row + load-more hover bg, segment track, archived badge bg, secondary dialog button bg, loading skeleton bars, thread-enter start bg |
| `--secondary-foreground` | (locked-state) inline code text |
| `--muted-foreground` | Icon-button idle color, segment idle text, meta text, placeholder/archived titles, empty/dialog description, load-more text, collapsed-rail icon |
| `--accent` | Selected thread-row bg |
| `--ring` | Focus-visible outline on buttons, thread items, segments, dialog buttons |
| `--destructive` | Delete-button icon color + delete-hover text |
| `--destructive-foreground` | Destructive dialog button text |
| `--font-body` | Header, segments, tooltip, empty card, and dialog typography |
(locked-state additionally uses `--secondary`, `--muted-foreground`, `--border`,
`--radius`, `--secondary-foreground`, and the `ui/card` + `ui/button` tokens via
those components.)
## Drawer-specific tokens (with fallbacks)
| Token | Controls | Fallback |
| --------------------------------- | ----------------------------------------- | --------------------------------------------------------- |
| `--threads-drawer-bg` | Drawer surface background | `var(--card)` |
| `--threads-drawer-border` | Drawer right border color | `var(--border)` |
| `--threads-drawer-shadow` | Open-drawer drop shadow | `4px 0 20px rgb(0 0 0 / 0.04)` |
| `--threads-segment-active-shadow` | Active filter-segment shadow | `0 1px 2px rgb(0 0 0 / 0.06)` |
| `--threads-delete-hover-bg` | Delete-button hover/focus background tint | `color-mix(in srgb, var(--destructive) 10%, transparent)` |
| `--threads-overlay-bg` | Confirm-dialog overlay scrim | `rgb(0 0 0 / 0.5)` |
| `--threads-dialog-shadow` | Confirm-dialog drop shadow | `0 20px 50px rgb(0 0 0 / 0.25)` |
| `--threads-tooltip-radius` | Action-button tooltip corner radius | `calc(var(--radius) - 0.45rem)` (= `0.3rem` at default) |
All fallbacks resolve to the original hardcoded values in the north-star, so an
example that defines none of the `--threads-*` tokens renders pixel-identical to
the pre-tokenization drawer.
@@ -1,4 +0,0 @@
"use client";
export { default as ThreadsDrawer } from "./threads-drawer";
export type { ThreadsDrawerProps } from "./threads-drawer";
@@ -1,91 +0,0 @@
"use client";
import * as React from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import styles from "./threads-drawer.module.css";
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
// The Threads drawer reads a client-only external store (useThreads /
// useSyncExternalStore) with no server snapshot, so it must not render during
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (process.env.NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED === "true") {
if (!mounted) {
// SSR / first-paint placeholder: matches the open drawer's footprint +
// surface (and collapses to nothing on mobile) so the panel doesn't flash
// a bare-background column or shift the content when the drawer mounts.
return <div className={styles.drawerPlaceholder} aria-hidden />;
}
return <>{children}</>;
}
return (
<div className="flex w-80 shrink-0 flex-col items-center justify-center p-4 bg-[var(--threads-drawer-bg,var(--card))] border-r border-[var(--threads-drawer-border,var(--border))] max-lg:hidden">
<Card className="w-full">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--muted-foreground)]"
aria-hidden="true"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<CardTitle>Threads</CardTitle>
<CardDescription>
Threads is a licensed CopilotKit Intelligence feature. Unlock
persistent conversation history, multi-session context, and thread
management across your application.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted-foreground)]">
To enable Threads, add a CopilotKit Intelligence license to your
project with:
</p>
</CardContent>
<CardFooter className="flex-col items-start gap-3">
<div className="w-full rounded-[var(--radius)] border border-[var(--border)] bg-[var(--secondary)] px-3 py-2">
<code className="text-xs whitespace-nowrap text-[var(--secondary-foreground)]">
copilotkit add-intelligence
</code>
</div>
<Button
variant="default"
size="sm"
className="w-full"
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -1,739 +0,0 @@
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
/* The `.threads-theme` wrapper only carries the light theme tokens; it must
not introduce a layout box, so the drawer/locked-state stay direct grid
children of `.layout` (custom properties still inherit through
display:contents). */
.threadsThemeRoot {
display: contents;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
box-shadow: var(--threads-drawer-shadow, 4px 0 20px rgb(0 0 0 / 0.04));
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
font-family: var(--font-body);
}
.drawerTitle {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: var(--radius);
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--secondary);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
}
.segmented {
display: inline-flex;
width: 100%;
padding: 0.2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--secondary);
gap: 0.15rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: calc(var(--radius) - 0.15rem);
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-segment-active-shadow, 0 1px 2px rgb(0 0 0 / 0.06));
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.75rem 0.5rem;
}
.threadRow {
position: relative;
}
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
box-shadow 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--secondary);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected {
background: var(--accent);
box-shadow: inset 0 0 0 1px var(--border);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.threadAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--border);
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
font-weight: 600;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 500;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7rem;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 500;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
.archivedBadge {
display: inline-block;
margin-left: 0.35rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--secondary);
font-size: 0.6rem;
font-weight: 600;
color: var(--muted-foreground);
vertical-align: middle;
}
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.25rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-size: 0.78rem;
font-weight: 600;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--secondary);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.4rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.15rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 0.3rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.2rem 0.45rem;
border-radius: var(--threads-tooltip-radius, calc(var(--radius) - 0.45rem));
border: 1px solid var(--border);
background: var(--foreground);
color: var(--background);
font-family: var(--font-body);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--destructive);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: var(
--threads-delete-hover-bg,
color-mix(in srgb, var(--destructive) 10%, transparent)
);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.2rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--muted-foreground);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--threads-overlay-bg, rgb(0 0 0 / 0.5));
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.1rem 1.1rem 1rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 0.25rem);
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-dialog-shadow, 0 20px 50px rgb(0 0 0 / 0.25));
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1rem;
font-size: 0.82rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.dialogButtonSecondary {
background: var(--secondary);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--border);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--secondary);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--threads-drawer-bg, var(--card));
border: 1px solid var(--threads-drawer-border, var(--border));
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -1,586 +0,0 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useThreads } from "@copilotkit/react-core/v2";
import styles from "./threads-drawer.module.css";
export interface ThreadsDrawerProps {
agentId: string;
threadId: string | undefined;
onThreadChange: (threadId: string | undefined) => void;
}
interface DrawerThread {
id: string;
name: string | null;
updatedAt: string;
archived: boolean;
lastRunAt?: string;
}
const THREAD_ENTRY_ANIMATION_MS = 420;
const TITLE_ANIMATION_MS = 360;
const UNTITLED_THREAD_LABEL = "New thread";
const RUNTIME_BASE_PATH = "/api/copilotkit";
function formatThreadTimestamp(updatedAt: string): string {
const timestamp = new Date(updatedAt);
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
function cx(...classNames: Array<string | false | undefined>): string {
return classNames.filter(Boolean).join(" ");
}
export default function ThreadsDrawer({
agentId,
threadId,
onThreadChange,
}: ThreadsDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
// Start collapsed on narrow screens (tablet + phone) so the panel — which
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
// chat on load. The drawer is client-mounted, so reading window here is safe
// and won't cause a hydration mismatch.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.innerWidth > 1024,
);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
} | null>(null);
const deleteTriggerRef = useRef<HTMLElement | null>(null);
const {
threads,
archiveThread,
deleteThread,
error,
isLoading,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId,
includeArchived: showArchived,
limit: 20,
});
const restoreThread = useCallback(
async (id: string) => {
const response = await fetch(
`${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId],
);
const hasMountedRef = useRef(false);
const hasLoadedOnceRef = useRef(false);
const stableThreadsRef = useRef<DrawerThread[]>(threads);
const previousThreadIdsRef = useRef<Set<string>>(new Set());
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
if (!isLoading) {
hasLoadedOnceRef.current = true;
stableThreadsRef.current = threads;
}
const displayThreads: DrawerThread[] =
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
const [enteringThreadIds, setEnteringThreadIds] = useState<
Record<string, true>
>({});
const [revealedTitleIds, setRevealedTitleIds] = useState<
Record<string, true>
>({});
useEffect(() => {
return () => {
for (const timeoutId of entryTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
for (const timeoutId of titleTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
// Skip diffing while the store is refetching (e.g. after a filter change
// clears the list). Otherwise every thread would be treated as newly
// added once the new page lands.
if (isLoading) return;
const nextThreadIds = new Set(threads.map((t) => t.id));
if (!hasMountedRef.current) {
hasMountedRef.current = true;
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
return;
}
const addedThreadIds = threads
.filter((t) => !previousThreadIdsRef.current.has(t.id))
.map((t) => t.id);
if (addedThreadIds.length > 0) {
setEnteringThreadIds((current) => {
const next = { ...current };
for (const id of addedThreadIds) {
next[id] = true;
const existing = entryTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setEnteringThreadIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
entryTimeoutsRef.current.delete(id);
}, THREAD_ENTRY_ANIMATION_MS);
entryTimeoutsRef.current.set(id, tid);
}
return next;
});
}
const renamedThreadIds = threads
.filter((t) => {
// Only reveal when an already-tracked thread's name transitions from
// null → named. Threads appearing for the first time (e.g. on a
// filter switch) already have their final name and should not trigger
// the title reveal animation — that would layer a blur/translateY
// onto the row's enter animation and produce a visible jitter.
if (!previousNamesRef.current.has(t.id)) return false;
const prev = previousNamesRef.current.get(t.id) ?? null;
return prev === null && t.name !== null;
})
.map((t) => t.id);
if (renamedThreadIds.length > 0) {
setRevealedTitleIds((current) => {
const next = { ...current };
for (const id of renamedThreadIds) {
next[id] = true;
const existing = titleTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setRevealedTitleIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
titleTimeoutsRef.current.delete(id);
}, TITLE_ANIMATION_MS);
titleTimeoutsRef.current.set(id, tid);
}
return next;
});
}
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
}, [threads, isLoading]);
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
if (error) {
console.error("Unable to load threads", error);
}
if (!isOpen) {
return (
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerClosed)}
>
<div className={styles.collapsedRail}>
{/* Native title here (not the styled ::after): the collapsed rail
sits at the viewport's left edge where a centered tooltip clips. */}
<button
aria-label="Open threads drawer"
title="Expand"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(true)}
>
<ChevronRight size={18} />
</button>
<button
aria-label="Create thread"
title="New thread"
className={styles.iconButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={18} />
</button>
</div>
</aside>
);
}
const closeDeleteDialog = () => {
setPendingDelete(null);
const trigger = deleteTriggerRef.current;
deleteTriggerRef.current = null;
trigger?.focus?.();
};
return (
<>
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerOpen)}
>
<div className={styles.drawerSurface}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
<div className={styles.headerActions}>
<button
aria-label="Create thread"
className={styles.newThreadButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={14} />
<span>New thread</span>
</button>
<button
aria-label="Collapse threads drawer"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(false)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
<div className={styles.filterBar}>
<div
aria-label="Thread filter"
className={styles.segmented}
role="tablist"
>
<button
aria-selected={!showArchived}
className={cx(
styles.segmentedOption,
!showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(false)}
>
Active
</button>
<button
aria-selected={showArchived}
className={cx(
styles.segmentedOption,
showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(true)}
>
All
</button>
</div>
</div>
<div className={styles.drawerContent}>
{error ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>
Couldn&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -1,52 +0,0 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
{
variants: {
variant: {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
outline:
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--secondary)]",
ghost:
"hover:bg-[var(--secondary)] hover:text-[var(--secondary-foreground)]",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -1,85 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};
@@ -1,7 +0,0 @@
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-6
View File
@@ -1,8 +1,2 @@
OPENAI_API_KEY=your-api-key-here
AGENT_URL=http://localhost:8000
# --- CopilotKit Intelligence (optional; set COPILOTKIT_LICENSE_TOKEN to enable Threads — server + UI) ---
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
# INTELLIGENCE_API_KEY= # local dev: see examples/integrations/_intelligence/.env.intelligence for the seed value
-14
View File
@@ -1,21 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
typescript: { ignoreBuildErrors: true },
serverExternalPackages: ["pino", "thread-stream"],
env: {
// The public Threads UI flag is DERIVED from the server-side license token.
// Set COPILOTKIT_LICENSE_TOKEN (only) to enable Threads — do not set this flag
// directly. NOTE: NEXT_PUBLIC_* resolves at BUILD time while the runtime reads
// the token per-request, so the UI gate and runtime agree only when the token is
// present at build time (the standard `next dev` / host-build flow). For a
// standalone/Docker image built without the token and injected at runtime, set
// COPILOTKIT_LICENSE_TOKEN at build time too (or gate the UI at runtime) so the
// baked flag reflects it.
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -13,17 +13,13 @@
},
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/react-core": "1.55.2",
"@copilotkit/runtime": "1.55.2",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4",
"lucide-react": "^0.525.0",
"next": "16.0.7",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.24.4"
},
"devDependencies": {
@@ -1,6 +1,5 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
@@ -9,26 +8,11 @@ import { handle } from "hono/vercel";
const runtime = new CopilotRuntime({
agents: {
default: new HttpAgent({
agno_agent: new HttpAgent({
url: (process.env.AGENT_URL || "http://localhost:8000") + "/agui",
}),
},
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your real auth-derived user identity before any
// multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
runner: new InMemoryAgentRunner(),
});
const app = createCopilotEndpoint({
@@ -38,5 +22,3 @@ const app = createCopilotEndpoint({
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
+7 -50
View File
@@ -1,58 +1,15 @@
@import "tailwindcss";
/*
* Threads panel theme — bespoke match to agno's CopilotKit chat (CopilotSidebar).
*
* The CopilotKit v2 chat renders LIGHT regardless of OS dark mode (neutral
* zinc/gray surfaces on white, ~1rem bubble radius, system-ui sans). To read as
* native to that chat, the threads panel is FORCED light: we drop agno's old
* `prefers-color-scheme: dark` override of --background/--foreground so the
* panel never goes dark while the chat beside it stays light. agno's own demo
* content sets all of its colors locally (text-white, bg-white/20, the indigo
* themeColor backdrop), so it is unaffected by this.
*
* Tokens are defined at :root (not a wrapper) so the drawer's delete-confirm
* dialog — which renders through a React portal to <body>, outside the panel —
* still resolves the same surfaces. This follows threads-drawer/THEME.md, which
* sanctions defining the contract tokens on :root. The drawer + locked-state are
* BASE components driven entirely by these tokens — do not edit the drawer files.
*
* The indigo accent (#6366f1) is agno's own app accent, tying the panel to the
* surrounding product; the neutral zinc surfaces/borders/muted text mirror the
* chat's light chrome.
*/
:root {
/* Neutral surfaces — match the chat's white/zinc light palette */
--background: #ffffff;
--foreground: #18181b; /* zinc-900 */
--card: #ffffff;
--card-foreground: #18181b;
--foreground: #171717;
}
/* Agno's indigo accent for the primary new-thread button + active thread */
--primary: #6366f1;
--primary-foreground: #ffffff;
/* Tints / hovers — zinc scale, matching the chat's neutral chrome */
--secondary: #f4f4f5; /* zinc-100 */
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a; /* zinc-500 */
--accent: #eef2ff; /* indigo-50 — selected thread row tint */
--accent-foreground: #18181b;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #e4e4e7; /* zinc-200 */
--input: #e4e4e7;
--ring: #a5b4fc; /* indigo-300 — focus ring keyed to the accent */
/* Match the chat's rounded-2xl (1rem) corner language */
--radius: 1rem;
/* Match the chat's system-ui sans stack */
--font-body:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
@@ -17,11 +17,7 @@ export default function RootLayout({
return (
<html lang="en">
<body className={"antialiased"}>
{/* Force REST transport so runtime-info + threads both hit the multi-route endpoint (auto-detect races the lazily-compiled API route in next dev). */}
<CopilotKit
runtimeUrl="/api/copilotkit"
useSingleEndpoint={false}
>
<CopilotKit runtimeUrl="/api/copilotkit" agent="agno_agent">
{children}
</CopilotKit>
</body>
+37 -65
View File
@@ -5,24 +5,14 @@ import {
useFrontendTool,
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import React, { useState } from "react";
import { z } from "zod";
import { DefaultToolComponent } from "@/components/default-tool-ui";
import { WeatherCard } from "@/components/weather";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
// agno registers a single agent under the key "default" (see
// src/app/api/copilotkit/[[...slug]]/route.ts), so the threads drawer + chat
// config provider must address that same agent id.
const AGENT_ID = "default";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
// 🪁 Frontend Actions: https://docs.copilotkit.ai/guides/frontend-actions
useFrontendTool({
@@ -38,61 +28,43 @@ export default function CopilotKitPage() {
});
return (
<div className={styles.layout}>
{/* Bespoke threads panel, themed in globals.css to match agno's chat. */}
<ThreadsPanelGate>
<ThreadsDrawer
agentId={AGENT_ID}
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
{/*
Share the active threadId between the threads drawer and the chat. The
CopilotSidebar's chat falls back to this provider's threadId when none is
passed explicitly, so selecting a thread in the drawer resumes it in the
chat.
*/}
<CopilotChatConfigurationProvider agentId={AGENT_ID} threadId={threadId}>
<main
className={styles.mainPanel}
style={
{ "--copilot-kit-primary-color": themeColor } as React.CSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
defaultOpen={true}
// Adds an initial message to the chat
labels={{
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
"👋 Hi, there! You're chatting with an Agno agent.",
}}
// Suggestions for guiding users
suggestions={[
{
title: "Generative UI",
message: "What's the weather in San Francisco?",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Default Tool Rendering",
message: "What's the latest price of Apple stock?",
},
{
title: "Writing Agent State",
message: "Add a proverb about AI.",
},
]}
/>
{/* CopilotSidebar self-docks; main content renders as a sibling. */}
</main>
</CopilotChatConfigurationProvider>
</div>
<main
style={
{ "--copilot-kit-primary-color": themeColor } as React.CSSProperties
}
>
<CopilotSidebar
clickOutsideToClose={false}
defaultOpen={true}
// Adds an initial message to the chat
labels={{
title: "Popup Assistant",
initial: "👋 Hi, there! You're chatting with an Agno agent.",
}}
// Suggestions for guiding users
suggestions={[
{
title: "Generative UI",
message: "What's the weather in San Francisco?",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Default Tool Rendering",
message: "What's the latest price of Apple stock?",
},
{
title: "Writing Agent State",
message: "Add a proverb about AI.",
},
]}
>
{/* Wrapping your content in the sidebar pushes it to the side*/}
<YourMainContent themeColor={themeColor} />
</CopilotSidebar>
</main>
);
}
@@ -1,53 +0,0 @@
# Threads Drawer — Theming Contract
The threads-drawer is a BASE component. It is fully driven by CSS variables and
contains no hardcoded colors, shadows, or surface radii. To theme it for an
example, (re)define the tokens below on any ancestor (e.g. `:root`, `body`, or a
wrapper element) — **never edit the drawer files**.
The drawer first consumes the shared design-system tokens (`--card`,
`--border`, `--radius`, …) that `ui/card.tsx` and `ui/button.tsx` also consume.
For a handful of drawer-specific visuals (scrim, shadows, delete-hover tint) it
exposes dedicated `--threads-*` tokens, each with a fallback to a shared token or
the original literal — so defining nothing reproduces the default look exactly.
## Shared design-system tokens consumed
| Token | Controls |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--background` | Tooltip text color (`color: var(--background)` on the dark tooltip body) |
| `--foreground` | Drawer/dialog title + body text, active segment text, tooltip surface bg |
| `--card` | Drawer surface bg (via `--threads-drawer-bg`), active segment bg, empty/dialog/load-more bg |
| `--border` | Drawer + header + filter + dialog borders, thread accent (idle), selected-row inset ring, secondary-button hover bg, tooltip border |
| `--radius` | Drawer/dialog/button/segment/thread/empty-card radii; tooltip radius derives from it |
| `--primary` | New-thread button bg, primary dialog button bg, selected thread accent |
| `--primary-foreground` | New-thread button text, primary dialog button text |
| `--secondary` | Icon-button + thread-row + load-more hover bg, segment track, archived badge bg, secondary dialog button bg, loading skeleton bars, thread-enter start bg |
| `--secondary-foreground` | (locked-state) inline code text |
| `--muted-foreground` | Icon-button idle color, segment idle text, meta text, placeholder/archived titles, empty/dialog description, load-more text, collapsed-rail icon |
| `--accent` | Selected thread-row bg |
| `--ring` | Focus-visible outline on buttons, thread items, segments, dialog buttons |
| `--destructive` | Delete-button icon color + delete-hover text |
| `--destructive-foreground` | Destructive dialog button text |
| `--font-body` | Header, segments, tooltip, empty card, and dialog typography |
(locked-state additionally uses `--secondary`, `--muted-foreground`, `--border`,
`--radius`, `--secondary-foreground`, and the `ui/card` + `ui/button` tokens via
those components.)
## Drawer-specific tokens (with fallbacks)
| Token | Controls | Fallback |
| --------------------------------- | ----------------------------------------- | --------------------------------------------------------- |
| `--threads-drawer-bg` | Drawer surface background | `var(--card)` |
| `--threads-drawer-border` | Drawer right border color | `var(--border)` |
| `--threads-drawer-shadow` | Open-drawer drop shadow | `4px 0 20px rgb(0 0 0 / 0.04)` |
| `--threads-segment-active-shadow` | Active filter-segment shadow | `0 1px 2px rgb(0 0 0 / 0.06)` |
| `--threads-delete-hover-bg` | Delete-button hover/focus background tint | `color-mix(in srgb, var(--destructive) 10%, transparent)` |
| `--threads-overlay-bg` | Confirm-dialog overlay scrim | `rgb(0 0 0 / 0.5)` |
| `--threads-dialog-shadow` | Confirm-dialog drop shadow | `0 20px 50px rgb(0 0 0 / 0.25)` |
| `--threads-tooltip-radius` | Action-button tooltip corner radius | `calc(var(--radius) - 0.45rem)` (= `0.3rem` at default) |
All fallbacks resolve to the original hardcoded values in the north-star, so an
example that defines none of the `--threads-*` tokens renders pixel-identical to
the pre-tokenization drawer.
@@ -1,4 +0,0 @@
"use client";
export { default as ThreadsDrawer } from "./threads-drawer";
export type { ThreadsDrawerProps } from "./threads-drawer";
@@ -1,91 +0,0 @@
"use client";
import * as React from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import styles from "./threads-drawer.module.css";
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
// The Threads drawer reads a client-only external store (useThreads /
// useSyncExternalStore) with no server snapshot, so it must not render during
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (process.env.NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED === "true") {
if (!mounted) {
// SSR / first-paint placeholder: matches the open drawer's footprint +
// surface (and collapses to nothing on mobile) so the panel doesn't flash
// a bare-background column or shift the content when the drawer mounts.
return <div className={styles.drawerPlaceholder} aria-hidden />;
}
return <>{children}</>;
}
return (
<div className="flex w-80 shrink-0 flex-col items-center justify-center p-4 bg-[var(--threads-drawer-bg,var(--card))] border-r border-[var(--threads-drawer-border,var(--border))] max-lg:hidden">
<Card className="w-full">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--muted-foreground)]"
aria-hidden="true"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<CardTitle>Threads</CardTitle>
<CardDescription>
Threads is a licensed CopilotKit Intelligence feature. Unlock
persistent conversation history, multi-session context, and thread
management across your application.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted-foreground)]">
To enable Threads, add a CopilotKit Intelligence license to your
project with:
</p>
</CardContent>
<CardFooter className="flex-col items-start gap-3">
<div className="w-full rounded-[var(--radius)] border border-[var(--border)] bg-[var(--secondary)] px-3 py-2">
<code className="text-xs whitespace-nowrap text-[var(--secondary-foreground)]">
copilotkit add-intelligence
</code>
</div>
<Button
variant="default"
size="sm"
className="w-full"
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -1,731 +0,0 @@
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
box-shadow: var(--threads-drawer-shadow, 4px 0 20px rgb(0 0 0 / 0.04));
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
font-family: var(--font-body);
}
.drawerTitle {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: var(--radius);
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--secondary);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
}
.segmented {
display: inline-flex;
width: 100%;
padding: 0.2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--secondary);
gap: 0.15rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: calc(var(--radius) - 0.15rem);
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-segment-active-shadow, 0 1px 2px rgb(0 0 0 / 0.06));
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.75rem 0.5rem;
}
.threadRow {
position: relative;
}
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
box-shadow 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--secondary);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected {
background: var(--accent);
box-shadow: inset 0 0 0 1px var(--border);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.threadAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--border);
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
font-weight: 600;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 500;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7rem;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 500;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
.archivedBadge {
display: inline-block;
margin-left: 0.35rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--secondary);
font-size: 0.6rem;
font-weight: 600;
color: var(--muted-foreground);
vertical-align: middle;
}
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.25rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-size: 0.78rem;
font-weight: 600;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--secondary);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.4rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.15rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 0.3rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.2rem 0.45rem;
border-radius: var(--threads-tooltip-radius, calc(var(--radius) - 0.45rem));
border: 1px solid var(--border);
background: var(--foreground);
color: var(--background);
font-family: var(--font-body);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--destructive);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: var(
--threads-delete-hover-bg,
color-mix(in srgb, var(--destructive) 10%, transparent)
);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.2rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--muted-foreground);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--threads-overlay-bg, rgb(0 0 0 / 0.5));
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.1rem 1.1rem 1rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 0.25rem);
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-dialog-shadow, 0 20px 50px rgb(0 0 0 / 0.25));
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1rem;
font-size: 0.82rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.dialogButtonSecondary {
background: var(--secondary);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--border);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--secondary);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--threads-drawer-bg, var(--card));
border: 1px solid var(--threads-drawer-border, var(--border));
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -1,586 +0,0 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useThreads } from "@copilotkit/react-core/v2";
import styles from "./threads-drawer.module.css";
export interface ThreadsDrawerProps {
agentId: string;
threadId: string | undefined;
onThreadChange: (threadId: string | undefined) => void;
}
interface DrawerThread {
id: string;
name: string | null;
updatedAt: string;
archived: boolean;
lastRunAt?: string;
}
const THREAD_ENTRY_ANIMATION_MS = 420;
const TITLE_ANIMATION_MS = 360;
const UNTITLED_THREAD_LABEL = "New thread";
const RUNTIME_BASE_PATH = "/api/copilotkit";
function formatThreadTimestamp(updatedAt: string): string {
const timestamp = new Date(updatedAt);
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
function cx(...classNames: Array<string | false | undefined>): string {
return classNames.filter(Boolean).join(" ");
}
export default function ThreadsDrawer({
agentId,
threadId,
onThreadChange,
}: ThreadsDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
// Start collapsed on narrow screens (tablet + phone) so the panel — which
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
// chat on load. The drawer is client-mounted, so reading window here is safe
// and won't cause a hydration mismatch.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.innerWidth > 1024,
);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
} | null>(null);
const deleteTriggerRef = useRef<HTMLElement | null>(null);
const {
threads,
archiveThread,
deleteThread,
error,
isLoading,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId,
includeArchived: showArchived,
limit: 20,
});
const restoreThread = useCallback(
async (id: string) => {
const response = await fetch(
`${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId],
);
const hasMountedRef = useRef(false);
const hasLoadedOnceRef = useRef(false);
const stableThreadsRef = useRef<DrawerThread[]>(threads);
const previousThreadIdsRef = useRef<Set<string>>(new Set());
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
if (!isLoading) {
hasLoadedOnceRef.current = true;
stableThreadsRef.current = threads;
}
const displayThreads: DrawerThread[] =
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
const [enteringThreadIds, setEnteringThreadIds] = useState<
Record<string, true>
>({});
const [revealedTitleIds, setRevealedTitleIds] = useState<
Record<string, true>
>({});
useEffect(() => {
return () => {
for (const timeoutId of entryTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
for (const timeoutId of titleTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
// Skip diffing while the store is refetching (e.g. after a filter change
// clears the list). Otherwise every thread would be treated as newly
// added once the new page lands.
if (isLoading) return;
const nextThreadIds = new Set(threads.map((t) => t.id));
if (!hasMountedRef.current) {
hasMountedRef.current = true;
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
return;
}
const addedThreadIds = threads
.filter((t) => !previousThreadIdsRef.current.has(t.id))
.map((t) => t.id);
if (addedThreadIds.length > 0) {
setEnteringThreadIds((current) => {
const next = { ...current };
for (const id of addedThreadIds) {
next[id] = true;
const existing = entryTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setEnteringThreadIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
entryTimeoutsRef.current.delete(id);
}, THREAD_ENTRY_ANIMATION_MS);
entryTimeoutsRef.current.set(id, tid);
}
return next;
});
}
const renamedThreadIds = threads
.filter((t) => {
// Only reveal when an already-tracked thread's name transitions from
// null → named. Threads appearing for the first time (e.g. on a
// filter switch) already have their final name and should not trigger
// the title reveal animation — that would layer a blur/translateY
// onto the row's enter animation and produce a visible jitter.
if (!previousNamesRef.current.has(t.id)) return false;
const prev = previousNamesRef.current.get(t.id) ?? null;
return prev === null && t.name !== null;
})
.map((t) => t.id);
if (renamedThreadIds.length > 0) {
setRevealedTitleIds((current) => {
const next = { ...current };
for (const id of renamedThreadIds) {
next[id] = true;
const existing = titleTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setRevealedTitleIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
titleTimeoutsRef.current.delete(id);
}, TITLE_ANIMATION_MS);
titleTimeoutsRef.current.set(id, tid);
}
return next;
});
}
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
}, [threads, isLoading]);
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
if (error) {
console.error("Unable to load threads", error);
}
if (!isOpen) {
return (
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerClosed)}
>
<div className={styles.collapsedRail}>
{/* Native title here (not the styled ::after): the collapsed rail
sits at the viewport's left edge where a centered tooltip clips. */}
<button
aria-label="Open threads drawer"
title="Expand"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(true)}
>
<ChevronRight size={18} />
</button>
<button
aria-label="Create thread"
title="New thread"
className={styles.iconButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={18} />
</button>
</div>
</aside>
);
}
const closeDeleteDialog = () => {
setPendingDelete(null);
const trigger = deleteTriggerRef.current;
deleteTriggerRef.current = null;
trigger?.focus?.();
};
return (
<>
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerOpen)}
>
<div className={styles.drawerSurface}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
<div className={styles.headerActions}>
<button
aria-label="Create thread"
className={styles.newThreadButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={14} />
<span>New thread</span>
</button>
<button
aria-label="Collapse threads drawer"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(false)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
<div className={styles.filterBar}>
<div
aria-label="Thread filter"
className={styles.segmented}
role="tablist"
>
<button
aria-selected={!showArchived}
className={cx(
styles.segmentedOption,
!showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(false)}
>
Active
</button>
<button
aria-selected={showArchived}
className={cx(
styles.segmentedOption,
showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(true)}
>
All
</button>
</div>
</div>
<div className={styles.drawerContent}>
{error ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>
Couldn&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -1,52 +0,0 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
{
variants: {
variant: {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
outline:
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--secondary)]",
ghost:
"hover:bg-[var(--secondary)] hover:text-[var(--secondary-foreground)]",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -1,85 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};
@@ -1,7 +0,0 @@
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -14,8 +14,8 @@
},
"dependencies": {
"@ag-ui/crewai": "^0.0.2",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/react-core": "1.55.2",
"@copilotkit/runtime": "1.55.2",
"hono": "^4",
"next": "^15.5.15",
"react": "^19.0.0",
@@ -10,7 +10,7 @@ import { handle } from "hono/vercel";
// integration to setup the connection.
const runtime = new CopilotRuntime({
agents: {
default: new CrewAIAgent({
starterAgent: new CrewAIAgent({
url: process.env.AGENT_URL || "http://localhost:8000/",
}),
},
@@ -17,10 +17,7 @@ export default function RootLayout({
return (
<html lang="en">
<body className={"antialiased"}>
<CopilotKit
runtimeUrl="/api/copilotkit"
useSingleEndpoint={false}
>
<CopilotKit runtimeUrl="/api/copilotkit" agent="starterAgent">
{children}
</CopilotKit>
</body>
@@ -14,8 +14,8 @@ export default function CopilotKitPage() {
clickOutsideToClose={false}
defaultOpen={true}
labels={{
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
title: "Popup Assistant",
initial:
'👋 Hi, there! You\'re chatting with an agent. This agent comes with a few tools to get you started.\n\nFor example you can try:\n- **Frontend Tools**: "Set the theme to orange"\n- **Shared State**: "Write a proverb about AI"\n- **Generative UI**: "Get the weather in SF"\n\nAs you interact with the agent, you\'ll see the UI update in real-time to reflect the agent\'s **state**, **tool calls**, and **progress**.',
}}
/>
@@ -25,7 +25,7 @@ export default function CopilotKitPage() {
function YourMainContent({ themeColor }: { themeColor: string }) {
const { agent } = useAgent({
agentId: "default",
agentId: "starterAgent",
});
useEffect(() => {
@@ -1,7 +0,0 @@
OPENAI_API_KEY=
# --- copilotkit:intelligence (optional local threads stack) ---
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_KEY=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
@@ -32,7 +32,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
@@ -40,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
@@ -1,25 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["@copilotkit/runtime"],
env: {
// The public Threads UI flag is DERIVED from the server-side license token.
// Set COPILOTKIT_LICENSE_TOKEN (only) to enable Threads — do not set this flag
// directly. NOTE: NEXT_PUBLIC_* resolves at BUILD time while the runtime reads
// the token per-request, so the UI gate and runtime agree only when the token is
// present at build time (the standard `next dev` / host-build flow). For a
// standalone/Docker image built without the token and injected at runtime, set
// COPILOTKIT_LICENSE_TOKEN at build time too (or gate the UI at runtime) so the
// baked flag reflects it.
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
typescript: {
// HttpAgent type mismatch with CopilotRuntime — pending upstream fix in @copilotkit/runtime
ignoreBuildErrors: true,
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -13,16 +13,12 @@
},
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"@copilotkit/react-core": "1.55.2",
"@copilotkit/runtime": "1.55.2",
"hono": "^4",
"lucide-react": "^0.525.0",
"next": "16.0.8",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwind-merge": "^3.5.0",
"zod": "^3.24.4"
},
"devDependencies": {
@@ -1,6 +1,5 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
@@ -13,22 +12,7 @@ const runtime = new CopilotRuntime({
agents: {
sample_agent: new HttpAgent({ url: "http://localhost:8000/" }),
},
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your own auth-derived user identity (e.g. OIDC)
// before any multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
runner: new InMemoryAgentRunner(),
});
// 2. Build a Hono app that handles the CopilotKit runtime requests.
@@ -39,5 +23,3 @@ const app = createCopilotEndpoint({
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -3,47 +3,6 @@
:root {
--background: #ffffff;
--foreground: #171717;
/* Threads-panel theme-map — values pulled verbatim from CopilotKit's V2
design system (the same one the CopilotSidebar renders with;
see @copilotkit/react-core/src/v2/styles/globals.css). The drawer is a
left-side companion to that LIGHT sidebar, so it inherits the sidebar's
exact neutral-gray palette, charcoal "primary" (NOT a brand accent — the
sidebar's primary buttons are near-black), and 0.625rem radius scale.
This keeps the threads panel reading as one product with the chat.
See ./../components/threads-drawer/THEME.md for the full token contract. */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
/* Match the V2 sidebar's radius scale exactly (--radius: 0.625rem there). */
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-body: var(--font-geist-sans), Arial, Helvetica, sans-serif;
--font-code: var(--font-geist-mono), "SFMono-Regular", Menlo, monospace;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
@@ -53,12 +12,6 @@
}
}
.threadsLayout,
body > [role="presentation"] {
--foreground: oklch(0.145 0 0);
--background: oklch(1 0 0);
}
body {
background: var(--background);
color: var(--foreground);
@@ -1,20 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { CopilotKit } from "@copilotkit/react-core/v2";
import "./globals.css";
import "@copilotkit/react-core/v2/styles.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
@@ -27,18 +16,8 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* Force REST (path-based) transport so runtime-info and the threads
REST API both hit the multi-route endpoint. Auto-detect probes
GET /info first, which races the lazily-compiled API route in
`next dev` and can fall back to single-route (no threads support). */}
<CopilotKit
runtimeUrl="/api/copilotkit"
agent="sample_agent"
useSingleEndpoint={false}
>
<body className={"antialiased"}>
<CopilotKit runtimeUrl="/api/copilotkit" agent="sample_agent">
{children}
</CopilotKit>
</body>
@@ -5,19 +5,12 @@ import {
useFrontendTool,
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import type { CSSProperties } from "react";
import { useState } from "react";
import { CSSProperties, useState } from "react";
import { z } from "zod";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
// 🪁 Frontend Tools: https://docs.copilotkit.ai/guides/frontend-actions
useFrontendTool({
@@ -27,70 +20,57 @@ export default function CopilotKitPage() {
.string()
.describe("The theme color to set. Make sure to pick nice colors."),
}),
handler: async ({ themeColor: nextThemeColor }) => {
setThemeColor(nextThemeColor);
handler: async ({ themeColor }) => {
setThemeColor(themeColor);
},
});
return (
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId="sample_agent"
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId="sample_agent"
threadId={threadId}
>
<main
style={
{
"--copilot-kit-primary-color": themeColor,
} as CSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
labels={{
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
"👋 Hi, there! You're chatting with an agent.",
}}
suggestions={[
{
title: "Generative UI",
message: "Get the weather in San Francisco.",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Write Agent State",
message: "Add a proverb about AI.",
},
{
title: "Update Agent State",
message:
"Please remove 1 random proverb from the list if there are any.",
},
{
title: "Read Agent State",
message: "What are the proverbs?",
},
]}
/>
</main>
</CopilotChatConfigurationProvider>
</div>
</div>
<main
style={{ "--copilot-kit-primary-color": themeColor } as CSSProperties}
>
<CopilotSidebar
disableSystemMessage={true}
clickOutsideToClose={false}
labels={{
title: "Popup Assistant",
initial: "👋 Hi, there! You're chatting with an agent.",
}}
suggestions={[
{
title: "Generative UI",
message: "Get the weather in San Francisco.",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Write Agent State",
message: "Add a proverb about AI.",
},
{
title: "Update Agent State",
message:
"Please remove 1 random proverb from the list if there are any.",
},
{
title: "Read Agent State",
message: "What are the proverbs?",
},
]}
>
<YourMainContent themeColor={themeColor} />
</CopilotSidebar>
</main>
);
}
// State of the agent, make sure this aligns with your agent's state.
type AgentState = {
proverbs: string[];
};
function YourMainContent({ themeColor }: { themeColor: string }) {
// 🪁 Shared State: https://docs.copilotkit.ai/coagents/shared-state
const { agent } = useAgent({
@@ -98,22 +78,25 @@ function YourMainContent({ themeColor }: { themeColor: string }) {
});
// 🪁 Frontend Tools: https://docs.copilotkit.ai/coagents/frontend-actions
useFrontendTool({
name: "updateProverb",
parameters: z.object({
proverbs: z
.array(z.string())
.describe(
"The proverbs to be committed into state. Make them witty, short and concise.",
),
}),
handler: async ({ proverbs }) => {
agent.setState({
...agent.state,
proverbs: [...proverbs],
});
useFrontendTool(
{
name: "updateProverb",
parameters: z.object({
proverbs: z
.array(z.string())
.describe(
"The proverbs to be committed into state. Make them witty, short and concise.",
),
}),
handler: async ({ proverbs }) => {
agent.setState({
...agent.state,
proverbs: [...proverbs],
});
},
},
});
[agent],
);
//🪁 Generative UI: https://docs.copilotkit.ai/coagents/generative-ui
useRenderTool({
@@ -1,45 +0,0 @@
# Threads Panel — Design Notes (mastra)
These are mastra's **bespoke** copies of the threads panel. They are no longer a
shared/tokenized base component — they are styled to read as one product with
mastra's `CopilotSidebar` (the right-side chat from `@copilotkit/react-core/v2`).
## Design source of truth
All surfaces, borders, radii, and type ramps are lifted from CopilotKit's V2
design system: `@copilotkit/react-core/src/v2/styles/globals.css` plus the chat
components (`CopilotModalHeader`, `CopilotChatSuggestionPill`,
`CopilotChatInput`, `CopilotSidebarView`). The tokens are mirrored verbatim into
`src/app/globals.css`:
| Token | Value (V2 light) | Role in the panel |
| -------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `--card` / `--background` | `oklch(1 0 0)` (white) | Panel + card surfaces |
| `--foreground` | `oklch(0.145 0 0)` | Titles, thread titles, dialog text |
| `--muted` / `--secondary` / `--accent` | `oklch(0.97 0 0)` | Hover/active surfaces, segment track, archived chip, code well |
| `--muted-foreground` | `oklch(0.556 0 0)` | Meta text, idle icons, descriptions, placeholders |
| `--border` / `--input` | `oklch(0.922 0 0)` | All hairline borders |
| `--primary` | `oklch(0.205 0 0)` (near-black) | New-thread pill, selected accent, primary CTA — the V2 sidebar's primary buttons are charcoal/black, **not** a brand accent |
| `--primary-foreground` | `oklch(0.985 0 0)` | Primary button text |
| `--destructive` | `oklch(0.577 0.245 27.325)` | Delete hover |
| `--ring` | `oklch(0.708 0 0)` | Focus rings (2px box-shadow) |
| `--radius` | `0.625rem` (+ sm/md/lg/xl) | Rectangular controls; icon buttons / pills / segments use `999px` to echo the sidebar's close button, suggestion pills, and send button |
## Forced light
mastra's `CopilotSidebar` is always light regardless of OS color scheme. The
panel must match it, so `src/app/globals.css` re-pins `--foreground` and
`--background` to the V2 light values on `.threadsLayout` (the layout wrapper)
and on `body > [role="presentation"]` (the confirm dialog renders in a portal on
`<body>`). The dark-mode `@media (prefers-color-scheme: dark)` block only flips
the bare page `--background`/`--foreground`; the panel overrides win because
they are scoped to the layout/portal roots.
## Typography
Geist (the app font, via `--font-body` / `--font-code`). Sizes/weights track the
sidebar: header title `1rem / 500 / tracking-tight`, thread titles
`0.8125rem / 500`, meta `0.6875rem`, all medium-weight — no heavy `700`s.
Edit these files freely; they are mastra-owned and not shared with other
examples.
@@ -1,4 +0,0 @@
"use client";
export { default as ThreadsDrawer } from "./threads-drawer";
export type { ThreadsDrawerProps } from "./threads-drawer";
@@ -1,70 +0,0 @@
"use client";
import * as React from "react";
import { Lock } from "lucide-react";
import styles from "./threads-drawer.module.css";
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
// The Threads drawer reads a client-only external store (useThreads /
// useSyncExternalStore) with no server snapshot, so it must not render during
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (process.env.NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED === "true") {
if (!mounted) {
// SSR / first-paint placeholder: matches the open drawer's footprint +
// surface (and collapses to nothing on mobile) so the panel doesn't flash
// a bare-background column or shift the content when the drawer mounts.
return <div className={styles.drawerPlaceholder} aria-hidden />;
}
return <>{children}</>;
}
return (
<aside aria-label="Threads (locked)" className={styles.lockedPanel}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
</div>
<div className={styles.lockedBody}>
<div className={styles.lockedCard}>
<span aria-hidden className={styles.lockedIcon}>
<Lock size={18} />
</span>
<div className={styles.lockedHeading}>
<h3 className={styles.lockedTitle}>
Threads is a licensed feature
</h3>
<p className={styles.lockedDescription}>
Unlock persistent conversation history, multi-session context, and
thread management with CopilotKit Intelligence.
</p>
</div>
<p className={styles.lockedDescription}>
Add it to your project with:
</p>
<div className={styles.lockedCommand}>
<code className={styles.lockedCommandCode}>
copilotkit add-intelligence
</code>
</div>
<button
type="button"
className={styles.lockedCta}
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</button>
</div>
</div>
</aside>
);
}
@@ -1,891 +0,0 @@
/* Threads panel — a left-side companion to mastra's CopilotSidebar.
Every surface, border, radius, and type ramp here is lifted from CopilotKit's
V2 design system (@copilotkit/react-core/src/v2) so the panel reads as the
same product as the chat on the right:
- surfaces: white card on a white app; borders are the --border hairline
- radius: --radius (0.625rem) for rectangular controls; rounded-full
(999px) for icon buttons, the segmented control, and the New-thread
affordance — echoing the sidebar's close button, suggestion pills, and
send button
- type: 0.875rem base / font-medium / tracking-tight, matching the
sidebar header + pills (no heavy 700 weights)
- primary action color is the sidebar's near-black --primary, NOT a brand
accent — the V2 sidebar's primary buttons render bg-black/text-white */
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
/* Header — mirrors CopilotModalHeader: hairline bottom border, generous
px-4 py-4 rhythm, medium-weight tracking-tight title (not bold). */
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.drawerTitle {
margin: 0;
font-size: 1rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.01em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Icon button — the sidebar's close button: size-8, rounded-full, muted
foreground, hover lifts to bg-muted + foreground. */
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--muted);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
/* New-thread affordance — a compact pill in the sidebar's near-black
--primary, echoing the send button (bg-black, rounded-full, medium text). */
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 2rem;
padding: 0 0.75rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition:
background-color 140ms ease,
opacity 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
}
/* Segmented control — a single muted track with a white "lifted" active tab,
the same surface relationship the suggestion pills use (bg over muted). */
.segmented {
display: inline-flex;
width: 100%;
padding: 0.1875rem;
border-radius: 999px;
background: var(--muted);
gap: 0.1875rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: 999px;
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.125rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.25rem 0.5rem 0.75rem;
}
.threadRow {
position: relative;
}
/* Thread row — a quiet hover/selected surface in the sidebar's accent gray,
--radius corners, no hairline rule between rows (the chat list is borderless
too). */
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--accent);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected,
.threadItemSelected:hover {
background: var(--accent);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* A slim left accent rail; muted by default, fills to --primary when selected
— the same charcoal that drives the primary action buttons. */
.threadAccent {
flex: none;
width: 0.1875rem;
height: 1.5rem;
border-radius: 999px;
background: transparent;
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.125rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.3;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 400;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.6875rem;
line-height: 1.3;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 400;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
/* Archived chip — a muted pill, the same surface/type as the suggestion
pills (bg muted, font-medium, tiny). */
.archivedBadge {
display: inline-block;
margin-left: 0.375rem;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--muted);
font-size: 0.625rem;
font-weight: 500;
color: var(--muted-foreground);
vertical-align: middle;
}
/* Load-more — a full-width outline pill, the sidebar's suggestion-pill
treatment (border, bg-background, hover to accent). */
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.375rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--card);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--accent);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.375rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.125rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
/* Tooltip — matches the V2 dropdown/popover surface (white card, hairline
border, soft shadow, rounded-md), not an inverted chip. */
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
/* Render below the trigger: a CSS pseudo-tooltip can't escape the drawer's
overflow containers, and "above" clips on the top row / collapsed rail. */
top: calc(100% + 0.35rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--card);
color: var(--foreground);
font-family: var(--font-body);
font-size: 0.6875rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--muted-foreground);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: color-mix(in oklch, var(--destructive) 10%, transparent);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.25rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.1875rem;
height: 1.5rem;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
/* Empty/error card — clean white card with hairline border and the V2
radius-lg, same card vocabulary as the locked state. */
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
/* Locked state — same panel chrome (white surface, hairline right border,
header rhythm) as the unlocked drawer, with a single clean card that speaks
the V2 card vocabulary: rounded-lg, hairline border, muted icon chip, the
add-intelligence command in a muted code well, and a near-black primary CTA
pill matching the sidebar's send button. */
.lockedPanel {
display: flex;
width: 20rem;
flex-shrink: 0;
flex-direction: column;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
}
.lockedBody {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
padding: 1rem;
}
.lockedCard {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.lockedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
}
.lockedHeading {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.lockedTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.lockedDescription {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.lockedCommand {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
}
.lockedCommandCode {
font-family: var(--font-code);
font-size: 0.75rem;
white-space: nowrap;
color: var(--secondary-foreground);
}
.lockedCta {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.25rem;
padding: 0 0.875rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 140ms ease;
}
.lockedCta:hover {
opacity: 0.9;
}
.lockedCta:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
radius-xl, soft elevated shadow. */
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
color: var(--foreground);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.4rem;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1.1rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.dialogButtonSecondary {
background: var(--card);
border: 1px solid var(--border);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--accent);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--accent);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
and a fixed-width panel leaves a dead column). */
.lockedPanel {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -1,586 +0,0 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useThreads } from "@copilotkit/react-core/v2";
import styles from "./threads-drawer.module.css";
export interface ThreadsDrawerProps {
agentId: string;
threadId: string | undefined;
onThreadChange: (threadId: string | undefined) => void;
}
interface DrawerThread {
id: string;
name: string | null;
updatedAt: string;
archived: boolean;
lastRunAt?: string;
}
const THREAD_ENTRY_ANIMATION_MS = 420;
const TITLE_ANIMATION_MS = 360;
const UNTITLED_THREAD_LABEL = "New thread";
const RUNTIME_BASE_PATH = "/api/copilotkit";
function formatThreadTimestamp(updatedAt: string): string {
const timestamp = new Date(updatedAt);
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
function cx(...classNames: Array<string | false | undefined>): string {
return classNames.filter(Boolean).join(" ");
}
export default function ThreadsDrawer({
agentId,
threadId,
onThreadChange,
}: ThreadsDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
// Start collapsed on narrow screens (tablet + phone) so the panel — which
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
// chat on load. The drawer is client-mounted, so reading window here is safe
// and won't cause a hydration mismatch.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.innerWidth > 1024,
);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
} | null>(null);
const deleteTriggerRef = useRef<HTMLElement | null>(null);
const {
threads,
archiveThread,
deleteThread,
error,
isLoading,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId,
includeArchived: showArchived,
limit: 20,
});
const restoreThread = useCallback(
async (id: string) => {
const response = await fetch(
`${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId],
);
const hasMountedRef = useRef(false);
const hasLoadedOnceRef = useRef(false);
const stableThreadsRef = useRef<DrawerThread[]>(threads);
const previousThreadIdsRef = useRef<Set<string>>(new Set());
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
if (!isLoading) {
hasLoadedOnceRef.current = true;
stableThreadsRef.current = threads;
}
const displayThreads: DrawerThread[] =
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
const [enteringThreadIds, setEnteringThreadIds] = useState<
Record<string, true>
>({});
const [revealedTitleIds, setRevealedTitleIds] = useState<
Record<string, true>
>({});
useEffect(() => {
return () => {
for (const timeoutId of entryTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
for (const timeoutId of titleTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
// Skip diffing while the store is refetching (e.g. after a filter change
// clears the list). Otherwise every thread would be treated as newly
// added once the new page lands.
if (isLoading) return;
const nextThreadIds = new Set(threads.map((t) => t.id));
if (!hasMountedRef.current) {
hasMountedRef.current = true;
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
return;
}
const addedThreadIds = threads
.filter((t) => !previousThreadIdsRef.current.has(t.id))
.map((t) => t.id);
if (addedThreadIds.length > 0) {
setEnteringThreadIds((current) => {
const next = { ...current };
for (const id of addedThreadIds) {
next[id] = true;
const existing = entryTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setEnteringThreadIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
entryTimeoutsRef.current.delete(id);
}, THREAD_ENTRY_ANIMATION_MS);
entryTimeoutsRef.current.set(id, tid);
}
return next;
});
}
const renamedThreadIds = threads
.filter((t) => {
// Only reveal when an already-tracked thread's name transitions from
// null → named. Threads appearing for the first time (e.g. on a
// filter switch) already have their final name and should not trigger
// the title reveal animation — that would layer a blur/translateY
// onto the row's enter animation and produce a visible jitter.
if (!previousNamesRef.current.has(t.id)) return false;
const prev = previousNamesRef.current.get(t.id) ?? null;
return prev === null && t.name !== null;
})
.map((t) => t.id);
if (renamedThreadIds.length > 0) {
setRevealedTitleIds((current) => {
const next = { ...current };
for (const id of renamedThreadIds) {
next[id] = true;
const existing = titleTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setRevealedTitleIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
titleTimeoutsRef.current.delete(id);
}, TITLE_ANIMATION_MS);
titleTimeoutsRef.current.set(id, tid);
}
return next;
});
}
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
}, [threads, isLoading]);
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
if (error) {
console.error("Unable to load threads", error);
}
if (!isOpen) {
return (
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerClosed)}
>
<div className={styles.collapsedRail}>
{/* Native title here (not the styled ::after): the collapsed rail
sits at the viewport's left edge where a centered tooltip clips. */}
<button
aria-label="Open threads drawer"
title="Expand"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(true)}
>
<ChevronRight size={18} />
</button>
<button
aria-label="Create thread"
title="New thread"
className={styles.iconButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={18} />
</button>
</div>
</aside>
);
}
const closeDeleteDialog = () => {
setPendingDelete(null);
const trigger = deleteTriggerRef.current;
deleteTriggerRef.current = null;
trigger?.focus?.();
};
return (
<>
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerOpen)}
>
<div className={styles.drawerSurface}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
<div className={styles.headerActions}>
<button
aria-label="Create thread"
className={styles.newThreadButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={14} />
<span>New thread</span>
</button>
<button
aria-label="Collapse threads drawer"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(false)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
<div className={styles.filterBar}>
<div
aria-label="Thread filter"
className={styles.segmented}
role="tablist"
>
<button
aria-selected={!showArchived}
className={cx(
styles.segmentedOption,
!showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(false)}
>
Active
</button>
<button
aria-selected={showArchived}
className={cx(
styles.segmentedOption,
showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(true)}
>
All
</button>
</div>
</div>
<div className={styles.drawerContent}>
{error ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>
Couldn&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -1,52 +0,0 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
{
variants: {
variant: {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
outline:
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--secondary)]",
ghost:
"hover:bg-[var(--secondary)] hover:text-[var(--secondary-foreground)]",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -1,85 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};
@@ -1,7 +0,0 @@
import { clsx } from "clsx";
import type { ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -13,9 +13,9 @@
"postinstall": "npm run install:agent"
},
"dependencies": {
"@copilotkit/a2ui-renderer": "1.59.1",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/a2ui-renderer": "1.56.5",
"@copilotkit/react-core": "1.56.5",
"@copilotkit/runtime": "1.56.5",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
@@ -3,7 +3,7 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { ModeToggle } from "./mode-toggle";
import { useFrontendTool } from "@copilotkit/react-core/v2";
import { useFrontendTool } from "@copilotkit/react-core";
interface ExampleLayoutProps {
chatContent: ReactNode;
@@ -42,14 +42,8 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
: "flex-1 max-lg:px-4"
}`}
>
{/* max-lg:pl-24 clears the threads drawer's floating launcher pill,
which is fixed at the top-left corner below 1024px. max-lg:pt-2.5 +
pb-0 vertically centers the logo with that launcher and the
top-right Chat/App toggle (both pinned at top-2). */}
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-24 max-lg:pt-2.5 max-lg:pb-0 flex gap-1.5 items-center align-center">
<span className="font-extrabold text-2xl pb-1.5 max-lg:pb-0">
CopilotKit
</span>
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-4 max-lg:pt-4 flex gap-1.5 items-center align-center">
<span className="font-extrabold text-2xl pb-1.5">CopilotKit</span>
<img
src="/copilotkit-logo-mark.svg"
alt="CopilotKit"
@@ -1,8 +1,2 @@
AGENT_URL=http://localhost:8123
OPENAI_API_KEY=
# --- CopilotKit Intelligence (optional; set COPILOTKIT_LICENSE_TOKEN to enable Threads — server + UI) ---
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
# INTELLIGENCE_API_KEY= # local dev: see examples/integrations/_intelligence/.env.intelligence for the seed value
@@ -0,0 +1,32 @@
import {
CopilotRuntime,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
import { handle } from "hono/vercel";
// 1. Create the CopilotRuntime instance and utilize the LangGraph AG-UI
// integration to setup the connection.
const runtime = new CopilotRuntime({
agents: {
starterAgent: new LangGraphAgent({
deploymentUrl:
process.env.AGENT_URL ||
process.env.LANGGRAPH_DEPLOYMENT_URL ||
"http://localhost:8123",
graphId: "starterAgent",
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
}),
},
runner: new InMemoryAgentRunner(),
});
// 2. Build a Hono app that handles the CopilotKit runtime requests.
const app = createCopilotEndpoint({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handle(app);
export const POST = handle(app);
@@ -6,11 +6,11 @@
*/
import {
CopilotRuntime,
InMemoryAgentRunner,
createCopilotEndpoint,
} from "@copilotkit/runtime/v2";
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent } from "@ag-ui/client";
import { handle } from "hono/vercel";
import type { NextRequest } from "next/server";
const agentUrl = process.env.AGENT_URL || "http://localhost:8123";
@@ -18,15 +18,14 @@ const defaultAgent = new HttpAgent({
url: `${agentUrl}/`,
});
const runtime = new CopilotRuntime({
agents: { default: defaultAgent },
runner: new InMemoryAgentRunner(),
});
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
endpoint: "/api/copilotkit",
serviceAdapter: new ExperimentalEmptyAdapter(),
runtime: new CopilotRuntime({
agents: { default: defaultAgent },
}),
});
const app = createCopilotEndpoint({
runtime,
basePath: "/api/copilotkit",
});
export const GET = handle(app);
export const POST = handle(app);
return handleRequest(req);
};
@@ -3,19 +3,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["@copilotkit/runtime"],
env: {
// The public Threads UI flag is DERIVED from the server-side license token.
// Set COPILOTKIT_LICENSE_TOKEN (only) to enable Threads — do not set this flag
// directly. NOTE: NEXT_PUBLIC_* resolves at BUILD time while the runtime reads
// the token per-request, so the UI gate and runtime agree only when the token is
// present at build time (the standard `next dev` / host-build flow). For a
// standalone/Docker image built without the token and injected at runtime, set
// COPILOTKIT_LICENSE_TOKEN at build time too (or gate the UI at runtime) so the
// baked flag reflects it.
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
typescript: {
// Docker route override uses HttpAgent which has a type mismatch with CopilotRuntime
ignoreBuildErrors: true,
File diff suppressed because it is too large Load Diff
@@ -14,9 +14,9 @@
"postinstall": "npm run install:agent"
},
"dependencies": {
"@copilotkit/a2ui-renderer": "1.59.1",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/a2ui-renderer": "1.56.5",
"@copilotkit/react-core": "1.56.5",
"@copilotkit/runtime": "1.56.5",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
@@ -1,6 +1,5 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
@@ -18,22 +17,7 @@ const defaultAgent = new LangGraphAgent({
const runtime = new CopilotRuntime({
agents: { default: defaultAgent },
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your real auth-derived user identity before any
// multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
runner: new InMemoryAgentRunner(),
openGenerativeUI: true,
a2ui: {
injectA2UITool: false,
@@ -56,5 +40,3 @@ const app = createCopilotEndpoint({
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -1,57 +1,24 @@
"use client";
import { useState } from "react";
import { ExampleLayout } from "@/components/example-layout";
import { ExampleCanvas } from "@/components/example-canvas";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
import {
CopilotChat,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
import { CopilotChat } from "@copilotkit/react-core/v2";
export default function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
const [threadId, setThreadId] = useState<string | undefined>(undefined);
return (
<div className={styles.layout}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId="default"
threadId={threadId}
onThreadChange={setThreadId}
<ExampleLayout
chatContent={
<CopilotChat
attachments={{ enabled: true }}
input={{ disclaimer: () => null, className: "pb-6" }}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
{/*
Wrap both the chat and the canvas in one CopilotChatConfigurationProvider
so they share the active threadId. `useAgent()` falls back to the
provider's threadId when called without an explicit one, which makes
the canvas read from the same per-thread agent clone that the chat's
/connect replay populates. Without this wrapper, the canvas resolves
to the registry agent and never receives STATE_SNAPSHOT events on
thread resume.
*/}
<CopilotChatConfigurationProvider agentId="default" threadId={threadId}>
<ExampleLayout
chatContent={
<CopilotChat
attachments={{ enabled: true }}
input={{ disclaimer: () => null, className: "pb-6" }}
/>
}
appContent={<ExampleCanvas />}
/>
</CopilotChatConfigurationProvider>
</div>
</div>
}
appContent={<ExampleCanvas />}
/>
);
}
@@ -3,7 +3,7 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { ModeToggle } from "./mode-toggle";
import { useFrontendTool } from "@copilotkit/react-core/v2";
import { useFrontendTool } from "@copilotkit/react-core";
interface ExampleLayoutProps {
chatContent: ReactNode;
@@ -42,14 +42,8 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
: "flex-1 max-lg:px-4"
}`}
>
{/* max-lg:pl-24 clears the threads drawer's floating launcher pill,
which is fixed at the top-left corner below 1024px. max-lg:pt-2.5 +
pb-0 vertically centers the logo with that launcher and the
top-right Chat/App toggle (both pinned at top-2). */}
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-24 max-lg:pt-2.5 max-lg:pb-0 flex gap-1.5 items-center align-center">
<span className="font-extrabold text-2xl pb-1.5 max-lg:pb-0">
CopilotKit
</span>
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-4 max-lg:pt-4 flex gap-1.5 items-center align-center">
<span className="font-extrabold text-2xl pb-1.5">CopilotKit</span>
<img
src="/copilotkit-logo-mark.svg"
alt="CopilotKit"
@@ -1,53 +0,0 @@
# Threads Drawer — Theming Contract
The threads-drawer is a BASE component. It is fully driven by CSS variables and
contains no hardcoded colors, shadows, or surface radii. To theme it for an
example, (re)define the tokens below on any ancestor (e.g. `:root`, `body`, or a
wrapper element) — **never edit the drawer files**.
The drawer first consumes the shared design-system tokens (`--card`,
`--border`, `--radius`, …) that `ui/card.tsx` and `ui/button.tsx` also consume.
For a handful of drawer-specific visuals (scrim, shadows, delete-hover tint) it
exposes dedicated `--threads-*` tokens, each with a fallback to a shared token or
the original literal — so defining nothing reproduces the default look exactly.
## Shared design-system tokens consumed
| Token | Controls |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--background` | Tooltip text color (`color: var(--background)` on the dark tooltip body) |
| `--foreground` | Drawer/dialog title + body text, active segment text, tooltip surface bg |
| `--card` | Drawer surface bg (via `--threads-drawer-bg`), active segment bg, empty/dialog/load-more bg |
| `--border` | Drawer + header + filter + dialog borders, thread accent (idle), selected-row inset ring, secondary-button hover bg, tooltip border |
| `--radius` | Drawer/dialog/button/segment/thread/empty-card radii; tooltip radius derives from it |
| `--primary` | New-thread button bg, primary dialog button bg, selected thread accent |
| `--primary-foreground` | New-thread button text, primary dialog button text |
| `--secondary` | Icon-button + thread-row + load-more hover bg, segment track, archived badge bg, secondary dialog button bg, loading skeleton bars, thread-enter start bg |
| `--secondary-foreground` | (locked-state) inline code text |
| `--muted-foreground` | Icon-button idle color, segment idle text, meta text, placeholder/archived titles, empty/dialog description, load-more text, collapsed-rail icon |
| `--accent` | Selected thread-row bg |
| `--ring` | Focus-visible outline on buttons, thread items, segments, dialog buttons |
| `--destructive` | Delete-button icon color + delete-hover text |
| `--destructive-foreground` | Destructive dialog button text |
| `--font-body` | Header, segments, tooltip, empty card, and dialog typography |
(locked-state additionally uses `--secondary`, `--muted-foreground`, `--border`,
`--radius`, `--secondary-foreground`, and the `ui/card` + `ui/button` tokens via
those components.)
## Drawer-specific tokens (with fallbacks)
| Token | Controls | Fallback |
| --------------------------------- | ----------------------------------------- | --------------------------------------------------------- |
| `--threads-drawer-bg` | Drawer surface background | `var(--card)` |
| `--threads-drawer-border` | Drawer right border color | `var(--border)` |
| `--threads-drawer-shadow` | Open-drawer drop shadow | `4px 0 20px rgb(0 0 0 / 0.04)` |
| `--threads-segment-active-shadow` | Active filter-segment shadow | `0 1px 2px rgb(0 0 0 / 0.06)` |
| `--threads-delete-hover-bg` | Delete-button hover/focus background tint | `color-mix(in srgb, var(--destructive) 10%, transparent)` |
| `--threads-overlay-bg` | Confirm-dialog overlay scrim | `rgb(0 0 0 / 0.5)` |
| `--threads-dialog-shadow` | Confirm-dialog drop shadow | `0 20px 50px rgb(0 0 0 / 0.25)` |
| `--threads-tooltip-radius` | Action-button tooltip corner radius | `calc(var(--radius) - 0.45rem)` (= `0.3rem` at default) |
All fallbacks resolve to the original hardcoded values in the north-star, so an
example that defines none of the `--threads-*` tokens renders pixel-identical to
the pre-tokenization drawer.
@@ -1,4 +0,0 @@
"use client";
export { default as ThreadsDrawer } from "./threads-drawer";
export type { ThreadsDrawerProps } from "./threads-drawer";
@@ -1,91 +0,0 @@
"use client";
import * as React from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import styles from "./threads-drawer.module.css";
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
// The Threads drawer reads a client-only external store (useThreads /
// useSyncExternalStore) with no server snapshot, so it must not render during
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (process.env.NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED === "true") {
if (!mounted) {
// SSR / first-paint placeholder: matches the open drawer's footprint +
// surface (and collapses to nothing on mobile) so the panel doesn't flash
// a bare-background column or shift the content when the drawer mounts.
return <div className={styles.drawerPlaceholder} aria-hidden />;
}
return <>{children}</>;
}
return (
<div className="flex w-80 shrink-0 flex-col items-center justify-center p-4 bg-[var(--threads-drawer-bg,var(--card))] border-r border-[var(--threads-drawer-border,var(--border))] max-lg:hidden">
<Card className="w-full">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--muted-foreground)]"
aria-hidden="true"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<CardTitle>Threads</CardTitle>
<CardDescription>
Threads is a licensed CopilotKit Intelligence feature. Unlock
persistent conversation history, multi-session context, and thread
management across your application.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted-foreground)]">
To enable Threads, add a CopilotKit Intelligence license to your
project with:
</p>
</CardContent>
<CardFooter className="flex-col items-start gap-3">
<div className="w-full rounded-[var(--radius)] border border-[var(--border)] bg-[var(--secondary)] px-3 py-2">
<code className="text-xs whitespace-nowrap text-[var(--secondary-foreground)]">
copilotkit add-intelligence
</code>
</div>
<Button
variant="default"
size="sm"
className="w-full"
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -1,738 +0,0 @@
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
box-shadow: var(--threads-drawer-shadow, 4px 0 20px rgb(0 0 0 / 0.04));
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
font-family: var(--font-body);
}
.drawerTitle {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: var(--radius);
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--secondary);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
}
.segmented {
display: inline-flex;
width: 100%;
padding: 0.2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--secondary);
gap: 0.15rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: calc(var(--radius) - 0.15rem);
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-segment-active-shadow, 0 1px 2px rgb(0 0 0 / 0.06));
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.75rem 0.5rem;
}
.threadRow {
position: relative;
}
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
box-shadow 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--secondary);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected {
background: var(--accent);
box-shadow: inset 0 0 0 1px var(--border);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.threadAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--border);
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
font-weight: 600;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 500;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7rem;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 500;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
.archivedBadge {
display: inline-block;
margin-left: 0.35rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--secondary);
font-size: 0.6rem;
font-weight: 600;
color: var(--muted-foreground);
vertical-align: middle;
}
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.25rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-size: 0.78rem;
font-weight: 600;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--secondary);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.4rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.15rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 0.3rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.2rem 0.45rem;
border-radius: var(--threads-tooltip-radius, calc(var(--radius) - 0.45rem));
border: 1px solid var(--border);
background: var(--foreground);
color: var(--background);
font-family: var(--font-body);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--destructive);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: var(
--threads-delete-hover-bg,
color-mix(in srgb, var(--destructive) 10%, transparent)
);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.2rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--muted-foreground);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--threads-overlay-bg, rgb(0 0 0 / 0.5));
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.1rem 1.1rem 1rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 0.25rem);
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-dialog-shadow, 0 20px 50px rgb(0 0 0 / 0.25));
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1rem;
font-size: 0.82rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.dialogButtonSecondary {
background: var(--secondary);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--border);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--secondary);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.125rem;
padding: 0.1875rem;
overflow: visible;
border-radius: 999px;
background: var(--threads-drawer-bg, var(--card));
border: 1px solid var(--threads-drawer-border, var(--border));
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Trim the launcher's icon buttons so the pill's height lines up with the
top-right Chat/App toggle instead of towering over it. */
.drawerClosed .collapsedRail .iconButton {
width: 1.75rem;
height: 1.75rem;
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -1,586 +0,0 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useThreads } from "@copilotkit/react-core/v2";
import styles from "./threads-drawer.module.css";
export interface ThreadsDrawerProps {
agentId: string;
threadId: string | undefined;
onThreadChange: (threadId: string | undefined) => void;
}
interface DrawerThread {
id: string;
name: string | null;
updatedAt: string;
archived: boolean;
lastRunAt?: string;
}
const THREAD_ENTRY_ANIMATION_MS = 420;
const TITLE_ANIMATION_MS = 360;
const UNTITLED_THREAD_LABEL = "New thread";
const RUNTIME_BASE_PATH = "/api/copilotkit";
function formatThreadTimestamp(updatedAt: string): string {
const timestamp = new Date(updatedAt);
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
function cx(...classNames: Array<string | false | undefined>): string {
return classNames.filter(Boolean).join(" ");
}
export default function ThreadsDrawer({
agentId,
threadId,
onThreadChange,
}: ThreadsDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
// Start collapsed on narrow screens (tablet + phone) so the panel — which
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
// chat on load. The drawer is client-mounted, so reading window here is safe
// and won't cause a hydration mismatch.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.innerWidth > 1024,
);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
} | null>(null);
const deleteTriggerRef = useRef<HTMLElement | null>(null);
const {
threads,
archiveThread,
deleteThread,
error,
isLoading,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId,
includeArchived: showArchived,
limit: 20,
});
const restoreThread = useCallback(
async (id: string) => {
const response = await fetch(
`${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId],
);
const hasMountedRef = useRef(false);
const hasLoadedOnceRef = useRef(false);
const stableThreadsRef = useRef<DrawerThread[]>(threads);
const previousThreadIdsRef = useRef<Set<string>>(new Set());
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
if (!isLoading) {
hasLoadedOnceRef.current = true;
stableThreadsRef.current = threads;
}
const displayThreads: DrawerThread[] =
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
const [enteringThreadIds, setEnteringThreadIds] = useState<
Record<string, true>
>({});
const [revealedTitleIds, setRevealedTitleIds] = useState<
Record<string, true>
>({});
useEffect(() => {
return () => {
for (const timeoutId of entryTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
for (const timeoutId of titleTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
// Skip diffing while the store is refetching (e.g. after a filter change
// clears the list). Otherwise every thread would be treated as newly
// added once the new page lands.
if (isLoading) return;
const nextThreadIds = new Set(threads.map((t) => t.id));
if (!hasMountedRef.current) {
hasMountedRef.current = true;
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
return;
}
const addedThreadIds = threads
.filter((t) => !previousThreadIdsRef.current.has(t.id))
.map((t) => t.id);
if (addedThreadIds.length > 0) {
setEnteringThreadIds((current) => {
const next = { ...current };
for (const id of addedThreadIds) {
next[id] = true;
const existing = entryTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setEnteringThreadIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
entryTimeoutsRef.current.delete(id);
}, THREAD_ENTRY_ANIMATION_MS);
entryTimeoutsRef.current.set(id, tid);
}
return next;
});
}
const renamedThreadIds = threads
.filter((t) => {
// Only reveal when an already-tracked thread's name transitions from
// null → named. Threads appearing for the first time (e.g. on a
// filter switch) already have their final name and should not trigger
// the title reveal animation — that would layer a blur/translateY
// onto the row's enter animation and produce a visible jitter.
if (!previousNamesRef.current.has(t.id)) return false;
const prev = previousNamesRef.current.get(t.id) ?? null;
return prev === null && t.name !== null;
})
.map((t) => t.id);
if (renamedThreadIds.length > 0) {
setRevealedTitleIds((current) => {
const next = { ...current };
for (const id of renamedThreadIds) {
next[id] = true;
const existing = titleTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setRevealedTitleIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
titleTimeoutsRef.current.delete(id);
}, TITLE_ANIMATION_MS);
titleTimeoutsRef.current.set(id, tid);
}
return next;
});
}
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
}, [threads, isLoading]);
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
if (error) {
console.error("Unable to load threads", error);
}
if (!isOpen) {
return (
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerClosed)}
>
<div className={styles.collapsedRail}>
{/* Native title here (not the styled ::after): the collapsed rail
sits at the viewport's left edge where a centered tooltip clips. */}
<button
aria-label="Open threads drawer"
title="Expand"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(true)}
>
<ChevronRight size={18} />
</button>
<button
aria-label="Create thread"
title="New thread"
className={styles.iconButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={18} />
</button>
</div>
</aside>
);
}
const closeDeleteDialog = () => {
setPendingDelete(null);
const trigger = deleteTriggerRef.current;
deleteTriggerRef.current = null;
trigger?.focus?.();
};
return (
<>
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerOpen)}
>
<div className={styles.drawerSurface}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
<div className={styles.headerActions}>
<button
aria-label="Create thread"
className={styles.newThreadButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={14} />
<span>New thread</span>
</button>
<button
aria-label="Collapse threads drawer"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(false)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
<div className={styles.filterBar}>
<div
aria-label="Thread filter"
className={styles.segmented}
role="tablist"
>
<button
aria-selected={!showArchived}
className={cx(
styles.segmentedOption,
!showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(false)}
>
Active
</button>
<button
aria-selected={showArchived}
className={cx(
styles.segmentedOption,
showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(true)}
>
All
</button>
</div>
</div>
<div className={styles.drawerContent}>
{error ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>
Couldn&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -1,8 +1,2 @@
AGENT_URL=http://localhost:8123
OPENAI_API_KEY=
# --- CopilotKit Intelligence (optional; set COPILOTKIT_LICENSE_TOKEN to enable Threads — server + UI) ---
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
# INTELLIGENCE_API_KEY= # local dev: see examples/integrations/_intelligence/.env.intelligence for the seed value
OPENAI_API_KEY=
@@ -3,19 +3,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["@copilotkit/runtime"],
env: {
// The public Threads UI flag is DERIVED from the server-side license token.
// Set COPILOTKIT_LICENSE_TOKEN (only) to enable Threads — do not set this flag
// directly. NOTE: NEXT_PUBLIC_* resolves at BUILD time while the runtime reads
// the token per-request, so the UI gate and runtime agree only when the token is
// present at build time (the standard `next dev` / host-build flow). For a
// standalone/Docker image built without the token and injected at runtime, set
// COPILOTKIT_LICENSE_TOKEN at build time too (or gate the UI at runtime) so the
// baked flag reflects it.
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
typescript: {
// Docker route override uses HttpAgent which has a type mismatch with CopilotRuntime
ignoreBuildErrors: true,
File diff suppressed because it is too large Load Diff
@@ -13,9 +13,9 @@
"postinstall": "npm run install:agent"
},
"dependencies": {
"@copilotkit/a2ui-renderer": "1.59.1",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/a2ui-renderer": "1.56.5",
"@copilotkit/react-core": "1.56.5",
"@copilotkit/runtime": "1.56.5",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
@@ -1,6 +1,5 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
@@ -18,22 +17,7 @@ const defaultAgent = new LangGraphAgent({
const runtime = new CopilotRuntime({
agents: { default: defaultAgent },
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your real auth-derived user identity before any
// multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
runner: new InMemoryAgentRunner(),
openGenerativeUI: true,
a2ui: {
injectA2UITool: false,
@@ -56,5 +40,3 @@ const app = createCopilotEndpoint({
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -1,57 +1,24 @@
"use client";
import { useState } from "react";
import { ExampleLayout } from "@/components/example-layout";
import { ExampleCanvas } from "@/components/example-canvas";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
import {
CopilotChat,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
import { CopilotChat } from "@copilotkit/react-core/v2";
export default function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
const [threadId, setThreadId] = useState<string | undefined>(undefined);
return (
<div className={styles.layout}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId="default"
threadId={threadId}
onThreadChange={setThreadId}
<ExampleLayout
chatContent={
<CopilotChat
attachments={{ enabled: true }}
input={{ disclaimer: () => null, className: "pb-6" }}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
{/*
Wrap both the chat and the canvas in one CopilotChatConfigurationProvider
so they share the active threadId. `useAgent()` falls back to the
provider's threadId when called without an explicit one, which makes
the canvas read from the same per-thread agent clone that the chat's
/connect replay populates. Without this wrapper, the canvas resolves
to the registry agent and never receives STATE_SNAPSHOT events on
thread resume.
*/}
<CopilotChatConfigurationProvider agentId="default" threadId={threadId}>
<ExampleLayout
chatContent={
<CopilotChat
attachments={{ enabled: true }}
input={{ disclaimer: () => null, className: "pb-6" }}
/>
}
appContent={<ExampleCanvas />}
/>
</CopilotChatConfigurationProvider>
</div>
</div>
}
appContent={<ExampleCanvas />}
/>
);
}
@@ -42,14 +42,8 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
: "flex-1 max-lg:px-4"
}`}
>
{/* max-lg:pl-24 clears the threads drawer's floating launcher pill,
which is fixed at the top-left corner below 1024px. max-lg:pt-2.5 +
pb-0 vertically centers the logo with that launcher and the
top-right Chat/App toggle (both pinned at top-2). */}
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-24 max-lg:pt-2.5 max-lg:pb-0 flex gap-1.5 items-center align-center">
<span className="font-extrabold text-2xl pb-1.5 max-lg:pb-0">
CopilotKit
</span>
<div className="shrink-0 pt-6 pl-6 pb-2 max-lg:pl-4 max-lg:pt-4 flex gap-1.5 items-center align-center">
<span className="font-extrabold text-2xl pb-1.5">CopilotKit</span>
<img
src="/copilotkit-logo-mark.svg"
alt="CopilotKit"
@@ -1,53 +0,0 @@
# Threads Drawer — Theming Contract
The threads-drawer is a BASE component. It is fully driven by CSS variables and
contains no hardcoded colors, shadows, or surface radii. To theme it for an
example, (re)define the tokens below on any ancestor (e.g. `:root`, `body`, or a
wrapper element) — **never edit the drawer files**.
The drawer first consumes the shared design-system tokens (`--card`,
`--border`, `--radius`, …) that `ui/card.tsx` and `ui/button.tsx` also consume.
For a handful of drawer-specific visuals (scrim, shadows, delete-hover tint) it
exposes dedicated `--threads-*` tokens, each with a fallback to a shared token or
the original literal — so defining nothing reproduces the default look exactly.
## Shared design-system tokens consumed
| Token | Controls |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--background` | Tooltip text color (`color: var(--background)` on the dark tooltip body) |
| `--foreground` | Drawer/dialog title + body text, active segment text, tooltip surface bg |
| `--card` | Drawer surface bg (via `--threads-drawer-bg`), active segment bg, empty/dialog/load-more bg |
| `--border` | Drawer + header + filter + dialog borders, thread accent (idle), selected-row inset ring, secondary-button hover bg, tooltip border |
| `--radius` | Drawer/dialog/button/segment/thread/empty-card radii; tooltip radius derives from it |
| `--primary` | New-thread button bg, primary dialog button bg, selected thread accent |
| `--primary-foreground` | New-thread button text, primary dialog button text |
| `--secondary` | Icon-button + thread-row + load-more hover bg, segment track, archived badge bg, secondary dialog button bg, loading skeleton bars, thread-enter start bg |
| `--secondary-foreground` | (locked-state) inline code text |
| `--muted-foreground` | Icon-button idle color, segment idle text, meta text, placeholder/archived titles, empty/dialog description, load-more text, collapsed-rail icon |
| `--accent` | Selected thread-row bg |
| `--ring` | Focus-visible outline on buttons, thread items, segments, dialog buttons |
| `--destructive` | Delete-button icon color + delete-hover text |
| `--destructive-foreground` | Destructive dialog button text |
| `--font-body` | Header, segments, tooltip, empty card, and dialog typography |
(locked-state additionally uses `--secondary`, `--muted-foreground`, `--border`,
`--radius`, `--secondary-foreground`, and the `ui/card` + `ui/button` tokens via
those components.)
## Drawer-specific tokens (with fallbacks)
| Token | Controls | Fallback |
| --------------------------------- | ----------------------------------------- | --------------------------------------------------------- |
| `--threads-drawer-bg` | Drawer surface background | `var(--card)` |
| `--threads-drawer-border` | Drawer right border color | `var(--border)` |
| `--threads-drawer-shadow` | Open-drawer drop shadow | `4px 0 20px rgb(0 0 0 / 0.04)` |
| `--threads-segment-active-shadow` | Active filter-segment shadow | `0 1px 2px rgb(0 0 0 / 0.06)` |
| `--threads-delete-hover-bg` | Delete-button hover/focus background tint | `color-mix(in srgb, var(--destructive) 10%, transparent)` |
| `--threads-overlay-bg` | Confirm-dialog overlay scrim | `rgb(0 0 0 / 0.5)` |
| `--threads-dialog-shadow` | Confirm-dialog drop shadow | `0 20px 50px rgb(0 0 0 / 0.25)` |
| `--threads-tooltip-radius` | Action-button tooltip corner radius | `calc(var(--radius) - 0.45rem)` (= `0.3rem` at default) |
All fallbacks resolve to the original hardcoded values in the north-star, so an
example that defines none of the `--threads-*` tokens renders pixel-identical to
the pre-tokenization drawer.
@@ -1,4 +0,0 @@
"use client";
export { default as ThreadsDrawer } from "./threads-drawer";
export type { ThreadsDrawerProps } from "./threads-drawer";
@@ -1,91 +0,0 @@
"use client";
import * as React from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import styles from "./threads-drawer.module.css";
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
// The Threads drawer reads a client-only external store (useThreads /
// useSyncExternalStore) with no server snapshot, so it must not render during
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (process.env.NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED === "true") {
if (!mounted) {
// SSR / first-paint placeholder: matches the open drawer's footprint +
// surface (and collapses to nothing on mobile) so the panel doesn't flash
// a bare-background column or shift the content when the drawer mounts.
return <div className={styles.drawerPlaceholder} aria-hidden />;
}
return <>{children}</>;
}
return (
<div className="flex w-80 shrink-0 flex-col items-center justify-center p-4 bg-[var(--threads-drawer-bg,var(--card))] border-r border-[var(--threads-drawer-border,var(--border))] max-lg:hidden">
<Card className="w-full">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--muted-foreground)]"
aria-hidden="true"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<CardTitle>Threads</CardTitle>
<CardDescription>
Threads is a licensed CopilotKit Intelligence feature. Unlock
persistent conversation history, multi-session context, and thread
management across your application.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted-foreground)]">
To enable Threads, add a CopilotKit Intelligence license to your
project with:
</p>
</CardContent>
<CardFooter className="flex-col items-start gap-3">
<div className="w-full rounded-[var(--radius)] border border-[var(--border)] bg-[var(--secondary)] px-3 py-2">
<code className="text-xs whitespace-nowrap text-[var(--secondary-foreground)]">
copilotkit add-intelligence
</code>
</div>
<Button
variant="default"
size="sm"
className="w-full"
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -1,738 +0,0 @@
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
box-shadow: var(--threads-drawer-shadow, 4px 0 20px rgb(0 0 0 / 0.04));
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
font-family: var(--font-body);
}
.drawerTitle {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: var(--radius);
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--secondary);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
}
.segmented {
display: inline-flex;
width: 100%;
padding: 0.2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--secondary);
gap: 0.15rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: calc(var(--radius) - 0.15rem);
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-segment-active-shadow, 0 1px 2px rgb(0 0 0 / 0.06));
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.75rem 0.5rem;
}
.threadRow {
position: relative;
}
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
box-shadow 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--secondary);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected {
background: var(--accent);
box-shadow: inset 0 0 0 1px var(--border);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.threadAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--border);
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
font-weight: 600;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 500;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7rem;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 500;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
.archivedBadge {
display: inline-block;
margin-left: 0.35rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--secondary);
font-size: 0.6rem;
font-weight: 600;
color: var(--muted-foreground);
vertical-align: middle;
}
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.25rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-size: 0.78rem;
font-weight: 600;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--secondary);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.4rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.15rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 0.3rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.2rem 0.45rem;
border-radius: var(--threads-tooltip-radius, calc(var(--radius) - 0.45rem));
border: 1px solid var(--border);
background: var(--foreground);
color: var(--background);
font-family: var(--font-body);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--destructive);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: var(
--threads-delete-hover-bg,
color-mix(in srgb, var(--destructive) 10%, transparent)
);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.2rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--muted-foreground);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--threads-overlay-bg, rgb(0 0 0 / 0.5));
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.1rem 1.1rem 1rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 0.25rem);
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-dialog-shadow, 0 20px 50px rgb(0 0 0 / 0.25));
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1rem;
font-size: 0.82rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.dialogButtonSecondary {
background: var(--secondary);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--border);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--secondary);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.125rem;
padding: 0.1875rem;
overflow: visible;
border-radius: 999px;
background: var(--threads-drawer-bg, var(--card));
border: 1px solid var(--threads-drawer-border, var(--border));
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Trim the launcher's icon buttons so the pill's height lines up with the
top-right Chat/App toggle instead of towering over it. */
.drawerClosed .collapsedRail .iconButton {
width: 1.75rem;
height: 1.75rem;
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -1,586 +0,0 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useThreads } from "@copilotkit/react-core/v2";
import styles from "./threads-drawer.module.css";
export interface ThreadsDrawerProps {
agentId: string;
threadId: string | undefined;
onThreadChange: (threadId: string | undefined) => void;
}
interface DrawerThread {
id: string;
name: string | null;
updatedAt: string;
archived: boolean;
lastRunAt?: string;
}
const THREAD_ENTRY_ANIMATION_MS = 420;
const TITLE_ANIMATION_MS = 360;
const UNTITLED_THREAD_LABEL = "New thread";
const RUNTIME_BASE_PATH = "/api/copilotkit";
function formatThreadTimestamp(updatedAt: string): string {
const timestamp = new Date(updatedAt);
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
function cx(...classNames: Array<string | false | undefined>): string {
return classNames.filter(Boolean).join(" ");
}
export default function ThreadsDrawer({
agentId,
threadId,
onThreadChange,
}: ThreadsDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
// Start collapsed on narrow screens (tablet + phone) so the panel — which
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
// chat on load. The drawer is client-mounted, so reading window here is safe
// and won't cause a hydration mismatch.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.innerWidth > 1024,
);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
} | null>(null);
const deleteTriggerRef = useRef<HTMLElement | null>(null);
const {
threads,
archiveThread,
deleteThread,
error,
isLoading,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId,
includeArchived: showArchived,
limit: 20,
});
const restoreThread = useCallback(
async (id: string) => {
const response = await fetch(
`${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId],
);
const hasMountedRef = useRef(false);
const hasLoadedOnceRef = useRef(false);
const stableThreadsRef = useRef<DrawerThread[]>(threads);
const previousThreadIdsRef = useRef<Set<string>>(new Set());
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
if (!isLoading) {
hasLoadedOnceRef.current = true;
stableThreadsRef.current = threads;
}
const displayThreads: DrawerThread[] =
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
const [enteringThreadIds, setEnteringThreadIds] = useState<
Record<string, true>
>({});
const [revealedTitleIds, setRevealedTitleIds] = useState<
Record<string, true>
>({});
useEffect(() => {
return () => {
for (const timeoutId of entryTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
for (const timeoutId of titleTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
// Skip diffing while the store is refetching (e.g. after a filter change
// clears the list). Otherwise every thread would be treated as newly
// added once the new page lands.
if (isLoading) return;
const nextThreadIds = new Set(threads.map((t) => t.id));
if (!hasMountedRef.current) {
hasMountedRef.current = true;
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
return;
}
const addedThreadIds = threads
.filter((t) => !previousThreadIdsRef.current.has(t.id))
.map((t) => t.id);
if (addedThreadIds.length > 0) {
setEnteringThreadIds((current) => {
const next = { ...current };
for (const id of addedThreadIds) {
next[id] = true;
const existing = entryTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setEnteringThreadIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
entryTimeoutsRef.current.delete(id);
}, THREAD_ENTRY_ANIMATION_MS);
entryTimeoutsRef.current.set(id, tid);
}
return next;
});
}
const renamedThreadIds = threads
.filter((t) => {
// Only reveal when an already-tracked thread's name transitions from
// null → named. Threads appearing for the first time (e.g. on a
// filter switch) already have their final name and should not trigger
// the title reveal animation — that would layer a blur/translateY
// onto the row's enter animation and produce a visible jitter.
if (!previousNamesRef.current.has(t.id)) return false;
const prev = previousNamesRef.current.get(t.id) ?? null;
return prev === null && t.name !== null;
})
.map((t) => t.id);
if (renamedThreadIds.length > 0) {
setRevealedTitleIds((current) => {
const next = { ...current };
for (const id of renamedThreadIds) {
next[id] = true;
const existing = titleTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setRevealedTitleIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
titleTimeoutsRef.current.delete(id);
}, TITLE_ANIMATION_MS);
titleTimeoutsRef.current.set(id, tid);
}
return next;
});
}
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
}, [threads, isLoading]);
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
if (error) {
console.error("Unable to load threads", error);
}
if (!isOpen) {
return (
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerClosed)}
>
<div className={styles.collapsedRail}>
{/* Native title here (not the styled ::after): the collapsed rail
sits at the viewport's left edge where a centered tooltip clips. */}
<button
aria-label="Open threads drawer"
title="Expand"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(true)}
>
<ChevronRight size={18} />
</button>
<button
aria-label="Create thread"
title="New thread"
className={styles.iconButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={18} />
</button>
</div>
</aside>
);
}
const closeDeleteDialog = () => {
setPendingDelete(null);
const trigger = deleteTriggerRef.current;
deleteTriggerRef.current = null;
trigger?.focus?.();
};
return (
<>
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerOpen)}
>
<div className={styles.drawerSurface}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
<div className={styles.headerActions}>
<button
aria-label="Create thread"
className={styles.newThreadButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={14} />
<span>New thread</span>
</button>
<button
aria-label="Collapse threads drawer"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(false)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
<div className={styles.filterBar}>
<div
aria-label="Thread filter"
className={styles.segmented}
role="tablist"
>
<button
aria-selected={!showArchived}
className={cx(
styles.segmentedOption,
!showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(false)}
>
Active
</button>
<button
aria-selected={showArchived}
className={cx(
styles.segmentedOption,
showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(true)}
>
All
</button>
</div>
</div>
<div className={styles.drawerContent}>
{error ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>
Couldn&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -1,8 +1,2 @@
OPENAI_API_KEY=your-api-key-here
AGENT_URL=http://127.0.0.1:9000
# Optional: enable CopilotKit Intelligence Threads locally
COPILOTKIT_LICENSE_TOKEN=
INTELLIGENCE_API_KEY=
INTELLIGENCE_API_URL=http://localhost:4201
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
+1 -2
View File
@@ -32,7 +32,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
@@ -47,4 +46,4 @@ next-env.d.ts
# python
agent/venv/
agent/agent/__pycache__/
.venv/
.venv/
@@ -1,13 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["@copilotkit/runtime"],
env: {
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -15,17 +15,13 @@
},
"dependencies": {
"@ag-ui/llamaindex": "0.1.5",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/react-core": "1.55.2",
"@copilotkit/runtime": "1.55.2",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4",
"lucide-react": "^0.468.0",
"next": "16.0.8",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.4"
},
"devDependencies": {
@@ -1,6 +1,5 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
@@ -9,27 +8,11 @@ import { handle } from "hono/vercel";
const runtime = new CopilotRuntime({
agents: {
// @ts-expect-error - @ag-ui/llamaindex carries its own AbstractAgent private type.
default: new LlamaIndexAgent({
sample_agent: new LlamaIndexAgent({
url: (process.env.AGENT_URL || "http://127.0.0.1:9000") + "/run",
}),
},
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your own auth-derived user identity (e.g. OIDC)
// before any multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
runner: new InMemoryAgentRunner(),
});
const app = createCopilotEndpoint({
@@ -39,5 +22,3 @@ const app = createCopilotEndpoint({
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -3,33 +3,6 @@
:root {
--background: #ffffff;
--foreground: #171717;
/* Threads-panel theme-map — values pulled verbatim from CopilotKit's V2
design system (the same one the CopilotSidebar renders with). */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-body: Arial, Helvetica, sans-serif;
--font-code: "SFMono-Regular", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
@@ -45,12 +18,6 @@ body {
font-family: Arial, Helvetica, sans-serif;
}
.threadsLayout,
body > [role="presentation"] {
--foreground: oklch(0.145 0 0);
--background: oklch(1 0 0);
}
body,
html {
height: 100%;
@@ -17,14 +17,7 @@ export default function RootLayout({
return (
<html lang="en">
<body className={"antialiased"}>
{/* Force REST (path-based) transport so runtime-info and the threads
REST API both hit the multi-route endpoint. Auto-detect probes
GET /info first, which races the lazily-compiled API route in
`next dev` and can fall back to single-route (no threads support). */}
<CopilotKit
runtimeUrl="/api/copilotkit"
useSingleEndpoint={false}
>
<CopilotKit runtimeUrl="/api/copilotkit" agent="sample_agent">
{children}
</CopilotKit>
</body>
@@ -1,23 +1,17 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { z } from "zod";
import { WeatherCard } from "@/components/WeatherCard";
import {
useAgent,
useFrontendTool,
CopilotChatConfigurationProvider,
CopilotSidebar,
} from "@copilotkit/react-core/v2";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
// 🪁 Frontend Actions: https://docs.copilotkit.ai/guides/frontend-actions
useFrontendTool({
@@ -27,47 +21,28 @@ export default function CopilotKitPage() {
.string()
.describe("The theme color to set. Make sure to pick nice colors."),
}),
handler: async ({ theme_color }) => {
handler({ theme_color }) {
setThemeColor(theme_color);
return `Changing background to ${theme_color}`;
},
});
return (
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId="default"
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId="default"
threadId={threadId}
>
<main
style={
{
"--copilot-kit-primary-color": themeColor,
} as React.CSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
clickOutsideToClose={false}
defaultOpen={true}
labels={{
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
'👋 Hi, there! You\'re chatting with an agent. This agent comes with a few tools to get you started.\n\nFor example you can try:\n- **Frontend Tools**: "Set the theme to orange"\n- **Shared State**: "Write a proverb about AI"\n- **Generative UI**: "Get the weather in SF"\n\nAs you interact with the agent, you\'ll see the UI update in real-time to reflect the agent\'s **state**, **tool calls**, and **progress**.',
}}
/>
</main>
</CopilotChatConfigurationProvider>
</div>
</div>
<main
style={
{ "--copilot-kit-primary-color": themeColor } as React.CSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
clickOutsideToClose={false}
defaultOpen={true}
labels={{
title: "Popup Assistant",
initial:
'👋 Hi, there! You\'re chatting with an agent. This agent comes with a few tools to get you started.\n\nFor example you can try:\n- **Frontend Tools**: "Set the theme to orange"\n- **Shared State**: "Write a proverb about AI"\n- **Generative UI**: "Get the weather in SF"\n\nAs you interact with the agent, you\'ll see the UI update in real-time to reflect the agent\'s **state**, **tool calls**, and **progress**.',
}}
/>
</main>
);
}
@@ -78,58 +53,44 @@ type AgentState = {
function YourMainContent({ themeColor }: { themeColor: string }) {
// 🪁 Shared State: https://docs.copilotkit.ai/coagents/shared-state
// V2: useAgent returns the agent; read agent.state and write via agent.setState.
const { agent } = useAgent({ agentId: "default" });
const state = (agent.state as AgentState | undefined) ?? { proverbs: [] };
const setState = (next: AgentState) => agent.setState(next);
// Seed an initial proverb once (the V2 agent starts with empty state).
useEffect(() => {
if ((agent.state as AgentState | undefined)?.proverbs === undefined) {
agent.setState({
proverbs: [
"CopilotKit may be new, but it's the best thing since sliced bread.",
],
});
}
}, [agent]);
const { state, setState } = useAgent<AgentState>({
name: "sample_agent",
initialState: {
proverbs: [
"CopilotKit may be new, but its the best thing since sliced bread.",
],
},
});
// 🪁 Frontend Actions: https://docs.copilotkit.ai/coagents/frontend-actions
useFrontendTool(
{
name: "add_proverb",
parameters: z.object({
proverb: z
.string()
.describe("The proverb to add. Make it witty, short and concise."),
}),
handler: async ({ proverb }) => {
setState({
...state,
proverbs: [...(state?.proverbs || []), proverb],
});
return `Added proverb: ${proverb}`;
},
useFrontendTool({
name: "add_proverb",
parameters: z.object({
proverb: z
.string()
.describe("The proverb to add. Make it witty, short and concise."),
}),
handler: ({ proverb }) => {
setState({
...state,
proverbs: [...(state?.proverbs || []), proverb],
});
},
[state],
);
});
//🪁 Generative UI: https://docs.copilotkit.ai/coagents/generative-ui
useFrontendTool(
{
name: "get_weather",
description: "Get the weather for a given location.",
available: false,
parameters: z.object({
location: z.string(),
}),
render: ({ args }) => {
return <WeatherCard location={args.location} themeColor={themeColor} />;
},
followUp: false,
useFrontendTool({
name: "get_weather",
description: "Get the weather for a given location.",
available: "disabled",
parameters: z.object({
location: z.string(),
}),
render: ({ args }) => {
return <WeatherCard location={args.location} themeColor={themeColor} />;
},
[themeColor],
);
followUp: false,
});
return (
<div
@@ -1,45 +0,0 @@
# Threads Panel — Design Notes (mastra)
These are mastra's **bespoke** copies of the threads panel. They are no longer a
shared/tokenized base component — they are styled to read as one product with
mastra's `CopilotSidebar` (the right-side chat from `@copilotkit/react-core/v2`).
## Design source of truth
All surfaces, borders, radii, and type ramps are lifted from CopilotKit's V2
design system: `@copilotkit/react-core/src/v2/styles/globals.css` plus the chat
components (`CopilotModalHeader`, `CopilotChatSuggestionPill`,
`CopilotChatInput`, `CopilotSidebarView`). The tokens are mirrored verbatim into
`src/app/globals.css`:
| Token | Value (V2 light) | Role in the panel |
| -------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `--card` / `--background` | `oklch(1 0 0)` (white) | Panel + card surfaces |
| `--foreground` | `oklch(0.145 0 0)` | Titles, thread titles, dialog text |
| `--muted` / `--secondary` / `--accent` | `oklch(0.97 0 0)` | Hover/active surfaces, segment track, archived chip, code well |
| `--muted-foreground` | `oklch(0.556 0 0)` | Meta text, idle icons, descriptions, placeholders |
| `--border` / `--input` | `oklch(0.922 0 0)` | All hairline borders |
| `--primary` | `oklch(0.205 0 0)` (near-black) | New-thread pill, selected accent, primary CTA — the V2 sidebar's primary buttons are charcoal/black, **not** a brand accent |
| `--primary-foreground` | `oklch(0.985 0 0)` | Primary button text |
| `--destructive` | `oklch(0.577 0.245 27.325)` | Delete hover |
| `--ring` | `oklch(0.708 0 0)` | Focus rings (2px box-shadow) |
| `--radius` | `0.625rem` (+ sm/md/lg/xl) | Rectangular controls; icon buttons / pills / segments use `999px` to echo the sidebar's close button, suggestion pills, and send button |
## Forced light
mastra's `CopilotSidebar` is always light regardless of OS color scheme. The
panel must match it, so `src/app/globals.css` re-pins `--foreground` and
`--background` to the V2 light values on `.threadsLayout` (the layout wrapper)
and on `body > [role="presentation"]` (the confirm dialog renders in a portal on
`<body>`). The dark-mode `@media (prefers-color-scheme: dark)` block only flips
the bare page `--background`/`--foreground`; the panel overrides win because
they are scoped to the layout/portal roots.
## Typography
Geist (the app font, via `--font-body` / `--font-code`). Sizes/weights track the
sidebar: header title `1rem / 500 / tracking-tight`, thread titles
`0.8125rem / 500`, meta `0.6875rem`, all medium-weight — no heavy `700`s.
Edit these files freely; they are mastra-owned and not shared with other
examples.
@@ -1,4 +0,0 @@
"use client";
export { default as ThreadsDrawer } from "./threads-drawer";
export type { ThreadsDrawerProps } from "./threads-drawer";
@@ -1,70 +0,0 @@
"use client";
import * as React from "react";
import { Lock } from "lucide-react";
import styles from "./threads-drawer.module.css";
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
// The Threads drawer reads a client-only external store (useThreads /
// useSyncExternalStore) with no server snapshot, so it must not render during
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (process.env.NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED === "true") {
if (!mounted) {
// SSR / first-paint placeholder: matches the open drawer's footprint +
// surface (and collapses to nothing on mobile) so the panel doesn't flash
// a bare-background column or shift the content when the drawer mounts.
return <div className={styles.drawerPlaceholder} aria-hidden />;
}
return <>{children}</>;
}
return (
<aside aria-label="Threads (locked)" className={styles.lockedPanel}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
</div>
<div className={styles.lockedBody}>
<div className={styles.lockedCard}>
<span aria-hidden className={styles.lockedIcon}>
<Lock size={18} />
</span>
<div className={styles.lockedHeading}>
<h3 className={styles.lockedTitle}>
Threads is a licensed feature
</h3>
<p className={styles.lockedDescription}>
Unlock persistent conversation history, multi-session context, and
thread management with CopilotKit Intelligence.
</p>
</div>
<p className={styles.lockedDescription}>
Add it to your project with:
</p>
<div className={styles.lockedCommand}>
<code className={styles.lockedCommandCode}>
copilotkit add-intelligence
</code>
</div>
<button
type="button"
className={styles.lockedCta}
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</button>
</div>
</div>
</aside>
);
}
@@ -1,891 +0,0 @@
/* Threads panel — a left-side companion to mastra's CopilotSidebar.
Every surface, border, radius, and type ramp here is lifted from CopilotKit's
V2 design system (@copilotkit/react-core/src/v2) so the panel reads as the
same product as the chat on the right:
- surfaces: white card on a white app; borders are the --border hairline
- radius: --radius (0.625rem) for rectangular controls; rounded-full
(999px) for icon buttons, the segmented control, and the New-thread
affordance — echoing the sidebar's close button, suggestion pills, and
send button
- type: 0.875rem base / font-medium / tracking-tight, matching the
sidebar header + pills (no heavy 700 weights)
- primary action color is the sidebar's near-black --primary, NOT a brand
accent — the V2 sidebar's primary buttons render bg-black/text-white */
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
/* Header — mirrors CopilotModalHeader: hairline bottom border, generous
px-4 py-4 rhythm, medium-weight tracking-tight title (not bold). */
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.drawerTitle {
margin: 0;
font-size: 1rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.01em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Icon button — the sidebar's close button: size-8, rounded-full, muted
foreground, hover lifts to bg-muted + foreground. */
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--muted);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
/* New-thread affordance — a compact pill in the sidebar's near-black
--primary, echoing the send button (bg-black, rounded-full, medium text). */
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 2rem;
padding: 0 0.75rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition:
background-color 140ms ease,
opacity 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
}
/* Segmented control — a single muted track with a white "lifted" active tab,
the same surface relationship the suggestion pills use (bg over muted). */
.segmented {
display: inline-flex;
width: 100%;
padding: 0.1875rem;
border-radius: 999px;
background: var(--muted);
gap: 0.1875rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: 999px;
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.125rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.25rem 0.5rem 0.75rem;
}
.threadRow {
position: relative;
}
/* Thread row — a quiet hover/selected surface in the sidebar's accent gray,
--radius corners, no hairline rule between rows (the chat list is borderless
too). */
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--accent);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected,
.threadItemSelected:hover {
background: var(--accent);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* A slim left accent rail; muted by default, fills to --primary when selected
— the same charcoal that drives the primary action buttons. */
.threadAccent {
flex: none;
width: 0.1875rem;
height: 1.5rem;
border-radius: 999px;
background: transparent;
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.125rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.3;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 400;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.6875rem;
line-height: 1.3;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 400;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
/* Archived chip — a muted pill, the same surface/type as the suggestion
pills (bg muted, font-medium, tiny). */
.archivedBadge {
display: inline-block;
margin-left: 0.375rem;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--muted);
font-size: 0.625rem;
font-weight: 500;
color: var(--muted-foreground);
vertical-align: middle;
}
/* Load-more — a full-width outline pill, the sidebar's suggestion-pill
treatment (border, bg-background, hover to accent). */
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.375rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--card);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--accent);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.375rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.125rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
/* Tooltip — matches the V2 dropdown/popover surface (white card, hairline
border, soft shadow, rounded-md), not an inverted chip. */
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
/* Render below the trigger: a CSS pseudo-tooltip can't escape the drawer's
overflow containers, and "above" clips on the top row / collapsed rail. */
top: calc(100% + 0.35rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--card);
color: var(--foreground);
font-family: var(--font-body);
font-size: 0.6875rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--muted-foreground);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: color-mix(in oklch, var(--destructive) 10%, transparent);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.25rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.1875rem;
height: 1.5rem;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
/* Empty/error card — clean white card with hairline border and the V2
radius-lg, same card vocabulary as the locked state. */
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
/* Locked state — same panel chrome (white surface, hairline right border,
header rhythm) as the unlocked drawer, with a single clean card that speaks
the V2 card vocabulary: rounded-lg, hairline border, muted icon chip, the
add-intelligence command in a muted code well, and a near-black primary CTA
pill matching the sidebar's send button. */
.lockedPanel {
display: flex;
width: 20rem;
flex-shrink: 0;
flex-direction: column;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
}
.lockedBody {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
padding: 1rem;
}
.lockedCard {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.lockedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
}
.lockedHeading {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.lockedTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.lockedDescription {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.lockedCommand {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
}
.lockedCommandCode {
font-family: var(--font-code);
font-size: 0.75rem;
white-space: nowrap;
color: var(--secondary-foreground);
}
.lockedCta {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.25rem;
padding: 0 0.875rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 140ms ease;
}
.lockedCta:hover {
opacity: 0.9;
}
.lockedCta:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
radius-xl, soft elevated shadow. */
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
color: var(--foreground);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.4rem;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1.1rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.dialogButtonSecondary {
background: var(--card);
border: 1px solid var(--border);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--accent);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--accent);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
and a fixed-width panel leaves a dead column). */
.lockedPanel {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}

Some files were not shown because too many files have changed in this diff Show More