chore(examples): de-fork threads-enabled integration examples onto SDK CopilotDrawer

Replace the hand-rolled threads-drawer fork in every threads-enabled
integration example with the SDK <CopilotDrawer> (uncontrolled
CopilotChatConfigurationProvider + reserved-column layout + theme no-flash
where applicable). 16 examples; all browser/build-validated locally.

DRAFT — depends on #5707 and the subsequent npm release; not mergeable until
the SDK publishes @copilotkit/web-components and react-core bumps. Pre-merge
TODOs in the PR description.
This commit is contained in:
Benjamin Taylor
2026-06-26 16:32:23 -05:00
parent 805e372bde
commit 2155821b86
148 changed files with 1600 additions and 25124 deletions
@@ -0,0 +1,40 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default 320px) as a fixed first
column so the layout doesn't shift when the client-only <CopilotDrawer>
mounts after hydration. On mobile the drawer is an off-canvas overlay (out
of flow), so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
}
.mainPanel {
/*
Pin the content to the SECOND track explicitly. The client-only drawer
renders nothing during SSR, so without this the content would flow into the
reserved first column at first paint and then jump once the drawer mounts.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise win and leak the two-column
desktop layout onto mobile).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -4,11 +4,10 @@ import { useState } from "react";
import Chat from "@/components/chat";
import {
CopilotChatConfigurationProvider,
CopilotDrawer,
CopilotKitProvider,
} 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";
import styles from "./page.module.css";
export type ResearchData = {
topic: string;
@@ -63,7 +62,7 @@ function ResearchAssistant() {
<div className="flex flex-1 flex-col gap-2 overflow-y-auto z-10 lg:flex-row lg:overflow-hidden">
<div className="flex min-h-[calc(100dvh-1rem)] w-full flex-shrink-0 flex-col overflow-hidden rounded-lg border-2 border-white bg-white/50 shadow-elevation-lg backdrop-blur-md lg:w-[450px]">
<div className="p-6 border-b border-[#DBDBE5]">
<div className="p-6 max-lg:pl-16 border-b border-[#DBDBE5]">
<h1 className="text-2xl font-semibold text-[#010507] mb-1">
Research Assistant
</h1>
@@ -201,31 +200,41 @@ function ResearchAssistant() {
}
export default function Home() {
const [threadId, setThreadId] = useState<string | undefined>(undefined);
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit"
showDevConsole="auto"
useSingleEndpoint={false}
>
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
{/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop)
owns the active thread for the whole surface. The SDK <CopilotDrawer>
drives it directly — picking a row sets the active thread, "+ New"
resets to a fresh thread — with no host thread-state. The chat (inside
ResearchAssistant) reads the same active thread from the provider. A
*controlled* provider would block "+ New" from resetting, so
uncontrolled-inside-provider is required, not optional.
*/}
<CopilotChatConfigurationProvider agentId="a2a_chat">
<div className={`${styles.layout} threadsLayout`}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for
parity with the fork's "Learn more" CTA. */}
<CopilotDrawer
agentId="a2a_chat"
threadId={threadId}
onThreadChange={setThreadId}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId="a2a_chat"
threadId={threadId}
>
<div className={styles.mainPanel}>
<ResearchAssistant />
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
</CopilotKitProvider>
);
}
@@ -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}>
npx copilotkit@latest license
</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
license 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,
);
}
@@ -18,7 +18,7 @@
"@ag-ui/a2a-middleware": "0.0.2",
"@ag-ui/client": "0.0.57",
"@ag-ui/core": "0.0.57",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "1.61.0",
"hono": "^4",
"lucide-react": "^0.577.0",
@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -10,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -18,10 +22,20 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+1 -1
View File
@@ -14,7 +14,7 @@
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "1.61.0",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
+13 -63
View File
@@ -12,6 +12,19 @@
}
}
/* adk's CopilotSidebar (CopilotKit's V2 chat) stays LIGHT regardless of the OS
color scheme, so the threads layout is held light too — otherwise it would
clash with the sidebar it sits beside. The SDK <CopilotDrawer> reads
--foreground/--background by token inheritance; re-pinning them here keeps the
panel readable without affecting the demo content. The confirm dialog renders
in a portal on <body>, so re-pin there as well. Values mirror the V2 light
theme (oklch(0.145 0 0) / oklch(1 0 0)). */
.threadsLayout,
body > [role="presentation"] {
--foreground: oklch(0.145 0 0);
--background: oklch(1 0 0);
}
body {
background: var(--background);
color: var(--foreground);
@@ -22,66 +35,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);
}
@@ -0,0 +1,40 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default 320px) as a fixed first
column so the layout doesn't shift when the client-only <CopilotDrawer>
mounts after hydration. On mobile the drawer is an off-canvas overlay (out
of flow), so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
}
.mainPanel {
/*
Pin the content to the SECOND track explicitly. The client-only drawer
renders nothing during SSR, so without this the content would flow into the
reserved first column at first paint and then jump once the drawer mounts.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise win and leak the two-column
desktop layout onto mobile).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
+46 -36
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,
@@ -11,11 +9,12 @@ import {
useFrontendTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
CopilotDrawer,
} from "@copilotkit/react-core/v2";
import React, { useEffect, useState } from "react";
import { z } from "zod";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
import styles from "./page.module.css";
// The agent key registered in the runtime route (`agents: { default: ... }`)
// and the id passed to `useAgent({ agentId: "default" })` below.
@@ -23,7 +22,6 @@ 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({
@@ -67,39 +65,51 @@ 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
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotDrawer> drives it
directly — selecting a row sets the active thread, "+ New" resets to a
fresh thread — with no host thread-state. The proverbs/weather content
and the CopilotSidebar read the same active thread from the provider (the
content's `useAgent()` falls back to it). A *controlled* provider would
block "+ New" from resetting, so uncontrolled-inside-provider is required.
`.threadsLayout` (globals.css) pins the light theme vars the drawer +
sidebar inherit; the SDK drawer follows them by token inheritance.
*/
<CopilotChatConfigurationProvider agentId={AGENT_ID}>
<div className={`${styles.layout} threadsLayout`}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for parity
with the fork's "Learn more" CTA. */}
<CopilotDrawer
agentId={AGENT_ID}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
defaultOpen={true}
labels={{
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
"👋 Hi, there! You're chatting with an agent.",
}}
/>
</main>
/>
<div className={styles.mainPanel}>
<main
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.",
}}
/>
</main>
</div>
</div>
</CopilotChatConfigurationProvider>
);
@@ -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)]">
npx copilotkit@latest license
</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,
);
}
@@ -16,6 +16,21 @@
href="https://fonts.googleapis.com/css2?family=Geist+Sans:wght@100..900&family=Geist+Mono:wght@100..900&display=swap"
rel="stylesheet"
/>
<!--
Set the theme class BEFORE first paint to avoid a white→dark flash.
useTheme applies the theme in a useEffect (post-mount), so without this the
app paints unthemed (light) first, then flips. This blocking inline script
matches useTheme's "system" default (light/dark on <html>) so there's no
flash and no mismatch when the provider re-applies.
-->
<script>
(function () {
try {
var d = window.matchMedia("(prefers-color-scheme: dark)").matches;
document.documentElement.classList.add(d ? "dark" : "light");
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
@@ -12,12 +12,15 @@
"clean": "rm -rf build/ node_modules/ .vite/"
},
"dependencies": {
"@copilotkit/react-core": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.10",
"@types/react-syntax-highlighter": "^15.5.13",
"aws-amplify": "^6.16.2",
"class-variance-authority": "^0.7.1",
@@ -0,0 +1,48 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default `--cpk-drawer-width`, 320px)
as a fixed first column so the layout does NOT shift when the client-only
drawer mounts. On mobile the drawer is an off-canvas overlay (out of flow),
so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
/*
Align the drawer's mobile launcher with this app's header controls. These
custom properties inherit into <copilotkit-drawer> and pierce its shadow
root; tuned to match the example header's top-left inset.
*/
--cpk-drawer-launcher-top: 7px;
--cpk-drawer-launcher-left: 16px;
}
.mainPanel {
/*
Pin the content to the SECOND grid track explicitly. The client-only
<CopilotDrawer> renders nothing during the drawer's mount gate, so without
an explicit placement the panel would flow into the reserved first track
and then jump once the drawer mounts. Forcing column 2 keeps it put.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise leak the desktop layout).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react";
import {
CopilotChat,
CopilotChatConfigurationProvider,
CopilotDrawer,
CopilotKitProvider,
useFrontendTool,
} from "@copilotkit/react-core/v2";
@@ -17,25 +18,16 @@ import { useCopilotExamples } from "@/hooks/useCopilotExamples";
import { ThemeProvider } from "@/hooks/useTheme";
import { TodoCanvas } from "@/components/canvas/TodoCanvas";
import { ModeToggle } from "@/components/ui/mode-toggle";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
import styles from "./CopilotKit.module.css";
const COPILOTKIT_AGENT_ID = "default";
type ResolvedAwsExportsConfig = AwsExportsConfig & {
copilotKitRuntimeUrl: string;
};
function CopilotChatContent({
runtimeUrl,
headers,
}: {
runtimeUrl: string;
headers: Record<string, string> | undefined;
}) {
function CopilotChatContent() {
const [mode, setMode] = useState<"chat" | "app">("chat");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
useExampleSuggestions();
useCopilotExamples();
@@ -57,27 +49,38 @@ function CopilotChatContent({
});
return (
<div className={styles.layout}>
<ThreadsPanelGate>
<ThreadsDrawer
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotDrawer> drives it
directly — picking a row sets the active thread, "+ New" resets to a fresh
thread — with no host thread-state. The drawer inherits `runtimeUrl` and
the Cognito auth `headers` from the surrounding <CopilotKitProvider> (via
useThreads -> useCopilotKit), so threads are fetched authenticated with no
explicit props. A *controlled* provider would block "+ New" from
resetting, so uncontrolled-inside-provider is required, not optional.
*/
<CopilotChatConfigurationProvider agentId={COPILOTKIT_AGENT_ID}>
<div className={styles.layout}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for parity
with the fork's "Learn more" CTA. */}
<CopilotDrawer
agentId={COPILOTKIT_AGENT_ID}
threadId={threadId}
onThreadChange={setThreadId}
runtimeUrl={runtimeUrl}
headers={headers}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId={COPILOTKIT_AGENT_ID}
threadId={threadId}
>
<div className={styles.mainPanel}>
<div className="h-full flex flex-row">
<ModeToggle mode={mode} onModeChange={setMode} />
<div
className={`max-h-full overflow-y-auto [&_.copilotKitChat]:h-full [&_.copilotKitChat]:border-0 [&_.copilotKitChat]:shadow-none ${
mode === "app"
? "w-1/3 px-6 max-lg:hidden"
? "w-1/2 px-6 max-lg:hidden"
: "flex-1 px-4 lg:px-6"
}`}
>
@@ -86,18 +89,24 @@ function CopilotChatContent({
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-2/3 border-l dark:border-zinc-700 max-lg:w-full max-lg:border-l-0"
? "w-1/2 border-l dark:border-zinc-700 max-lg:w-full max-lg:border-l-0"
: "w-0 border-l-0"
}`}
>
<div className="h-full w-full lg:w-[66.666vw]">
{/*
Fill the state panel's own width. The previous `lg:w-[66.666vw]`
was viewport-relative, so with a reserved drawer column it
overflowed this container (clipped by overflow-hidden) and
pushed centered content right of the visible box's center.
*/}
<div className="h-full w-full">
<TodoCanvas />
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -120,10 +129,7 @@ function CopilotKitShell({
headers={headers}
useSingleEndpoint={false}
>
<CopilotChatContent
runtimeUrl={config.copilotKitRuntimeUrl}
headers={headers}
/>
<CopilotChatContent />
</CopilotKitProvider>
);
}
@@ -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 (import.meta.env.VITE_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)]">
npx copilotkit@latest license
</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,589 +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;
runtimeUrl: string;
headers?: Record<string, string>;
}
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";
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,
runtimeUrl,
headers,
}: 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(
`${runtimeUrl}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId, headers, runtimeUrl],
);
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 -1
View File
@@ -13,7 +13,7 @@
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "1.61.0",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
+22 -13
View File
@@ -1,25 +1,23 @@
@import "tailwindcss";
/*
* Threads panel theme — bespoke match to agno's CopilotKit chat (CopilotSidebar).
* Threads panel theme — 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.
* native to that chat, the SDK <CopilotDrawer> is held light by token
* inheritance: 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.
* still resolves the same surfaces. The SDK drawer is driven entirely by these
* tokens; 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 */
@@ -55,6 +53,17 @@
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
/* The CopilotKit v2 chat (CopilotSidebar) stays LIGHT regardless of the OS color
scheme, so the threads layout is held light too — otherwise the SDK drawer
would clash with the sidebar it sits beside. The confirm dialog renders in a
portal on <body>, so re-pin there as well. Values mirror the V2 light theme
(oklch(0.145 0 0) / oklch(1 0 0)). */
.threadsLayout,
body > [role="presentation"] {
--foreground: oklch(0.145 0 0);
--background: oklch(1 0 0);
}
body {
background: var(--background);
color: var(--foreground);
@@ -0,0 +1,40 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default 320px) as a fixed first
column so the layout doesn't shift when the client-only <CopilotDrawer>
mounts after hydration. On mobile the drawer is an off-canvas overlay (out
of flow), so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
}
.mainPanel {
/*
Pin the content to the SECOND track explicitly. The client-only drawer
renders nothing during SSR, so without this the content would flow into the
reserved first column at first paint and then jump once the drawer mounts.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise win and leak the two-column
desktop layout onto mobile).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
+50 -38
View File
@@ -8,14 +8,14 @@ import {
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
CopilotDrawer,
} from "@copilotkit/react-core/v2";
import React, { useEffect, 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";
import styles from "./page.module.css";
// agno registers a single agent under the key "default" (see
// src/app/api/copilotkit/[[...slug]]/route.ts), so the threads drawer + chat
@@ -24,7 +24,6 @@ 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({
@@ -64,42 +63,55 @@ export default function CopilotKitPage() {
});
return (
<div className={styles.layout}>
{/* Bespoke threads panel, themed in globals.css to match agno's chat. */}
<ThreadsPanelGate>
<ThreadsDrawer
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotDrawer> drives it
directly — selecting a row sets the active thread, "+ New" resets to a
fresh thread — with no host thread-state. The proverbs/weather content
and the CopilotSidebar read the same active thread from the provider (the
content's `useAgent()` falls back to it). A *controlled* provider would
block "+ New" from resetting, so uncontrolled-inside-provider is required.
`.threadsLayout` (globals.css) pins the light theme vars the drawer +
sidebar inherit; the SDK drawer follows them by token inheritance.
*/
<CopilotChatConfigurationProvider agentId={AGENT_ID}>
<div className={`${styles.layout} threadsLayout`}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for parity
with the fork's "Learn more" CTA. */}
<CopilotDrawer
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
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
<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.",
}}
/>
{/* CopilotSidebar self-docks; main content renders as a sibling. */}
</main>
</CopilotChatConfigurationProvider>
</div>
/>
<div className={styles.mainPanel}>
<main
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.",
}}
/>
{/* CopilotSidebar self-docks; main content renders as a sibling. */}
</main>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -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)]">
npx copilotkit@latest license
</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,
);
}
@@ -15,7 +15,7 @@
"dependencies": {
"@ag-ui/client": "0.0.57",
"@ag-ui/crewai": "0.0.3",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "1.61.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -10,8 +10,7 @@
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. */
This keeps the threads panel reading as one product with the chat. */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
@@ -0,0 +1,40 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default 320px) as a fixed first
column so the layout doesn't shift when the client-only <CopilotDrawer>
mounts after hydration. On mobile the drawer is an off-canvas overlay (out
of flow), so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
}
.mainPanel {
/*
Pin the content to the SECOND track explicitly. The client-only drawer
renders nothing during SSR, so without this the content would flow into the
reserved first column at first paint and then jump once the drawer mounts.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise win and leak the two-column
desktop layout onto mobile).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -6,20 +6,18 @@ import {
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
CopilotDrawer,
} from "@copilotkit/react-core/v2";
import { CSSProperties, useEffect, 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";
import styles from "./page.module.css";
// The agent key registered in the runtime route (`agents: { default: ... }`).
const AGENT_ID = "default";
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({
@@ -36,19 +34,33 @@ export default function CopilotKitPage() {
});
return (
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotDrawer> drives it
directly — selecting a row sets the active thread, "+ New" resets to a
fresh thread — with no host thread-state. The proverbs/weather content
and the CopilotSidebar read the same active thread from the provider (the
content's `useAgent()` falls back to it). A *controlled* provider would
block "+ New" from resetting, so uncontrolled-inside-provider is required.
`.threadsLayout` (globals.css) pins the light theme vars the drawer +
sidebar inherit; the SDK drawer follows them by token inheritance.
*/
<CopilotChatConfigurationProvider agentId={AGENT_ID}>
<div className={`${styles.layout} threadsLayout`}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for parity
with the fork's "Learn more" CTA. */}
<CopilotDrawer
agentId={AGENT_ID}
threadId={threadId}
onThreadChange={setThreadId}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId={AGENT_ID}
threadId={threadId}
>
<div className={styles.mainPanel}>
<main
style={
{ "--copilot-kit-primary-color": themeColor } as CSSProperties
@@ -65,9 +77,9 @@ export default function CopilotKitPage() {
}}
/>
</main>
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -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}>
npx copilotkit@latest license
</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
license 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,
);
}
@@ -13,7 +13,7 @@
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "1.61.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -10,8 +10,7 @@
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. */
This keeps the threads panel reading as one product with the chat. */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
@@ -0,0 +1,40 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default 320px) as a fixed first
column so the layout doesn't shift when the client-only <CopilotDrawer>
mounts after hydration. On mobile the drawer is an off-canvas overlay (out
of flow), so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
}
.mainPanel {
/*
Pin the content to the SECOND track explicitly. The client-only drawer
renders nothing during SSR, so without this the content would flow into the
reserved first column at first paint and then jump once the drawer mounts.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise win and leak the two-column
desktop layout onto mobile).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -6,20 +6,18 @@ import {
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
CopilotDrawer,
} from "@copilotkit/react-core/v2";
import { CSSProperties, useEffect, 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";
import styles from "./page.module.css";
// The agent key registered in the runtime route (`agents: { default: ... }`).
const AGENT_ID = "default";
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({
@@ -36,19 +34,33 @@ export default function CopilotKitPage() {
});
return (
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotDrawer> drives it
directly — selecting a row sets the active thread, "+ New" resets to a
fresh thread — with no host thread-state. The content and the
CopilotSidebar read the same active thread from the provider (the content's
`useAgent()` falls back to it). A *controlled* provider would block
"+ New" from resetting, so uncontrolled-inside-provider is required.
`.threadsLayout` (globals.css) pins the light theme vars the drawer +
sidebar inherit; the SDK drawer follows them by token inheritance.
*/
<CopilotChatConfigurationProvider agentId={AGENT_ID}>
<div className={`${styles.layout} threadsLayout`}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for parity
with the fork's "Learn more" CTA. */}
<CopilotDrawer
agentId={AGENT_ID}
threadId={threadId}
onThreadChange={setThreadId}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId={AGENT_ID}
threadId={threadId}
>
<div className={styles.mainPanel}>
<main
style={
{
@@ -66,9 +78,9 @@ export default function CopilotKitPage() {
}}
/>
</main>
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -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}>
npx copilotkit@latest license
</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
license 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,
);
}
@@ -14,8 +14,8 @@
},
"dependencies": {
"@copilotkit/a2ui-renderer": "1.61.0",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/runtime": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "npm:@copilotkit/runtime@1.61.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
@@ -12,7 +12,7 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<title>CopilotKit</title>
<link
@@ -20,6 +20,19 @@ export default function RootLayout({
type="image/svg+xml"
href="/copilotkit-logo-mark.svg"
/>
{/*
Set the theme class BEFORE first paint to avoid a white→dark flash.
ThemeProvider applies the theme in a useEffect (post-hydration), so
without this the page paints unthemed (light) first, then flips. This
blocking inline script matches ThemeProvider's "system" default so
there's no flash and no class mismatch when the provider re-applies.
*/}
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var d=window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.add(d?'dark':'light');}catch(e){}})();",
}}
/>
</head>
{/*
suppressHydrationWarning: browser extensions (e.g. Grammarly) inject
@@ -0,0 +1,53 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default `--cpk-drawer-width`, 320px)
as a fixed first column so the layout does NOT shift when the client-only
drawer mounts after hydration. On mobile the drawer is an off-canvas overlay
(out of flow), so the column collapses and the chat fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
/*
Align the drawer's mobile launcher with this app's header controls. These
custom properties inherit into <copilotkit-drawer> and pierce its shadow
root; tuned to match the example header's top-left inset.
*/
--cpk-drawer-launcher-top: 7px;
--cpk-drawer-launcher-left: 16px;
}
.mainPanel {
/*
Pin the content to the SECOND grid track explicitly. The client-only
<CopilotDrawer> renders NOTHING during SSR/prerender (mounted-gated), so at
first paint the grid has a single child — this panel. Without an explicit
placement it would flow into the reserved first track and then jump once the
drawer mounts. Forcing column 2 keeps it put from first paint.
*/
grid-column: 2;
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise leak the desktop layout).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -1,46 +1,49 @@
"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,
CopilotDrawer,
} from "@copilotkit/react-core/v2";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
import styles from "./page.module.css";
export default function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
const [threadId, setThreadId] = useState<string | undefined>(undefined);
return (
<div className={styles.layout}>
<ThreadsPanelGate>
<ThreadsDrawer
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotDrawer> drives it
directly — picking a row sets the active thread, "+ New" resets to a fresh
thread (clearing the chat) — with no host thread-state. The chat and the
canvas read the same active thread from the provider (the canvas's
`useAgent()` falls back to it), so they stay on the same per-thread agent
clone the chat's /connect replay populates. A *controlled* provider would
block "+ New" from resetting the chat, so uncontrolled-inside-provider is
required, not optional.
*/
<CopilotChatConfigurationProvider agentId="default">
<div className={styles.layout}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for parity
with the fork's "Learn more" CTA. */}
<CopilotDrawer
agentId="default"
threadId={threadId}
onThreadChange={setThreadId}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
/>
</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}>
<div className={styles.mainPanel}>
<ExampleLayout
chatContent={
<CopilotChat
@@ -50,8 +53,8 @@ export default function HomePage() {
}
appContent={<ExampleCanvas />}
/>
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -38,7 +38,7 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
<div
className={`max-h-full flex flex-col dark:bg-stone-950 ${
mode === "app"
? "w-1/3 px-6 max-lg:hidden" // Hide on mobile in app mode
? "w-1/2 px-6 max-lg:hidden" // Half/half with the canvas; hidden on mobile in app mode
: "flex-1 max-lg:px-4"
}`}
>
@@ -63,11 +63,17 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-2/3 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Full width on mobile
? "w-1/2 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Half/half with the chat; full width on mobile
: "w-0 border-l-0"
}`}
>
<div className="w-full lg:w-[66.666vw] h-full">{appContent}</div>
{/*
Fill the state panel's own width. The previous `lg:w-[66.666vw]` was
viewport-relative, so with a reserved drawer column it overflowed this
container (clipped by overflow-hidden) and pushed centered content
right of the visible box's center.
*/}
<div className="w-full h-full">{appContent}</div>
</div>
</div>
);
@@ -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)]">
npx copilotkit@latest license
</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,
);
}
@@ -14,9 +14,9 @@
"postinstall": "npm run install:agent"
},
"dependencies": {
"@copilotkit/a2ui-renderer": "1.61.0",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/runtime": "1.61.0",
"@copilotkit/a2ui-renderer": "workspace:*",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "npm:@copilotkit/runtime@1.61.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
@@ -0,0 +1,53 @@
{
"name": "langgraph-js-starter",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
"dev:debug": "LOG_LEVEL=debug npm run dev",
"dev:ui": "next dev --turbopack",
"dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
"build": "next build",
"start": "next start",
"lint": "next lint",
"install:agent": "cd agent && npm install",
"postinstall": "npm run install:agent"
},
"dependencies": {
"@copilotkit/a2ui-renderer": "workspace:*",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "workspace:*",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.12.10",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-rnd": "^10.5.2",
"react18-json-view": "^0.2.9",
"recharts": "^3.7.0",
"shiki": "^3.22.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"tailwindcss": "^4",
"typescript": "^5"
},
"overrides": {
"@langchain/core": "1.1.44",
"@langchain/langgraph": "1.3.0"
},
"engines": {
"node": ">=18.0.0"
}
}
@@ -12,7 +12,7 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<title>CopilotKit</title>
<link
@@ -20,6 +20,19 @@ export default function RootLayout({
type="image/svg+xml"
href="/copilotkit-logo-mark.svg"
/>
{/*
Set the theme class BEFORE first paint to avoid a white→dark flash.
ThemeProvider applies the theme in a useEffect (post-hydration), so
without this the page paints unthemed (light) first, then flips. This
blocking inline script matches ThemeProvider's "system" default so
there's no flash and no class mismatch when the provider re-applies.
*/}
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var d=window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.add(d?'dark':'light');}catch(e){}})();",
}}
/>
</head>
{/*
suppressHydrationWarning: browser extensions (e.g. Grammarly) inject
@@ -0,0 +1,62 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default `--cpk-drawer-width`, 320px)
as a fixed first column so the layout does NOT shift when the client-only
drawer mounts after hydration. Without this the column is `auto` → 0px until
the drawer boots, so the chat starts full-width and then "slides over" when
the drawer appears. On mobile the drawer is an off-canvas overlay (out of
flow), so the column collapses to `auto` (≈0) and the chat fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
/*
Vertically center the drawer's mobile launcher on this app's header toggle
group (the Chat/App control sits at y=13, height 28 → center 27). The 40px
launcher therefore needs top = 27 - 20 = 7px; left mirrors the toggle
group's ~16px inset on the right for symmetry. These custom properties
inherit into the <copilotkit-drawer> element and pierce its shadow root.
*/
--cpk-drawer-launcher-top: 7px;
--cpk-drawer-launcher-left: 16px;
}
.mainPanel {
/*
Pin the chat to the SECOND grid track explicitly. The client-only
<CopilotDrawer> renders NOTHING during SSR/prerender (it is mounted-gated),
so at first paint the grid has a single child — this panel. Without an
explicit placement it would flow into the FIRST (reserved 320px) track,
cramming the chat at x:0, then jump to the second track once the drawer
mounts as the first child — the "starts left, then slides over" shift.
Forcing column 2 keeps the chat in its final position from first paint; the
drawer fills the reserved first track when it mounts.
*/
grid-column: 2;
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay, so don't reserve a
column for it — collapse to a single track and let the chat fill the full
width. This block MUST come after the base rules above: media queries do not
add specificity, so a same-specificity base rule placed later would otherwise
win and leak the desktop two-column layout onto mobile.
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -1,46 +1,44 @@
"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,
CopilotDrawer,
} from "@copilotkit/react-core/v2";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
import styles from "./page.module.css";
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}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
/*
One CopilotChatConfigurationProvider owns the active thread for the whole
surface. It is UNCONTROLLED (no `threadId` prop): the SDK <CopilotDrawer>
drives it directly — picking a row sets the active thread, "+ New" resets
to a fresh thread (clearing the chat), all with no host wiring. The chat
and the canvas read the same active thread from the provider (the canvas's
`useAgent()` falls back to it), so they stay on the same per-thread agent
clone the chat's /connect replay populates.
The drawer is mounted INSIDE the provider so it registers with the chat
configuration — that's what surfaces the header thread-list launcher on
mobile, where the drawer is an off-canvas modal rather than a sidebar.
*/
<CopilotChatConfigurationProvider agentId="default">
<div className={styles.layout}>
{/*
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.
SDK threads drawer (replaces the former hand-rolled fork). SSR-safe and
license-gated (shows its own upsell when threads aren't licensed), so it
needs no example-level gate.
*/}
<CopilotChatConfigurationProvider agentId="default" threadId={threadId}>
<CopilotDrawer agentId="default" />
<div className={styles.mainPanel}>
<ExampleLayout
chatContent={
<CopilotChat
@@ -50,8 +48,8 @@ export default function HomePage() {
}
appContent={<ExampleCanvas />}
/>
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -38,7 +38,7 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
<div
className={`max-h-full flex flex-col dark:bg-stone-950 ${
mode === "app"
? "w-1/3 px-6 max-lg:hidden" // Hide on mobile in app mode
? "w-1/2 px-6 max-lg:hidden" // Half/half with the canvas; hidden on mobile in app mode
: "flex-1 max-lg:px-4"
}`}
>
@@ -63,11 +63,17 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-2/3 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Full width on mobile
? "w-1/2 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Half/half with the chat; full width on mobile
: "w-0 border-l-0"
}`}
>
<div className="w-full lg:w-[66.666vw] h-full">{appContent}</div>
{/*
Fill the state panel's own width. The previous `lg:w-[66.666vw]` was
viewport-relative, so with a reserved drawer column it overflowed this
container (clipped by overflow-hidden) and pushed centered content
(e.g. the empty-state) right of the visible box's center.
*/}
<div className="w-full h-full">{appContent}</div>
</div>
</div>
);
@@ -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)]">
npx copilotkit@latest license
</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,
);
}
@@ -14,8 +14,8 @@
},
"dependencies": {
"@copilotkit/a2ui-renderer": "1.61.0",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/runtime": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "npm:@copilotkit/runtime@1.61.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
@@ -0,0 +1,44 @@
{
"name": "copilotkit-langgraph-template",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
"dev:debug": "LOG_LEVEL=debug npm run dev",
"dev:ui": "next dev --turbopack",
"dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
"build": "next build",
"start": "next start",
"install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat",
"postinstall": "npm run install:agent"
},
"dependencies": {
"@copilotkit/a2ui-renderer": "1.61.0",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/runtime": "1.61.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4.12.10",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-rnd": "^10.5.2",
"react18-json-view": "^0.2.9",
"recharts": "^3.7.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}
@@ -12,7 +12,7 @@ export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head>
<title>CopilotKit</title>
<link
@@ -20,6 +20,19 @@ export default function RootLayout({
type="image/svg+xml"
href="/copilotkit-logo-mark.svg"
/>
{/*
Set the theme class BEFORE first paint to avoid a white→dark flash.
ThemeProvider applies the theme in a useEffect (post-hydration), so
without this the page paints unthemed (light) first, then flips. This
blocking inline script matches ThemeProvider's "system" default so
there's no flash and no class mismatch when the provider re-applies.
*/}
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var d=window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.add(d?'dark':'light');}catch(e){}})();",
}}
/>
</head>
{/*
suppressHydrationWarning: browser extensions (e.g. Grammarly) inject
@@ -0,0 +1,53 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default `--cpk-drawer-width`, 320px)
as a fixed first column so the layout does NOT shift when the client-only
drawer mounts after hydration. On mobile the drawer is an off-canvas overlay
(out of flow), so the column collapses and the chat fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
/*
Align the drawer's mobile launcher with this app's header controls. These
custom properties inherit into <copilotkit-drawer> and pierce its shadow
root; tuned to match the example header's top-left inset.
*/
--cpk-drawer-launcher-top: 7px;
--cpk-drawer-launcher-left: 16px;
}
.mainPanel {
/*
Pin the content to the SECOND grid track explicitly. The client-only
<CopilotDrawer> renders NOTHING during SSR/prerender (mounted-gated), so at
first paint the grid has a single child — this panel. Without an explicit
placement it would flow into the reserved first track and then jump once the
drawer mounts. Forcing column 2 keeps it put from first paint.
*/
grid-column: 2;
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise leak the desktop layout).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -1,46 +1,49 @@
"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,
CopilotDrawer,
} from "@copilotkit/react-core/v2";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
import styles from "./page.module.css";
export default function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
const [threadId, setThreadId] = useState<string | undefined>(undefined);
return (
<div className={styles.layout}>
<ThreadsPanelGate>
<ThreadsDrawer
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotDrawer> drives it
directly — picking a row sets the active thread, "+ New" resets to a fresh
thread (clearing the chat) — with no host thread-state. The chat and the
canvas read the same active thread from the provider (the canvas's
`useAgent()` falls back to it), so they stay on the same per-thread agent
clone the chat's /connect replay populates. A *controlled* provider would
block "+ New" from resetting the chat, so uncontrolled-inside-provider is
required, not optional.
*/
<CopilotChatConfigurationProvider agentId="default">
<div className={styles.layout}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for parity
with the fork's "Learn more" CTA. */}
<CopilotDrawer
agentId="default"
threadId={threadId}
onThreadChange={setThreadId}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
/>
</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}>
<div className={styles.mainPanel}>
<ExampleLayout
chatContent={
<CopilotChat
@@ -50,8 +53,8 @@ export default function HomePage() {
}
appContent={<ExampleCanvas />}
/>
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -38,7 +38,7 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
<div
className={`max-h-full flex flex-col dark:bg-stone-950 ${
mode === "app"
? "w-1/3 px-6 max-lg:hidden" // Hide on mobile in app mode
? "w-1/2 px-6 max-lg:hidden" // Half/half with the canvas; hidden on mobile in app mode
: "flex-1 max-lg:px-4"
}`}
>
@@ -63,11 +63,17 @@ export function ExampleLayout({ chatContent, appContent }: ExampleLayoutProps) {
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-2/3 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Full width on mobile
? "w-1/2 max-lg:w-full border-l border-[var(--border)] max-lg:border-l-0" // Half/half with the chat; full width on mobile
: "w-0 border-l-0"
}`}
>
<div className="w-full lg:w-[66.666vw] h-full">{appContent}</div>
{/*
Fill the state panel's own width. The previous `lg:w-[66.666vw]` was
viewport-relative, so with a reserved drawer column it overflowed this
container (clipped by overflow-hidden) and pushed centered content
right of the visible box's center.
*/}
<div className="w-full h-full">{appContent}</div>
</div>
</div>
);
@@ -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)]">
npx copilotkit@latest license
</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,
);
}
@@ -16,7 +16,7 @@
"dependencies": {
"@ag-ui/client": "0.0.57",
"@ag-ui/llamaindex": "0.1.5",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "1.61.0",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
@@ -0,0 +1,40 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default 320px) as a fixed first
column so the layout doesn't shift when the client-only <CopilotDrawer>
mounts after hydration. On mobile the drawer is an off-canvas overlay (out
of flow), so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
}
.mainPanel {
/*
Pin the content to the SECOND track explicitly. The client-only drawer
renders nothing during SSR, so without this the content would flow into the
reserved first column at first paint and then jump once the drawer mounts.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise win and leak the two-column
desktop layout onto mobile).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
@@ -9,15 +9,13 @@ import {
useFrontendTool,
CopilotChatConfigurationProvider,
CopilotSidebar,
CopilotDrawer,
} 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";
import styles from "./page.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({
@@ -34,16 +32,19 @@ export default function CopilotKitPage() {
});
return (
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
<CopilotChatConfigurationProvider agentId="default">
<div className={`${styles.layout} threadsLayout`}>
<CopilotDrawer
agentId="default"
threadId={threadId}
onThreadChange={setThreadId}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider agentId="default" threadId={threadId}>
<div className={styles.mainPanel}>
<main
style={
{
@@ -62,9 +63,9 @@ export default function CopilotKitPage() {
}}
/>
</main>
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -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}>
npx copilotkit@latest license
</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
license 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 -1
View File
@@ -14,7 +14,7 @@
"@ag-ui/client": "0.0.57",
"@ag-ui/mastra": "0.2.1-beta.2",
"@ai-sdk/openai": "^2.0.42",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/react-core": "workspace:*",
"@copilotkit/runtime": "1.61.0",
"@libsql/client": "^0.15.15",
"@mastra/client-js": "1.23.2",
@@ -0,0 +1,51 @@
{
"name": "starter",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
"dev:agent": "mastra dev",
"dev:ui": "next dev --turbopack",
"dev:debug": "LOG_LEVEL=debug npm run dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@ag-ui/mastra": "0.2.1-beta.2",
"@ai-sdk/openai": "^2.0.42",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/runtime": "1.61.0",
"@libsql/client": "^0.15.15",
"@mastra/client-js": "1.23.2",
"@mastra/core": "1.41.0",
"@mastra/libsql": "1.1.0-alpha.2",
"@mastra/memory": "1.0.1-alpha.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4",
"libsql": "^0.5.22",
"lucide-react": "^0.525.0",
"mastra": "1.1.0-alpha.3",
"next": "16.1.2",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwind-merge": "^3.5.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"tailwindcss": "^4",
"typescript": "^5"
},
"overrides": {
"@ag-ui/client": "0.0.57",
"@ag-ui/core": "0.0.57",
"@ag-ui/encoder": "0.0.57",
"@ag-ui/proto": "0.0.57"
}
}
@@ -11,7 +11,8 @@
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. */
The SDK <CopilotDrawer> inherits these tokens by CSS custom-property
inheritance from the `.threadsLayout` wrapper. */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
@@ -0,0 +1,40 @@
.layout {
display: grid;
/*
Reserve the desktop drawer's width (its default 320px) as a fixed first
column so the layout doesn't shift when the client-only <CopilotDrawer>
mounts after hydration. On mobile the drawer is an off-canvas overlay (out
of flow), so the column collapses and the content fills the width.
*/
grid-template-columns: 320px minmax(0, 1fr);
height: 100dvh;
width: 100%;
overflow: hidden;
}
.mainPanel {
/*
Pin the content to the SECOND track explicitly. The client-only drawer
renders nothing during SSR, so without this the content would flow into the
reserved first column at first paint and then jump once the drawer mounts.
*/
grid-column: 2;
min-width: 0;
height: 100dvh;
overflow: hidden;
}
/*
Mobile (≤768px): the drawer is an off-canvas overlay — collapse to a single
track. MUST come after the base rules (media queries add no specificity, so a
later same-specificity base rule would otherwise win and leak the two-column
desktop layout onto mobile).
*/
@media (max-width: 768px) {
.layout {
grid-template-columns: minmax(0, 1fr);
}
.mainPanel {
grid-column: auto;
}
}
+29 -22
View File
@@ -11,17 +11,15 @@ import {
useHumanInTheLoop,
CopilotSidebar,
CopilotChatConfigurationProvider,
CopilotDrawer,
} from "@copilotkit/react-core/v2";
import { useEffect, 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";
import styles from "./page.module.css";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
// 🪁 Frontend Actions: https://docs.copilotkit.ai/mastra/frontend-actions
useFrontendTool({
@@ -70,24 +68,33 @@ export default function CopilotKitPage() {
});
return (
<div className={`${styles.layout} threadsLayout`}>
{/* In-flow threads drawer on the LEFT, themed light in globals.css to
match the CopilotSidebar chat aesthetic. */}
<ThreadsPanelGate>
<ThreadsDrawer
/*
One UNCONTROLLED CopilotChatConfigurationProvider (no `threadId` prop) owns
the active thread for the whole surface. The SDK <CopilotDrawer> drives it
directly — selecting a row sets the active thread, "+ New" resets to a
fresh thread — with no host thread-state. The proverbs/weather/moon content
and the CopilotSidebar read the same active thread from the provider (the
content's `useAgent()` falls back to it). A *controlled* provider would
block "+ New" from resetting, so uncontrolled-inside-provider is required.
`.threadsLayout` (globals.css) pins the light theme vars the drawer +
sidebar inherit; the SDK drawer follows them by token inheritance.
*/
<CopilotChatConfigurationProvider agentId="default">
<div className={`${styles.layout} threadsLayout`}>
{/* SDK threads drawer (replaces the hand-rolled fork). License-gated
with its own upsell; onUpsell opens the Intelligence docs for parity
with the fork's "Learn more" CTA. */}
<CopilotDrawer
agentId="default"
threadId={threadId}
onThreadChange={setThreadId}
onUpsell={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
{/*
Share the active threadId with the chat + demo content via one
CopilotChatConfigurationProvider. `useAgent()` falls back to the
provider's threadId when called without an explicit one, so selecting
a thread in the drawer drives the same per-thread agent clone.
*/}
<CopilotChatConfigurationProvider agentId="default" threadId={threadId}>
<div className={styles.mainPanel}>
<main
style={
{
@@ -105,9 +112,9 @@ export default function CopilotKitPage() {
}}
/>
</main>
</CopilotChatConfigurationProvider>
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
);
}
@@ -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.

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