chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
:root {
|
||||
--n-100: #ffffff;
|
||||
--n-99: #fcfcfc;
|
||||
--n-98: #f9f9f9;
|
||||
--n-95: #f1f1f1;
|
||||
--n-90: #e2e2e2;
|
||||
--n-80: #c6c6c6;
|
||||
--n-70: #ababab;
|
||||
--n-60: #919191;
|
||||
--n-50: #777777;
|
||||
--n-40: #5e5e5e;
|
||||
--n-35: #525252;
|
||||
--n-30: #474747;
|
||||
--n-25: #3b3b3b;
|
||||
--n-20: #303030;
|
||||
--n-15: #262626;
|
||||
--n-10: #1b1b1b;
|
||||
--n-5: #111111;
|
||||
--n-0: #000000;
|
||||
|
||||
--p-100: #ffffff;
|
||||
--p-99: #fffbff;
|
||||
--p-98: #fcf8ff;
|
||||
--p-95: #f2efff;
|
||||
--p-90: #e1e0ff;
|
||||
--p-80: #c0c1ff;
|
||||
--p-70: #a0a3ff;
|
||||
--p-60: #8487ea;
|
||||
--p-50: #6a6dcd;
|
||||
--p-40: #5154b3;
|
||||
--p-35: #4447a6;
|
||||
--p-30: #383b99;
|
||||
--p-25: #2c2e8d;
|
||||
--p-20: #202182;
|
||||
--p-15: #131178;
|
||||
--p-10: #06006c;
|
||||
--p-5: #03004d;
|
||||
--p-0: #000000;
|
||||
|
||||
--s-100: #ffffff;
|
||||
--s-99: #fffbff;
|
||||
--s-98: #fcf8ff;
|
||||
--s-95: #f2efff;
|
||||
--s-90: #e2e0f9;
|
||||
--s-80: #c6c4dd;
|
||||
--s-70: #aaa9c1;
|
||||
--s-60: #8f8fa5;
|
||||
--s-50: #75758b;
|
||||
--s-40: #5d5c72;
|
||||
--s-35: #515165;
|
||||
--s-30: #454559;
|
||||
--s-25: #393a4d;
|
||||
--s-20: #2e2f42;
|
||||
--s-15: #242437;
|
||||
--s-10: #191a2c;
|
||||
--s-5: #0f0f21;
|
||||
--s-0: #000000;
|
||||
|
||||
--t-100: #ffffff;
|
||||
--t-99: #fffbff;
|
||||
--t-98: #fff8f9;
|
||||
--t-95: #ffecf4;
|
||||
--t-90: #ffd8ec;
|
||||
--t-80: #e9b9d3;
|
||||
--t-70: #cc9eb8;
|
||||
--t-60: #af849d;
|
||||
--t-50: #946b83;
|
||||
--t-40: #79536a;
|
||||
--t-35: #6c475d;
|
||||
--t-30: #5f3c51;
|
||||
--t-25: #523146;
|
||||
--t-20: #46263a;
|
||||
--t-15: #3a1b2f;
|
||||
--t-10: #2e1125;
|
||||
--t-5: #22071a;
|
||||
--t-0: #000000;
|
||||
|
||||
--nv-100: #ffffff;
|
||||
--nv-99: #fffbff;
|
||||
--nv-98: #fcf8ff;
|
||||
--nv-95: #f2effa;
|
||||
--nv-90: #e4e1ec;
|
||||
--nv-80: #c8c5d0;
|
||||
--nv-70: #acaab4;
|
||||
--nv-60: #918f9a;
|
||||
--nv-50: #777680;
|
||||
--nv-40: #5e5d67;
|
||||
--nv-35: #52515b;
|
||||
--nv-30: #46464f;
|
||||
--nv-25: #3b3b43;
|
||||
--nv-20: #303038;
|
||||
--nv-15: #25252d;
|
||||
--nv-10: #1b1b23;
|
||||
--nv-5: #101018;
|
||||
--nv-0: #000000;
|
||||
|
||||
--e-100: #ffffff;
|
||||
--e-99: #fffbff;
|
||||
--e-98: #fff8f7;
|
||||
--e-95: #ffedea;
|
||||
--e-90: #ffdad6;
|
||||
--e-80: #ffb4ab;
|
||||
--e-70: #ff897d;
|
||||
--e-60: #ff5449;
|
||||
--e-50: #de3730;
|
||||
--e-40: #ba1a1a;
|
||||
--e-35: #a80710;
|
||||
--e-30: #93000a;
|
||||
--e-25: #7e0007;
|
||||
--e-20: #690005;
|
||||
--e-15: #540003;
|
||||
--e-10: #410002;
|
||||
--e-5: #2d0001;
|
||||
--e-0: #000000;
|
||||
|
||||
--primary: #137fec;
|
||||
--text-color: #fff;
|
||||
--background-light: #f6f7f8;
|
||||
--background-dark: #101922;
|
||||
--border-color: oklch(
|
||||
from var(--background-light) l c h / calc(alpha * 0.15)
|
||||
);
|
||||
--elevated-background-light: oklch(
|
||||
from var(--background-light) l c h / calc(alpha * 0.05)
|
||||
);
|
||||
--bb-grid-size: 4px;
|
||||
--bb-grid-size-2: calc(var(--bb-grid-size) * 2);
|
||||
--bb-grid-size-3: calc(var(--bb-grid-size) * 3);
|
||||
--bb-grid-size-4: calc(var(--bb-grid-size) * 4);
|
||||
--bb-grid-size-5: calc(var(--bb-grid-size) * 5);
|
||||
--bb-grid-size-6: calc(var(--bb-grid-size) * 6);
|
||||
--bb-grid-size-7: calc(var(--bb-grid-size) * 7);
|
||||
--bb-grid-size-8: calc(var(--bb-grid-size) * 8);
|
||||
--bb-grid-size-9: calc(var(--bb-grid-size) * 9);
|
||||
--bb-grid-size-10: calc(var(--bb-grid-size) * 10);
|
||||
--bb-grid-size-11: calc(var(--bb-grid-size) * 11);
|
||||
--bb-grid-size-12: calc(var(--bb-grid-size) * 12);
|
||||
--bb-grid-size-13: calc(var(--bb-grid-size) * 13);
|
||||
--bb-grid-size-14: calc(var(--bb-grid-size) * 14);
|
||||
--bb-grid-size-15: calc(var(--bb-grid-size) * 15);
|
||||
--bb-grid-size-16: calc(var(--bb-grid-size) * 16);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
--font-family: "Google Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-family-flex:
|
||||
"Google Sans Flex", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-family-mono:
|
||||
"Google Sans Code", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
background: var(--background-light);
|
||||
font-family: var(--font-family);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100svw;
|
||||
height: 100svh;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
CopilotKitIntelligence,
|
||||
createCopilotEndpoint,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type {
|
||||
AgentSubscriber,
|
||||
RunAgentInput,
|
||||
RunAgentParameters,
|
||||
RunAgentResult,
|
||||
} from "@ag-ui/client";
|
||||
import { handle } from "hono/vercel";
|
||||
import { A2AAgent } from "@ag-ui/a2a";
|
||||
import type { A2AAgentConfig } from "@ag-ui/a2a";
|
||||
import { A2AClient } from "@a2a-js/sdk/client";
|
||||
|
||||
const a2aClient = new A2AClient("http://localhost:10002");
|
||||
|
||||
type RuntimeRunAgentInput = RunAgentParameters &
|
||||
Partial<Pick<RunAgentInput, "messages" | "state" | "threadId">>;
|
||||
|
||||
class RuntimeA2AAgent extends A2AAgent {
|
||||
private readonly client: A2AClient;
|
||||
|
||||
constructor(config: A2AAgentConfig) {
|
||||
super(config);
|
||||
this.client = config.a2aClient;
|
||||
}
|
||||
|
||||
async runAgent(
|
||||
parameters: RuntimeRunAgentInput = {},
|
||||
subscriber?: AgentSubscriber,
|
||||
): Promise<RunAgentResult> {
|
||||
const isolatedAgent = new A2AAgent({
|
||||
a2aClient: this.client,
|
||||
agentId: this.agentId,
|
||||
debug: this.debug,
|
||||
description: this.description,
|
||||
initialMessages: this.messages,
|
||||
initialState: this.state,
|
||||
threadId: this.threadId,
|
||||
});
|
||||
|
||||
if (parameters.threadId) {
|
||||
isolatedAgent.threadId = parameters.threadId;
|
||||
}
|
||||
|
||||
if (parameters.state) {
|
||||
isolatedAgent.setState(parameters.state);
|
||||
}
|
||||
|
||||
if (parameters.messages) {
|
||||
isolatedAgent.setMessages(parameters.messages);
|
||||
}
|
||||
|
||||
return isolatedAgent.runAgent(
|
||||
{
|
||||
context: parameters.context,
|
||||
forwardedProps: parameters.forwardedProps,
|
||||
runId: parameters.runId,
|
||||
tools: parameters.tools,
|
||||
},
|
||||
subscriber,
|
||||
);
|
||||
}
|
||||
|
||||
clone(): RuntimeA2AAgent {
|
||||
return new RuntimeA2AAgent({
|
||||
a2aClient: this.client,
|
||||
agentId: this.agentId,
|
||||
debug: this.debug,
|
||||
description: this.description,
|
||||
initialMessages: this.messages,
|
||||
initialState: this.state,
|
||||
threadId: this.threadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const agent = new RuntimeA2AAgent({ a2aClient });
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
default: agent,
|
||||
},
|
||||
a2ui: {},
|
||||
// --- copilotkit:intelligence (remove this block to opt out) ---
|
||||
...(process.env.COPILOTKIT_LICENSE_TOKEN
|
||||
? {
|
||||
intelligence: new CopilotKitIntelligence({
|
||||
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
|
||||
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
|
||||
wsUrl:
|
||||
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
|
||||
}),
|
||||
// Demo stub — replace with your own auth-derived user identity (e.g. OIDC)
|
||||
// before any multi-user deployment, or all users share one thread history.
|
||||
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
|
||||
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
|
||||
}
|
||||
: { runner: new InMemoryAgentRunner() }),
|
||||
// --- /copilotkit:intelligence ---
|
||||
});
|
||||
|
||||
const app = createCopilotEndpoint({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
});
|
||||
|
||||
export const GET = handle(app);
|
||||
export const POST = handle(app);
|
||||
export const PATCH = handle(app);
|
||||
export const DELETE = handle(app);
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import * as UI from "@a2ui/lit/ui";
|
||||
|
||||
type Status = "pending" | "success" | "failure";
|
||||
|
||||
const TARGET_TAG = "a2ui-root";
|
||||
|
||||
export function A2UIStatus() {
|
||||
const [status, setStatus] = useState<Status>("pending");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const hasCustomElement =
|
||||
typeof customElements !== "undefined" &&
|
||||
!!customElements.get(TARGET_TAG);
|
||||
|
||||
const hasExport = typeof UI?.Root === "function";
|
||||
|
||||
if (hasCustomElement && hasExport) {
|
||||
setStatus("success");
|
||||
} else {
|
||||
setStatus("failure");
|
||||
}
|
||||
} catch {
|
||||
setStatus("failure");
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (status === "pending") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return status === "success" ? (
|
||||
<span aria-live="polite">✅ All set</span>
|
||||
) : (
|
||||
<span aria-live="assertive">❌ Failed</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactActivityMessageRenderer } from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
|
||||
type A2UIOperation = {
|
||||
beginRendering?: { surfaceId?: string };
|
||||
surfaceUpdate?: {
|
||||
components?: Array<{
|
||||
id?: string;
|
||||
component?: {
|
||||
Text?: {
|
||||
text?: { literalString?: string };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
dataModelUpdate?: {
|
||||
contents?: A2UIDataEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
type A2UIDataEntry = {
|
||||
key: string;
|
||||
valueString?: string;
|
||||
valueMap?: A2UIDataEntry[];
|
||||
};
|
||||
|
||||
type Restaurant = {
|
||||
name?: string;
|
||||
rating?: string;
|
||||
detail?: string;
|
||||
infoLink?: string;
|
||||
imageUrl?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
function getOperations(content: unknown): A2UIOperation[] {
|
||||
if (!content || typeof content !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const payload = content as {
|
||||
a2ui_operations?: A2UIOperation[];
|
||||
operations?: A2UIOperation[];
|
||||
};
|
||||
|
||||
const operations = payload.a2ui_operations ?? payload.operations;
|
||||
if (Array.isArray(operations)) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
if (!operations || typeof operations !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
"beginRendering" in operations ||
|
||||
"surfaceUpdate" in operations ||
|
||||
"dataModelUpdate" in operations
|
||||
) {
|
||||
return [operations as A2UIOperation];
|
||||
}
|
||||
|
||||
return Object.values(operations).filter(
|
||||
(operation): operation is A2UIOperation =>
|
||||
!!operation && typeof operation === "object",
|
||||
);
|
||||
}
|
||||
|
||||
function getTitle(operations: A2UIOperation[]): string {
|
||||
for (const operation of operations) {
|
||||
const title = operation.surfaceUpdate?.components?.find(
|
||||
(component) => component.id === "title-heading",
|
||||
)?.component?.Text?.text?.literalString;
|
||||
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
return "Top Restaurants";
|
||||
}
|
||||
|
||||
function dataEntriesToObject(entries: A2UIDataEntry[] = []): Restaurant {
|
||||
return Object.fromEntries(
|
||||
entries.map((entry) => [entry.key, entry.valueString ?? ""]),
|
||||
);
|
||||
}
|
||||
|
||||
function getRestaurants(operations: A2UIOperation[]): Restaurant[] {
|
||||
const dataModel = operations.find(
|
||||
(operation) => operation.dataModelUpdate,
|
||||
)?.dataModelUpdate;
|
||||
|
||||
const itemsEntry = dataModel?.contents?.find(
|
||||
(entry) => entry.key === "items",
|
||||
);
|
||||
return (itemsEntry?.valueMap ?? []).map((entry) =>
|
||||
dataEntriesToObject(entry.valueMap),
|
||||
);
|
||||
}
|
||||
|
||||
function readableInfoLink(infoLink: string | undefined): string | null {
|
||||
if (!infoLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = infoLink.match(/\[([^\]]+)\]\(([^)]+)\)/);
|
||||
return match?.[2] ?? infoLink;
|
||||
}
|
||||
|
||||
function A2UIV08Surface({ content }: { content: unknown }) {
|
||||
const operations = getOperations(content);
|
||||
const restaurants = getRestaurants(operations);
|
||||
const title = getTitle(operations);
|
||||
|
||||
if (!operations.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-500">
|
||||
Generating UI...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{restaurants.map((restaurant, index) => (
|
||||
<article
|
||||
key={`${restaurant.name ?? "restaurant"}-${index}`}
|
||||
className="grid gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm sm:grid-cols-[144px_1fr]"
|
||||
>
|
||||
{restaurant.imageUrl ? (
|
||||
<img
|
||||
src={restaurant.imageUrl}
|
||||
alt={restaurant.name ?? "Restaurant"}
|
||||
className="h-32 w-full rounded-md object-cover sm:h-full"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-950">
|
||||
{restaurant.name}
|
||||
</h3>
|
||||
{restaurant.rating ? (
|
||||
<p className="text-sm text-amber-500">{restaurant.rating}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{restaurant.detail ? (
|
||||
<p className="text-sm text-gray-600">{restaurant.detail}</p>
|
||||
) : null}
|
||||
{restaurant.address ? (
|
||||
<p className="text-xs text-gray-500">{restaurant.address}</p>
|
||||
) : null}
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{readableInfoLink(restaurant.infoLink) ? (
|
||||
<a
|
||||
href={readableInfoLink(restaurant.infoLink) ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-9 items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700"
|
||||
>
|
||||
More Info
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center rounded-md bg-[#FF0000] px-3 text-sm font-medium text-white"
|
||||
>
|
||||
Book Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const a2uiV08Renderer: ReactActivityMessageRenderer<unknown> = {
|
||||
activityType: "a2ui-surface",
|
||||
content: z.unknown(),
|
||||
render: ({ content }) => <A2UIV08Surface content={content} />,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
|
||||
export { default as ThreadsDrawer } from "./threads-drawer";
|
||||
export type { ThreadsDrawerProps } from "./threads-drawer";
|
||||
@@ -0,0 +1,70 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+891
@@ -0,0 +1,891 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
"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’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,
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,58 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
|
||||
--radius: 0.625rem;
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--font-body: Arial, Helvetica, sans-serif;
|
||||
--font-code: "SFMono-Regular", Menlo, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply min-h-screen bg-background text-foreground font-sans antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
.threadsLayout,
|
||||
body > [role="presentation"] {
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import "./a2ui-theme.css";
|
||||
import "@copilotkit/react-core/v2/styles.css";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Google+Sans+Code&family=Google+Sans+Flex:opsz,wght,ROND@6..144,1..1000,100&family=Google+Sans:opsz,wght@17..18,400..700&display=block&family=IBM+Plex+Serif:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Google+Symbols:opsz,wght,FILL,GRAD,ROND@20..48,100..700,0..1,-50..200,0..100&display=swap&icon_names=arrow_drop_down,check_circle,close,communication,content_copy,delete,draw,error,info,mobile_layout,pen_size_1,progress_activity,rectangle,send,upload,warning"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopilotChat,
|
||||
CopilotChatConfigurationProvider,
|
||||
CopilotKitProvider,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { useState } from "react";
|
||||
import { a2uiV08Renderer } from "./components/a2ui-v0-8-renderer";
|
||||
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 { theme } from "./theme";
|
||||
|
||||
// Disable static optimization for this page
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const activityRenderers = [a2uiV08Renderer];
|
||||
|
||||
export default function Home() {
|
||||
const [threadId, setThreadId] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<CopilotKitProvider
|
||||
runtimeUrl="/api/copilotkit"
|
||||
showDevConsole="auto"
|
||||
useSingleEndpoint={false}
|
||||
a2ui={{ theme }}
|
||||
renderActivityMessages={activityRenderers}
|
||||
>
|
||||
<div className={`${styles.layout} threadsLayout`}>
|
||||
<ThreadsPanelGate>
|
||||
<ThreadsDrawer
|
||||
agentId="default"
|
||||
threadId={threadId}
|
||||
onThreadChange={setThreadId}
|
||||
/>
|
||||
</ThreadsPanelGate>
|
||||
<div className={styles.mainPanel}>
|
||||
<CopilotChatConfigurationProvider
|
||||
agentId="default"
|
||||
threadId={threadId}
|
||||
>
|
||||
<main
|
||||
className="h-full overflow-auto w-screen"
|
||||
style={{ minHeight: "100dvh" }}
|
||||
>
|
||||
<CopilotChat agentId="default" className="h-full" />
|
||||
</main>
|
||||
</CopilotChatConfigurationProvider>
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Styles, type Types } from "@a2ui/lit/0.8";
|
||||
|
||||
/** Elements */
|
||||
|
||||
const a = {
|
||||
"typography-f-sf": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-500": true,
|
||||
"layout-as-n": true,
|
||||
"layout-dis-iflx": true,
|
||||
"layout-al-c": true,
|
||||
};
|
||||
|
||||
const audio = {
|
||||
"layout-w-100": true,
|
||||
};
|
||||
|
||||
const body = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-mt-0": true,
|
||||
"layout-mb-2": true,
|
||||
"typography-sz-bm": true,
|
||||
"color-c-n10": true,
|
||||
};
|
||||
|
||||
const button = {
|
||||
"typography-f-sf": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-500": true,
|
||||
"layout-pt-3": true,
|
||||
"layout-pb-3": true,
|
||||
"layout-pl-5": true,
|
||||
"layout-pr-5": true,
|
||||
"layout-mb-1": true,
|
||||
"border-br-16": true,
|
||||
"border-bw-0": true,
|
||||
"border-c-n70": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-s30": true,
|
||||
"color-c-n100": true,
|
||||
"behavior-ho-80": true,
|
||||
};
|
||||
|
||||
const heading = {
|
||||
"typography-f-sf": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-500": true,
|
||||
"layout-mt-0": true,
|
||||
"layout-mb-2": true,
|
||||
"color-c-n10": true,
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
...heading,
|
||||
"typography-sz-tl": true,
|
||||
};
|
||||
|
||||
const h2 = {
|
||||
...heading,
|
||||
"typography-sz-tm": true,
|
||||
};
|
||||
|
||||
const h3 = {
|
||||
...heading,
|
||||
"typography-sz-ts": true,
|
||||
};
|
||||
|
||||
const h4 = {
|
||||
...heading,
|
||||
"typography-sz-bl": true,
|
||||
};
|
||||
|
||||
const h5 = {
|
||||
...heading,
|
||||
"typography-sz-bm": true,
|
||||
};
|
||||
|
||||
const iframe = {
|
||||
"behavior-sw-n": true,
|
||||
};
|
||||
|
||||
const input = {
|
||||
"typography-f-sf": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-pl-4": true,
|
||||
"layout-pr-4": true,
|
||||
"layout-pt-2": true,
|
||||
"layout-pb-2": true,
|
||||
"border-br-6": true,
|
||||
"border-bw-1": true,
|
||||
"color-bc-s70": true,
|
||||
"border-bs-s": true,
|
||||
"layout-as-n": true,
|
||||
"color-c-n10": true,
|
||||
};
|
||||
|
||||
const p = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"typography-sz-bm": true,
|
||||
"layout-as-n": true,
|
||||
"color-c-n10": true,
|
||||
};
|
||||
|
||||
const orderedList = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"typography-sz-bm": true,
|
||||
"layout-as-n": true,
|
||||
};
|
||||
|
||||
const unorderedList = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"typography-sz-bm": true,
|
||||
"layout-as-n": true,
|
||||
};
|
||||
|
||||
const listItem = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"typography-sz-bm": true,
|
||||
"layout-as-n": true,
|
||||
};
|
||||
|
||||
const pre = {
|
||||
"typography-f-c": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"typography-sz-bm": true,
|
||||
"typography-ws-p": true,
|
||||
"layout-as-n": true,
|
||||
};
|
||||
|
||||
const textarea = {
|
||||
...input,
|
||||
"layout-r-none": true,
|
||||
"layout-fs-c": true,
|
||||
};
|
||||
|
||||
const video = {
|
||||
"layout-el-cv": true,
|
||||
};
|
||||
|
||||
const aLight = Styles.merge(a, { "color-c-n5": true });
|
||||
const inputLight = Styles.merge(input, { "color-c-n5": true });
|
||||
const textareaLight = Styles.merge(textarea, { "color-c-n5": true });
|
||||
const buttonLight = Styles.merge(button, { "color-c-n100": true });
|
||||
const h1Light = Styles.merge(h1, { "color-c-n5": true });
|
||||
const h2Light = Styles.merge(h2, { "color-c-n5": true });
|
||||
const h3Light = Styles.merge(h3, { "color-c-n5": true });
|
||||
const h4Light = Styles.merge(h4, { "color-c-n5": true });
|
||||
const h5Light = Styles.merge(h5, { "color-c-n5": true });
|
||||
const bodyLight = Styles.merge(body, { "color-c-n5": true });
|
||||
const pLight = Styles.merge(p, { "color-c-n35": true });
|
||||
const preLight = Styles.merge(pre, { "color-c-n35": true });
|
||||
const orderedListLight = Styles.merge(orderedList, {
|
||||
"color-c-n35": true,
|
||||
});
|
||||
const unorderedListLight = Styles.merge(unorderedList, {
|
||||
"color-c-n35": true,
|
||||
});
|
||||
const listItemLight = Styles.merge(listItem, {
|
||||
"color-c-n35": true,
|
||||
});
|
||||
|
||||
export const theme: Types.Theme = {
|
||||
additionalStyles: {
|
||||
Button: {
|
||||
"--n-35": "var(--n-100)",
|
||||
},
|
||||
},
|
||||
components: {
|
||||
AudioPlayer: {},
|
||||
Button: {
|
||||
"layout-pt-2": true,
|
||||
"layout-pb-2": true,
|
||||
"layout-pl-3": true,
|
||||
"layout-pr-3": true,
|
||||
"border-br-12": true,
|
||||
"border-bw-0": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-p30": true,
|
||||
"color-c-n100": true,
|
||||
"behavior-ho-70": true,
|
||||
},
|
||||
Card: { "border-br-9": true, "color-bgc-p100": true, "layout-p-4": true },
|
||||
CheckBox: {
|
||||
element: {
|
||||
"layout-m-0": true,
|
||||
"layout-mr-2": true,
|
||||
"layout-p-2": true,
|
||||
"border-br-12": true,
|
||||
"border-bw-1": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-p100": true,
|
||||
"color-bc-p60": true,
|
||||
"color-c-n30": true,
|
||||
"color-c-p30": true,
|
||||
},
|
||||
label: {
|
||||
"color-c-p30": true,
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-flx-1": true,
|
||||
"typography-sz-ll": true,
|
||||
},
|
||||
container: {
|
||||
"layout-dsp-iflex": true,
|
||||
"layout-al-c": true,
|
||||
},
|
||||
},
|
||||
Column: {
|
||||
"layout-g-2": true,
|
||||
},
|
||||
DateTimeInput: {
|
||||
container: {
|
||||
"typography-sz-bm": true,
|
||||
"layout-w-100": true,
|
||||
"layout-g-2": true,
|
||||
"layout-dsp-flexhor": true,
|
||||
"layout-al-c": true,
|
||||
},
|
||||
label: {
|
||||
"layout-flx-0": true,
|
||||
},
|
||||
element: {
|
||||
"layout-pt-2": true,
|
||||
"layout-pb-2": true,
|
||||
"layout-pl-3": true,
|
||||
"layout-pr-3": true,
|
||||
"border-br-12": true,
|
||||
"border-bw-1": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-p100": true,
|
||||
"color-bc-p60": true,
|
||||
"color-c-n30": true,
|
||||
"color-c-p30": true,
|
||||
},
|
||||
},
|
||||
Divider: {},
|
||||
Image: {
|
||||
all: {
|
||||
"border-br-5": true,
|
||||
"layout-el-cv": true,
|
||||
"layout-w-100": true,
|
||||
"layout-h-100": true,
|
||||
},
|
||||
avatar: {},
|
||||
header: {},
|
||||
icon: {},
|
||||
largeFeature: {},
|
||||
mediumFeature: {},
|
||||
smallFeature: {},
|
||||
},
|
||||
Icon: {},
|
||||
List: {
|
||||
"layout-g-4": true,
|
||||
"layout-p-2": true,
|
||||
},
|
||||
Modal: {
|
||||
backdrop: { "color-bbgc-p60_20": true },
|
||||
element: {
|
||||
"border-br-2": true,
|
||||
"color-bgc-p100": true,
|
||||
"layout-p-4": true,
|
||||
"border-bw-1": true,
|
||||
"border-bs-s": true,
|
||||
"color-bc-p80": true,
|
||||
},
|
||||
},
|
||||
MultipleChoice: {
|
||||
container: {},
|
||||
label: {},
|
||||
element: {},
|
||||
},
|
||||
Row: {
|
||||
"layout-g-4": true,
|
||||
},
|
||||
Slider: {
|
||||
container: {},
|
||||
label: {},
|
||||
element: {},
|
||||
},
|
||||
Tabs: {
|
||||
container: {},
|
||||
controls: { all: {}, selected: {} },
|
||||
element: {},
|
||||
},
|
||||
Text: {
|
||||
all: {
|
||||
"layout-w-100": true,
|
||||
"layout-g-2": true,
|
||||
"color-c-p30": true,
|
||||
},
|
||||
h1: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-tl": true,
|
||||
},
|
||||
h2: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-tm": true,
|
||||
},
|
||||
h3: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-ts": true,
|
||||
},
|
||||
h4: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-bl": true,
|
||||
},
|
||||
h5: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-bm": true,
|
||||
},
|
||||
body: {},
|
||||
caption: {},
|
||||
},
|
||||
TextField: {
|
||||
container: {
|
||||
"typography-sz-bm": true,
|
||||
"layout-w-100": true,
|
||||
"layout-g-2": true,
|
||||
"layout-dsp-flexhor": true,
|
||||
"layout-al-c": true,
|
||||
},
|
||||
label: {
|
||||
"layout-flx-0": true,
|
||||
},
|
||||
element: {
|
||||
"typography-sz-bm": true,
|
||||
"layout-pt-2": true,
|
||||
"layout-pb-2": true,
|
||||
"layout-pl-3": true,
|
||||
"layout-pr-3": true,
|
||||
"border-br-12": true,
|
||||
"border-bw-1": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-p100": true,
|
||||
"color-bc-p60": true,
|
||||
"color-c-n30": true,
|
||||
"color-c-p30": true,
|
||||
},
|
||||
},
|
||||
Video: {
|
||||
"border-br-5": true,
|
||||
"layout-el-cv": true,
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
a: aLight,
|
||||
audio,
|
||||
body: bodyLight,
|
||||
button: buttonLight,
|
||||
h1: h1Light,
|
||||
h2: h2Light,
|
||||
h3: h3Light,
|
||||
h4: h4Light,
|
||||
h5: h5Light,
|
||||
iframe,
|
||||
input: inputLight,
|
||||
p: pLight,
|
||||
pre: preLight,
|
||||
textarea: textareaLight,
|
||||
video,
|
||||
},
|
||||
markdown: {
|
||||
p: Object.keys(pLight),
|
||||
h1: Object.keys(h1Light),
|
||||
h2: Object.keys(h2Light),
|
||||
h3: Object.keys(h3Light),
|
||||
h4: Object.keys(h4Light),
|
||||
h5: Object.keys(h5Light),
|
||||
ul: Object.keys(unorderedListLight),
|
||||
ol: Object.keys(orderedListLight),
|
||||
li: Object.keys(listItemLight),
|
||||
a: Object.keys(aLight),
|
||||
strong: [],
|
||||
em: [],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user