chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
# @copilotkit/web-components
|
||||
|
||||
Framework-agnostic, shadow-DOM [Lit](https://lit.dev) custom elements for CopilotKit.
|
||||
|
||||
This package currently ships **`<copilotkit-threads-drawer>`** — a public, self-contained,
|
||||
controlled threads drawer. It is a pure **VIEW**: domain data flows in as
|
||||
properties and user intent flows out as DOM `CustomEvent`s. It imports no React,
|
||||
Angular, or `@copilotkit/core` code and renders correctly in any host page,
|
||||
surviving hostile host CSS (`all: unset`, Tailwind preflight `!important`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @copilotkit/web-components lit
|
||||
```
|
||||
|
||||
`lit` is a peer/runtime dependency so the host dedupes a single Lit runtime.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { defineCopilotKitThreadsDrawer } from "@copilotkit/web-components/threads-drawer";
|
||||
|
||||
defineCopilotKitThreadsDrawer(); // registers <copilotkit-threads-drawer> (idempotent)
|
||||
```
|
||||
|
||||
```html
|
||||
<copilotkit-threads-drawer active-thread-id="t-123"></copilotkit-threads-drawer>
|
||||
<script>
|
||||
const drawer = document.querySelector("copilotkit-threads-drawer");
|
||||
drawer.threads = [
|
||||
{
|
||||
id: "t-123",
|
||||
name: "My thread",
|
||||
archived: false,
|
||||
createdAt: "...",
|
||||
updatedAt: "...",
|
||||
},
|
||||
];
|
||||
drawer.addEventListener("thread-selected", (e) => open(e.detail.threadId));
|
||||
drawer.addEventListener("new-thread", () => createThread());
|
||||
</script>
|
||||
```
|
||||
|
||||
### Inbound properties (DOMAIN state — owned by the consumer)
|
||||
|
||||
| property | type | notes |
|
||||
| ---------------- | ---------------- | ----------------------------------------------- |
|
||||
| `threads` | `DrawerThread[]` | the element re-orders + filters authoritatively |
|
||||
| `loading` | `boolean` | initial-fetch loading |
|
||||
| `error` | `string \| null` | initial-fetch error → actionable Retry |
|
||||
| `activeThreadId` | `string \| null` | drives selection highlight |
|
||||
| `licensed` | `boolean` | `false` → upsell replaces the list |
|
||||
| `fetchingMore` | `boolean` | in-flight pagination |
|
||||
| `fetchMoreError` | `string \| null` | inline "couldn't load more — retry" |
|
||||
| `open` | `boolean` | externally controllable (mobile coordination) |
|
||||
| `collapsed` | `boolean` | desktop collapse-to-rail |
|
||||
|
||||
### Outbound events (INTENT — bubbling + composed `CustomEvent`s)
|
||||
|
||||
`thread-selected`, `archive`, `unarchive`, `delete` (after in-element confirm),
|
||||
`new-thread`, `filter-change`, `open-change`, `retry` (`{ scope }`), `upsell`.
|
||||
|
||||
The element owns VIEW state: open/collapsed, the Active/All filter, the
|
||||
confirm-delete dialog, and per-row entry/reveal animations.
|
||||
|
||||
## Theming (hybrid)
|
||||
|
||||
- **CSS variables** pierce the shadow boundary — set `--cpk-drawer-*`
|
||||
(`-bg`, `-fg`, `-surface`, `-accent`, `-primary`, `-danger`, `-border`,
|
||||
`-ring`, `-radius`, `-width`, `-font-family`, …) from the host.
|
||||
- **`::part()`** hooks on structural nodes (`root`, `header`, `list`, `row`,
|
||||
`row-name`, `confirm-dialog`, `backdrop`, …).
|
||||
- **Named slots**: `header`, `footer`, `empty`, `upsell`, `memories`, plus a
|
||||
**per-row slot** `row:{id}` that projects wrapper-provided row content while
|
||||
the element keeps the selection/archived/animation chrome around it.
|
||||
|
||||
The built-in default skin's token values are **derived at build time** from
|
||||
react-core's canonical theme (`packages/react-core/src/v2/styles/globals.css`)
|
||||
by `scripts/generate-tokens.ts`, which writes the checked-in
|
||||
`src/threads-drawer/generated-tokens.ts`. Run `pnpm run gen:tokens` to regenerate;
|
||||
`generated-tokens.test.ts` fails if the checked-in values drift from react-core.
|
||||
|
||||
## Mobile + a11y
|
||||
|
||||
At/below `768px` the drawer is an off-canvas modal overlay with backdrop,
|
||||
`Escape` close, scroll-lock, and a focus trap (mobile only) that operates over
|
||||
the composed/flattened tree so slotted rows are included. On desktop it is an
|
||||
in-flow region with collapse-to-rail — **not** a modal (no focus trap, no
|
||||
scroll-lock).
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "@copilotkit/web-components",
|
||||
"version": "1.62.3",
|
||||
"private": false,
|
||||
"description": "Framework-agnostic shadow-DOM web components for CopilotKit - includes the <copilotkit-threads-drawer> threads drawer element",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"copilotkit",
|
||||
"custom-elements",
|
||||
"drawer",
|
||||
"lit",
|
||||
"shadow-dom",
|
||||
"threads",
|
||||
"web-components"
|
||||
],
|
||||
"homepage": "https://github.com/CopilotKit/CopilotKit",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CopilotKit/CopilotKit.git",
|
||||
"directory": "packages/web-components"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.cts",
|
||||
"unpkg": "./dist/index.umd.js",
|
||||
"jsdelivr": "./dist/index.umd.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./threads-drawer": {
|
||||
"import": {
|
||||
"types": "./dist/threads-drawer/index.d.mts",
|
||||
"default": "./dist/threads-drawer/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/threads-drawer/index.d.cts",
|
||||
"default": "./dist/threads-drawer/index.cjs"
|
||||
}
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pnpm run gen:tokens && tsdown",
|
||||
"gen:tokens": "tsx scripts/generate-tokens.ts",
|
||||
"compat-check": "es-check es2022 --module 'dist/**/!(*.umd).{mjs,cjs,js}' && es-check es2018 'dist/**/*.umd.js'",
|
||||
"check-types": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"publint": "publint .",
|
||||
"attw": "attw --pack . --profile node16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@copilotkit/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.15.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"lit": "^3.3.2",
|
||||
"tsdown": "^0.20.3",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Build-time token sync (anti-drift).
|
||||
*
|
||||
* `packages/react-core/src/v2/styles/globals.css` is the canonical source of
|
||||
* truth for CopilotKit's visual design tokens. This script reads the light-mode
|
||||
* `[data-copilotkit] { ... }` token block from that file and DERIVES a small,
|
||||
* curated subset of bundled default values that the drawer's shadow-DOM CSS
|
||||
* falls back to (e.g. `var(--cpk-drawer-bg, <built default>)`).
|
||||
*
|
||||
* Running this at build time means the drawer's bundled defaults cannot drift
|
||||
* away from react-core's theme: a token change in react-core re-derives here.
|
||||
* The output is a checked-in generated file (`src/threads-drawer/generated-tokens.ts`)
|
||||
* so the package still builds and tests run without invoking the script, and so
|
||||
* drift is visible in code review as a diff to that file.
|
||||
*
|
||||
* If the canonical file or a required token is missing, this fails loudly
|
||||
* rather than silently emitting stale or empty defaults.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
extractLightThemeBlock,
|
||||
parseCustomProperties,
|
||||
} from "../src/threads-drawer/token-extraction";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageDir = path.resolve(dirname, "..");
|
||||
const repoRoot = path.resolve(packageDir, "../../");
|
||||
|
||||
const GLOBALS_CSS = path.join(
|
||||
repoRoot,
|
||||
"packages/react-core/src/v2/styles/globals.css",
|
||||
);
|
||||
|
||||
const OUTPUT = path.join(packageDir, "src/threads-drawer/generated-tokens.ts");
|
||||
|
||||
/**
|
||||
* The react-core token names (without leading `--`) that the drawer derives its
|
||||
* bundled defaults from, mapped to the drawer-local token suffix they back.
|
||||
* Keep this list minimal and structural — the drawer is a small surface.
|
||||
*/
|
||||
const TOKEN_MAP: ReadonlyArray<
|
||||
readonly [reactCoreVar: string, drawerToken: string]
|
||||
> = [
|
||||
["background", "bg"],
|
||||
["foreground", "fg"],
|
||||
["card", "surface"],
|
||||
["card-foreground", "surface-fg"],
|
||||
["muted", "muted"],
|
||||
["muted-foreground", "muted-fg"],
|
||||
["accent", "accent"],
|
||||
["accent-foreground", "accent-fg"],
|
||||
["primary", "primary"],
|
||||
["primary-foreground", "primary-fg"],
|
||||
["destructive", "danger"],
|
||||
["border", "border"],
|
||||
["ring", "ring"],
|
||||
["radius", "radius"],
|
||||
];
|
||||
|
||||
function formatObjectKey(key: string): string {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const css = readFileSync(GLOBALS_CSS, "utf8");
|
||||
const block = extractLightThemeBlock(css, GLOBALS_CSS);
|
||||
const props = parseCustomProperties(block);
|
||||
|
||||
const entries: string[] = [];
|
||||
for (const [reactCoreVar, drawerToken] of TOKEN_MAP) {
|
||||
const value = props.get(reactCoreVar);
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
`[generate-tokens] Required token "--${reactCoreVar}" not found in canonical theme; ` +
|
||||
`react-core globals.css changed shape — update TOKEN_MAP in generate-tokens.ts.`,
|
||||
);
|
||||
}
|
||||
entries.push(
|
||||
` ${formatObjectKey(drawerToken)}: ${JSON.stringify(value)},`,
|
||||
);
|
||||
}
|
||||
|
||||
const file = `/**
|
||||
* GENERATED FILE — DO NOT EDIT BY HAND.
|
||||
*
|
||||
* Bundled default values for the drawer's design tokens, derived at build time
|
||||
* from the canonical react-core theme (\`packages/react-core/src/v2/styles/globals.css\`)
|
||||
* by \`scripts/generate-tokens.ts\`. Run \`pnpm run gen:tokens\` to regenerate.
|
||||
*
|
||||
* The drawer's shadow-DOM CSS references these as fallbacks, e.g.
|
||||
* \`var(--cpk-drawer-bg, <built default>)\`, so consumers can override every
|
||||
* token while the built-in skin stays in sync with react-core.
|
||||
*/
|
||||
export const GENERATED_DRAWER_TOKEN_DEFAULTS = {
|
||||
${entries.join("\n")}
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
export type GeneratedDrawerTokenKey =
|
||||
keyof typeof GENERATED_DRAWER_TOKEN_DEFAULTS;
|
||||
`;
|
||||
|
||||
writeFileSync(OUTPUT, file, "utf8");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[generate-tokens] Wrote ${TOKEN_MAP.length} derived token defaults to ${path.relative(repoRoot, OUTPUT)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Only run the file-writing entrypoint when executed as a script (via tsx),
|
||||
// NOT when imported by a test that exercises the pure helpers.
|
||||
const executedDirectly =
|
||||
typeof process !== "undefined" &&
|
||||
Array.isArray(process.argv) &&
|
||||
process.argv[1] !== undefined &&
|
||||
fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
||||
|
||||
if (executedDirectly) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* `@copilotkit/web-components` — framework-agnostic shadow-DOM custom elements
|
||||
* for CopilotKit.
|
||||
*
|
||||
* Currently exposes the `<copilotkit-threads-drawer>` threads drawer. Importing this
|
||||
* root re-exports the drawer's public API; `@copilotkit/web-components/threads-drawer`
|
||||
* is the focused subpath for tree-shaking when only the drawer is needed.
|
||||
*/
|
||||
export * from "./threads-drawer/index";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Guards the build-time token-sync (anti-drift) contract.
|
||||
*
|
||||
* The drawer's bundled default token values are DERIVED from react-core's
|
||||
* canonical theme by `scripts/generate-tokens.ts` and checked in as
|
||||
* `generated-tokens.ts`. These tests assert (a) the generated file exposes the
|
||||
* tokens the shadow-DOM CSS depends on, and (b) the checked-in values still
|
||||
* match what the canonical theme currently declares — i.e. the generated file
|
||||
* has not drifted from `react-core/.../globals.css`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
import { GENERATED_DRAWER_TOKEN_DEFAULTS } from "../generated-tokens";
|
||||
import type { GeneratedDrawerTokenKey } from "../generated-tokens";
|
||||
import {
|
||||
extractLightThemeBlock,
|
||||
parseCustomProperties,
|
||||
} from "../token-extraction";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// __tests__ -> drawer -> src -> web-components -> packages -> repo root
|
||||
const repoRoot = path.resolve(dirname, "../../../../../");
|
||||
const GLOBALS_CSS = path.join(
|
||||
repoRoot,
|
||||
"packages/react-core/src/v2/styles/globals.css",
|
||||
);
|
||||
|
||||
/** Maps drawer token keys back to the react-core custom property they derive from. */
|
||||
const DERIVATION: Record<GeneratedDrawerTokenKey, string> = {
|
||||
bg: "background",
|
||||
fg: "foreground",
|
||||
surface: "card",
|
||||
"surface-fg": "card-foreground",
|
||||
muted: "muted",
|
||||
"muted-fg": "muted-foreground",
|
||||
accent: "accent",
|
||||
"accent-fg": "accent-foreground",
|
||||
primary: "primary",
|
||||
"primary-fg": "primary-foreground",
|
||||
danger: "destructive",
|
||||
border: "border",
|
||||
ring: "ring",
|
||||
radius: "radius",
|
||||
};
|
||||
|
||||
function readCanonicalLightTokens(): Map<string, string> {
|
||||
const css = readFileSync(GLOBALS_CSS, "utf8");
|
||||
// Route through the SAME canonical parser the build script uses, rather than a
|
||||
// local naive first-`}` scan: a reimplementation here could pass while the real
|
||||
// extractor truncates (or vice-versa), defeating the drift guarantee.
|
||||
return parseCustomProperties(extractLightThemeBlock(css, GLOBALS_CSS));
|
||||
}
|
||||
|
||||
test("generated defaults expose every token the drawer CSS references", () => {
|
||||
const expectedKeys: GeneratedDrawerTokenKey[] = [
|
||||
"bg",
|
||||
"fg",
|
||||
"surface",
|
||||
"surface-fg",
|
||||
"muted",
|
||||
"muted-fg",
|
||||
"accent",
|
||||
"accent-fg",
|
||||
"primary",
|
||||
"primary-fg",
|
||||
"danger",
|
||||
"border",
|
||||
"ring",
|
||||
"radius",
|
||||
];
|
||||
|
||||
for (const key of expectedKeys) {
|
||||
expect(GENERATED_DRAWER_TOKEN_DEFAULTS[key]).toBeTypeOf("string");
|
||||
expect(GENERATED_DRAWER_TOKEN_DEFAULTS[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("checked-in generated defaults have not drifted from react-core's canonical theme", () => {
|
||||
const canonical = readCanonicalLightTokens();
|
||||
|
||||
for (const [drawerKey, reactCoreVar] of Object.entries(DERIVATION)) {
|
||||
const canonicalValue = canonical.get(reactCoreVar);
|
||||
expect(
|
||||
canonicalValue,
|
||||
`react-core theme is missing --${reactCoreVar}; regenerate tokens`,
|
||||
).toBeDefined();
|
||||
expect(
|
||||
GENERATED_DRAWER_TOKEN_DEFAULTS[drawerKey as GeneratedDrawerTokenKey],
|
||||
`drawer token "${drawerKey}" drifted from react-core --${reactCoreVar}; run "pnpm run gen:tokens"`,
|
||||
).toBe(canonicalValue);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Fix 8: brace-balanced block extraction -------------------------------
|
||||
|
||||
test("extractLightThemeBlock captures the full block past a nested brace", () => {
|
||||
const css = [
|
||||
"[data-copilotkit] {",
|
||||
" --background: #fff;",
|
||||
" @media (min-width: 0) { --inner: 1; }",
|
||||
" --foreground: #000;",
|
||||
"}",
|
||||
".other { --x: 1; }",
|
||||
].join("\n");
|
||||
|
||||
const block = extractLightThemeBlock(css);
|
||||
|
||||
// a naive first-`}` scan would stop at the @media close and drop --foreground
|
||||
expect(block).toContain("--background: #fff;");
|
||||
expect(block).toContain("--foreground: #000;");
|
||||
// and must NOT bleed into the sibling rule that follows the matching close
|
||||
expect(block).not.toContain("--x: 1;");
|
||||
});
|
||||
|
||||
test("extractLightThemeBlock throws on unbalanced braces rather than truncating", () => {
|
||||
const css = "[data-copilotkit] {\n --background: #fff;\n { --orphan: 1;\n";
|
||||
|
||||
expect(() => extractLightThemeBlock(css)).toThrow(/Unbalanced braces/);
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* De-risk spike (required by the design).
|
||||
*
|
||||
* Proves that the id-keyed per-row `<slot name="row:{id}">` projection survives
|
||||
* a live reorder of `threads` WITHOUT remounting/recreating the slotted row
|
||||
* content. Two independent identity guarantees are asserted across a reorder:
|
||||
*
|
||||
* 1. The wrapper-provided light-DOM node (projected into the slot) is never
|
||||
* touched by the element — it is the same DOM node before and after.
|
||||
* 2. The shadow-side `<slot name="row:{id}">` element for a given thread id is
|
||||
* reused (same node identity), because `repeat()` is keyed by thread id —
|
||||
* so projection is preserved rather than torn down and re-established.
|
||||
*/
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import {
|
||||
COPILOTKIT_THREADS_DRAWER_TAG,
|
||||
defineCopilotKitThreadsDrawer,
|
||||
} from "../index";
|
||||
import type { DrawerThread, CopilotKitThreadsDrawer } from "../index";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
async function flush(element: CopilotKitThreadsDrawer) {
|
||||
await element.updateComplete;
|
||||
await tick();
|
||||
await element.updateComplete;
|
||||
}
|
||||
|
||||
function thread(id: string, updatedAt: string): DrawerThread {
|
||||
return {
|
||||
id,
|
||||
name: `Thread ${id}`,
|
||||
archived: false,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
test("id-keyed per-row slot reconciliation survives a live reorder without remounting slotted content", async () => {
|
||||
defineCopilotKitThreadsDrawer();
|
||||
window.matchMedia = vi.fn().mockReturnValue({
|
||||
matches: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
}) as unknown as typeof window.matchMedia;
|
||||
|
||||
const element = document.createElement(
|
||||
COPILOTKIT_THREADS_DRAWER_TAG,
|
||||
) as CopilotKitThreadsDrawer;
|
||||
|
||||
// Newest-first ordering by updatedAt → initial order is [a, b].
|
||||
element.threads = [
|
||||
thread("a", "2026-06-20T00:00:00.000Z"),
|
||||
thread("b", "2026-06-10T00:00:00.000Z"),
|
||||
];
|
||||
|
||||
// Wrapper projects per-row light-DOM content for each thread id.
|
||||
const rowA = document.createElement("span");
|
||||
rowA.slot = "row:a";
|
||||
rowA.id = "wrapper-row-a";
|
||||
const rowB = document.createElement("span");
|
||||
rowB.slot = "row:b";
|
||||
rowB.id = "wrapper-row-b";
|
||||
element.append(rowA, rowB);
|
||||
|
||||
document.body.appendChild(element);
|
||||
await flush(element);
|
||||
|
||||
const shadow = element.shadowRoot as ShadowRoot;
|
||||
const slotForBefore = (id: string) =>
|
||||
shadow.querySelector(`slot[name="row:${id}"]`) as HTMLSlotElement;
|
||||
|
||||
const slotABefore = slotForBefore("a");
|
||||
const slotBBefore = slotForBefore("b");
|
||||
const orderBefore = Array.from(shadow.querySelectorAll("li.row")).map((el) =>
|
||||
el.getAttribute("data-thread-id"),
|
||||
);
|
||||
expect(orderBefore).toEqual(["a", "b"]);
|
||||
expect(slotABefore.assignedElements()[0]).toBe(rowA);
|
||||
expect(slotBBefore.assignedElements()[0]).toBe(rowB);
|
||||
|
||||
// Live reorder: bump b to be the newest → order should become [b, a].
|
||||
element.threads = [
|
||||
thread("a", "2026-06-20T00:00:00.000Z"),
|
||||
thread("b", "2026-06-25T00:00:00.000Z"),
|
||||
];
|
||||
await flush(element);
|
||||
|
||||
const slotAAfter = shadow.querySelector(
|
||||
'slot[name="row:a"]',
|
||||
) as HTMLSlotElement;
|
||||
const slotBAfter = shadow.querySelector(
|
||||
'slot[name="row:b"]',
|
||||
) as HTMLSlotElement;
|
||||
const orderAfter = Array.from(shadow.querySelectorAll("li.row")).map((el) =>
|
||||
el.getAttribute("data-thread-id"),
|
||||
);
|
||||
|
||||
// The visual order changed...
|
||||
expect(orderAfter).toEqual(["b", "a"]);
|
||||
|
||||
// ...but the keyed slot nodes were REUSED (node identity preserved), not
|
||||
// recreated — keyed reconciliation moved them rather than tearing down.
|
||||
expect(slotAAfter).toBe(slotABefore);
|
||||
expect(slotBAfter).toBe(slotBBefore);
|
||||
|
||||
// ...and the projected wrapper light-DOM nodes are the exact same instances,
|
||||
// still assigned to their id-matched slots (no remount of slotted content).
|
||||
expect(slotAAfter.assignedElements()[0]).toBe(rowA);
|
||||
expect(slotBAfter.assignedElements()[0]).toBe(rowB);
|
||||
expect(document.getElementById("wrapper-row-a")).toBe(rowA);
|
||||
expect(document.getElementById("wrapper-row-b")).toBe(rowB);
|
||||
|
||||
element.remove();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
COPILOTKIT_THREADS_DRAWER_TAG,
|
||||
CopilotKitThreadsDrawer,
|
||||
} from "./copilotkit-threads-drawer";
|
||||
|
||||
/**
|
||||
* Registers the `<copilotkit-threads-drawer>` custom element. Idempotent — safe to call
|
||||
* multiple times (e.g. from several framework wrappers in the same page); a
|
||||
* second registration of the same tag is a no-op.
|
||||
*/
|
||||
export function defineCopilotKitThreadsDrawer(): void {
|
||||
if (
|
||||
typeof customElements !== "undefined" &&
|
||||
!customElements.get(COPILOTKIT_THREADS_DRAWER_TAG)
|
||||
) {
|
||||
customElements.define(
|
||||
COPILOTKIT_THREADS_DRAWER_TAG,
|
||||
CopilotKitThreadsDrawer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"copilotkit-threads-drawer": CopilotKitThreadsDrawer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* GENERATED FILE — DO NOT EDIT BY HAND.
|
||||
*
|
||||
* Bundled default values for the drawer's design tokens, derived at build time
|
||||
* from the canonical react-core theme (`packages/react-core/src/v2/styles/globals.css`)
|
||||
* by `scripts/generate-tokens.ts`. Run `pnpm run gen:tokens` to regenerate.
|
||||
*
|
||||
* The drawer's shadow-DOM CSS references these as fallbacks, e.g.
|
||||
* `var(--cpk-drawer-bg, <built default>)`, so consumers can override every
|
||||
* token while the built-in skin stays in sync with react-core.
|
||||
*/
|
||||
export const GENERATED_DRAWER_TOKEN_DEFAULTS = {
|
||||
bg: "oklch(1 0 0)",
|
||||
fg: "oklch(0.145 0 0)",
|
||||
surface: "oklch(1 0 0)",
|
||||
"surface-fg": "oklch(0.145 0 0)",
|
||||
muted: "oklch(0.97 0 0)",
|
||||
"muted-fg": "oklch(0.556 0 0)",
|
||||
accent: "oklch(0.97 0 0)",
|
||||
"accent-fg": "oklch(0.205 0 0)",
|
||||
primary: "oklch(0.205 0 0)",
|
||||
"primary-fg": "oklch(0.985 0 0)",
|
||||
danger: "oklch(0.577 0.245 27.325)",
|
||||
border: "oklch(0.922 0 0)",
|
||||
ring: "oklch(0.708 0 0)",
|
||||
radius: "0.625rem",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
export type GeneratedDrawerTokenKey =
|
||||
keyof typeof GENERATED_DRAWER_TOKEN_DEFAULTS;
|
||||
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
COPILOTKIT_THREADS_DRAWER_TAG,
|
||||
CopilotKitThreadsDrawer,
|
||||
} from "./copilotkit-threads-drawer";
|
||||
export { defineCopilotKitThreadsDrawer } from "./define";
|
||||
export { drawerStyles } from "./styles";
|
||||
export { GENERATED_DRAWER_TOKEN_DEFAULTS } from "./generated-tokens";
|
||||
export type { GeneratedDrawerTokenKey } from "./generated-tokens";
|
||||
export type {
|
||||
ArchiveDetail,
|
||||
CollapseChangeDetail,
|
||||
CopilotKitThreadsDrawerEventMap,
|
||||
DeleteDetail,
|
||||
DrawerFilter,
|
||||
DrawerThread,
|
||||
FilterChangeDetail,
|
||||
LoadMoreDetail,
|
||||
NewThreadDetail,
|
||||
OpenChangeDetail,
|
||||
RetryDetail,
|
||||
ThreadSelectedDetail,
|
||||
UnarchiveDetail,
|
||||
LicensedDetail,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,705 @@
|
||||
import { css, unsafeCSS } from "lit";
|
||||
import { GENERATED_DRAWER_TOKEN_DEFAULTS } from "./generated-tokens";
|
||||
|
||||
/**
|
||||
* Wraps a generated token default value so it can be safely interpolated into a
|
||||
* lit `css` template. Values come from the checked-in generated tokens file
|
||||
* (derived from react-core), never from user input, so `unsafeCSS` is safe
|
||||
* here.
|
||||
*/
|
||||
const tok = (value: string) => unsafeCSS(value);
|
||||
const T = GENERATED_DRAWER_TOKEN_DEFAULTS;
|
||||
|
||||
/**
|
||||
* Self-contained shadow-DOM styles for `<copilotkit-threads-drawer>`.
|
||||
*
|
||||
* Authoring rules baked in here:
|
||||
* - Every visual value is a `var(--cpk-drawer-<token>, <built default>)`. The
|
||||
* built defaults come from {@link GENERATED_DRAWER_TOKEN_DEFAULTS}, derived
|
||||
* from react-core's canonical theme at build time (anti-drift). Consumers
|
||||
* override by setting `--cpk-drawer-*` from the light DOM — CSS custom
|
||||
* properties pierce the shadow boundary by design.
|
||||
* - Inherited properties (font, color, line-height) are RE-PINNED on `:host`
|
||||
* so a hostile host stylesheet (`all: unset`, `* { ... !important }`,
|
||||
* Tailwind preflight) cannot leak into or strip styling from the element.
|
||||
* - Structural nodes expose `::part(...)` hooks for fine-grained theming.
|
||||
*
|
||||
* Token fallbacks are interpolated as literals (not runtime-read) so the CSS is
|
||||
* static and ships compiled.
|
||||
*/
|
||||
export const drawerStyles = css`
|
||||
:host {
|
||||
/* Re-pin inheritable properties to close inheritance leaks from hostile
|
||||
host CSS. These are the only properties that cross the shadow boundary
|
||||
via inheritance, so we hard-set them at the root. */
|
||||
all: initial;
|
||||
display: block;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: var(
|
||||
--cpk-drawer-font-family,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif
|
||||
);
|
||||
font-size: var(--cpk-drawer-font-size, 14px);
|
||||
line-height: var(--cpk-drawer-line-height, 1.4);
|
||||
color: var(--cpk-drawer-fg, var(--foreground, ${tok(T.fg)}));
|
||||
|
||||
/* Three-level token resolution, highest priority first:
|
||||
1. explicit per-token override (--cpk-drawer-*),
|
||||
2. the host app's theme variable (--background/--card/… — the standard
|
||||
react-core/shadcn names), so the drawer follows the host's light/dark
|
||||
theme by inheritance (custom properties are NOT reset by all:initial),
|
||||
3. the built-in light default derived from react-core at build time, so a
|
||||
host with no theme still renders correctly (self-contained). */
|
||||
--_bg: var(--cpk-drawer-bg, var(--background, ${tok(T.bg)}));
|
||||
--_surface: var(--cpk-drawer-surface, var(--card, ${tok(T.surface)}));
|
||||
--_surface-fg: var(
|
||||
--cpk-drawer-surface-fg,
|
||||
var(--card-foreground, ${tok(T["surface-fg"])})
|
||||
);
|
||||
--_muted: var(--cpk-drawer-muted, var(--muted, ${tok(T.muted)}));
|
||||
--_muted-fg: var(
|
||||
--cpk-drawer-muted-fg,
|
||||
var(--muted-foreground, ${tok(T["muted-fg"])})
|
||||
);
|
||||
--_accent: var(--cpk-drawer-accent, var(--accent, ${tok(T.accent)}));
|
||||
--_accent-fg: var(
|
||||
--cpk-drawer-accent-fg,
|
||||
var(--accent-foreground, ${tok(T["accent-fg"])})
|
||||
);
|
||||
--_primary: var(--cpk-drawer-primary, var(--primary, ${tok(T.primary)}));
|
||||
--_primary-fg: var(
|
||||
--cpk-drawer-primary-fg,
|
||||
var(--primary-foreground, ${tok(T["primary-fg"])})
|
||||
);
|
||||
--_danger: var(--cpk-drawer-danger, var(--destructive, ${tok(T.danger)}));
|
||||
--_border: var(--cpk-drawer-border, var(--border, ${tok(T.border)}));
|
||||
--_ring: var(--cpk-drawer-ring, var(--ring, ${tok(T.ring)}));
|
||||
--_indicator: var(--cpk-drawer-indicator, #5b94e4);
|
||||
/* Cap the corner radius at 4px (per designer) — themeable but never larger. */
|
||||
--_radius: min(
|
||||
var(--cpk-drawer-radius, var(--radius, ${tok(T.radius)})),
|
||||
4px
|
||||
);
|
||||
--_width: var(--cpk-drawer-width, 320px);
|
||||
}
|
||||
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: var(--_width);
|
||||
background: var(--_bg);
|
||||
border-right: 1px solid var(--_border);
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
/* The confirm-delete dialog no longer needs a positioning context here: it
|
||||
is a native <dialog> opened with showModal(), which renders in the
|
||||
browser top layer independent of any stacking context (ENT-1051). No
|
||||
other direct child of .root is absolutely positioned (the filter and
|
||||
row-action popovers anchor to their own positioned ancestors), so .root
|
||||
stays static. The mobile path establishes its own context via
|
||||
position:fixed. */
|
||||
}
|
||||
|
||||
/* Mobile: off-canvas overlay (modal pattern). */
|
||||
.root.mobile {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 1000;
|
||||
width: min(var(--_width), 85vw);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 0 0 100vmax transparent;
|
||||
}
|
||||
|
||||
.root.mobile.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Desktop collapsed: the panel is replaced by the floating cluster, so remove
|
||||
it from the layout entirely. The host reclaims the reserved column via the
|
||||
--cpk-drawer-reserved-width:0px override the element sets on collapse. */
|
||||
.root.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Floating launcher cluster (Figma "closed" mockup): a rounded surface card
|
||||
holding the sidebar toggle + a "New Conversation" (+) icon button. Rendered
|
||||
by the element itself in the mobile-closed AND desktop-collapsed states so
|
||||
there is always a way to reopen/expand — and to start a new conversation —
|
||||
with no host wiring. */
|
||||
.launcher-cluster {
|
||||
position: fixed;
|
||||
z-index: 998;
|
||||
/* Position is themeable so a host can line the cluster up with its own
|
||||
header controls (e.g. vertically centering it on a toggle group). */
|
||||
top: var(--cpk-drawer-launcher-top, 24px);
|
||||
left: var(--cpk-drawer-launcher-left, 24px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border-radius: var(--_radius);
|
||||
border: 1px solid var(--_border);
|
||||
background: var(--_surface);
|
||||
color: var(--_surface-fg);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
|
||||
.launcher {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
border-radius: calc(var(--_radius) - 3px);
|
||||
background: transparent;
|
||||
color: var(--_surface-fg);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.launcher:hover,
|
||||
.launcher:focus-visible {
|
||||
background: var(--_muted);
|
||||
}
|
||||
|
||||
.launcher .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* The header is hidden until a consumer projects slot="header" content
|
||||
(the redesign has no built-in header controls). Mirrors the memories/footer
|
||||
gating so an empty padded header bar never renders above the list. */
|
||||
.header[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Optional consumer projection surface. When a consumer projects into
|
||||
slot="header" it fills the header row. */
|
||||
.header slot[name="header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
border-radius: var(--_radius);
|
||||
background: transparent;
|
||||
color: var(--_muted-fg);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* "Filter applied" indicator dot at the funnel's bottom-right (Figma archived
|
||||
view). Shown only when a non-default filter is active. */
|
||||
.filter-dot {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--_indicator);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon-btn:hover,
|
||||
.icon-btn:focus-visible {
|
||||
background: var(--_muted);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon-btn[aria-pressed="true"] {
|
||||
background: var(--_muted);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.new-conversation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
/* No top margin by default: the header bar (the collapse toggle on desktop,
|
||||
the close button on mobile-open) sits above this row and supplies the top
|
||||
spacing — the header-hidden case re-adds it below. Horizontal geometry
|
||||
matches the list rows: the hover background insets 12px (like a row's
|
||||
highlight) and, with 10px inner padding, the icon's left edge lands at
|
||||
22px — the same x as the thread names below (list padding 12 + row
|
||||
padding 10). */
|
||||
margin: 0 12px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--_radius);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Only when the header bar is fully hidden (collapsible=false AND no projected
|
||||
header content) is this row the first element — give it the top spacing the
|
||||
header would otherwise supply. */
|
||||
.header[hidden] + .new-conversation {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.new-conversation:hover,
|
||||
.new-conversation:focus-visible {
|
||||
background: var(--_muted);
|
||||
}
|
||||
|
||||
.new-conversation .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* Match the list rows' content edge: "Recent Conversations" lands at 22px
|
||||
(margin 12 + padding 10), aligned with the thread names and the New
|
||||
Conversation icon; the funnel sits 22px from the right, over the kebabs. */
|
||||
padding: 8px 10px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--_muted-fg);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.icon-btn.small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.icon-btn.small .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.filter-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
background: var(--_surface);
|
||||
color: var(--_surface-fg);
|
||||
border: 1px solid var(--_border);
|
||||
border-radius: var(--_radius);
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.filter-opt {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filter-opt:hover,
|
||||
.filter-opt:focus-visible {
|
||||
background: var(--_muted);
|
||||
}
|
||||
|
||||
.filter-opt[aria-pressed="true"] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--_radius);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid transparent;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
animation: cpk-drawer-row-in 0.18s ease forwards;
|
||||
/* Positioned so the hovered row can be lifted above later rows (below). */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Lift the interacted row above later rows so its kebab popover (which paints
|
||||
inside the row's own transform stacking context) is not clipped by / drawn
|
||||
under the rows below it. */
|
||||
.row:hover,
|
||||
.row:focus-within,
|
||||
.row.menu-open {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* While a kebab menu is open, the popover only covers part of the rows it
|
||||
overlaps, so the rest of the list would still respond to hover — revealing
|
||||
other rows' kebabs and (under a host \`::part(row):hover\` theme) painting a
|
||||
hover background "through"/around the open menu. Freeze pointer events on
|
||||
every OTHER row so the menu reads as a single focused surface. The owner
|
||||
row (\`.menu-open\`) and its popover stay interactive, and a pointerdown that
|
||||
lands on a frozen row still reaches the document handler that closes the
|
||||
menu, so click-away dismissal is preserved. */
|
||||
.list.menu-open .row:not(.menu-open) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes cpk-drawer-row-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.row.active {
|
||||
background: var(--_accent);
|
||||
color: var(--_accent-fg);
|
||||
/* No border on the selected row — the background change alone marks it
|
||||
(per designer). The base row keeps its 1px transparent border so the
|
||||
layout stays stable. */
|
||||
}
|
||||
|
||||
.row.archived .row-name {
|
||||
color: var(--_muted-fg);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-name-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-name.placeholder {
|
||||
color: var(--_muted-fg);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.row-name.revealed {
|
||||
animation: cpk-drawer-name-reveal 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes cpk-drawer-name-reveal {
|
||||
from {
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.row-action {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--_muted-fg);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.row-action:hover,
|
||||
.row-action:focus-visible {
|
||||
background: var(--_muted);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.row-action-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Per-row kebab trigger: hidden at rest, revealed when the row is hovered,
|
||||
focused (keyboard), active (selected), or its menu is open. */
|
||||
.row-menu {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 4px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--_muted-fg);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .row-menu,
|
||||
.row:focus-within .row-menu,
|
||||
.row.active .row-menu,
|
||||
.row-menu[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-menu:hover,
|
||||
.row-menu:focus-visible {
|
||||
background: var(--_muted);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.row-menu .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.row-menu-popover {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: calc(100% - 4px);
|
||||
z-index: 16;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 140px;
|
||||
padding: 4px;
|
||||
background: var(--_surface);
|
||||
color: var(--_surface-fg);
|
||||
border: 1px solid var(--_border);
|
||||
border-radius: var(--_radius);
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
/* Rows in the lower portion of the list open their kebab menu UPWARD so it is
|
||||
not clipped by the list's overflow scroll box (.list is overflow-y:auto,
|
||||
which also clips the x-axis). Anchoring to the button's top edge via
|
||||
\`bottom\` keeps the popover inside the list's visible area for bottom rows. */
|
||||
.row.menu-up .row-menu-popover {
|
||||
top: auto;
|
||||
bottom: calc(100% - 4px);
|
||||
}
|
||||
|
||||
.row-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.row-menu-item:hover,
|
||||
.row-menu-item:focus-visible {
|
||||
background: var(--_muted);
|
||||
}
|
||||
|
||||
.row-menu-item.danger:hover,
|
||||
.row-menu-item.danger:focus-visible {
|
||||
color: var(--_danger);
|
||||
}
|
||||
|
||||
.row-menu-item .row-action-icon,
|
||||
.row-menu-item .icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--_primary);
|
||||
color: var(--_primary-fg);
|
||||
border: 0;
|
||||
border-radius: var(--_radius);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.state {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--_muted-fg);
|
||||
}
|
||||
|
||||
.state.error {
|
||||
color: var(--_danger);
|
||||
}
|
||||
|
||||
.fetch-more-error {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
color: var(--_danger);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--_muted-fg);
|
||||
font: inherit;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-more:hover,
|
||||
.load-more:focus-visible {
|
||||
color: var(--_surface-fg);
|
||||
background: var(--_muted);
|
||||
}
|
||||
|
||||
.licensed {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.memories {
|
||||
border-top: 1px solid var(--_border);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.memories[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Confirm-delete dialog — a native <dialog> opened with showModal(). It lives
|
||||
in the browser TOP LAYER, so it can never be painted under other UI (the
|
||||
old CSS-positioned overlay was trapped in the drawer host's stacking
|
||||
context and appeared under the chat's welcome view — ENT-1051). The UA
|
||||
centers it in the viewport via margin:auto; we reset the UA chrome and
|
||||
apply the drawer's surface-card look. */
|
||||
.dialog {
|
||||
/* Top-layer (showModal) so it is never clipped, but centered over the DRAWER
|
||||
PANEL rather than the viewport: JS sets --confirm-cx/cy from the visible
|
||||
.root rect (falls back to viewport-center when unmeasured, e.g. jsdom).
|
||||
Width is capped to the drawer band so it reads as "inside the drawer". */
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
left: var(--confirm-cx, 50%);
|
||||
top: var(--confirm-cy, 50%);
|
||||
transform: translate(-50%, -50%);
|
||||
border: 0;
|
||||
padding: 16px;
|
||||
width: max-content;
|
||||
max-width: min(80vw, calc(var(--_width) - 24px));
|
||||
background: var(--_surface);
|
||||
color: var(--_surface-fg);
|
||||
border-radius: var(--_radius);
|
||||
}
|
||||
|
||||
/* Only lay out the card contents when open. A closed <dialog> is display:none
|
||||
via the UA stylesheet, and author display rules must not override that (or
|
||||
an empty box would leak), so the flex layout is scoped to [open]. */
|
||||
.dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid var(--_border);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Pure CSS token-extraction helpers shared by the build-time token-sync script
|
||||
* (`scripts/generate-tokens.ts`) and the drift-guard test
|
||||
* (`__tests__/generated-tokens.test.ts`).
|
||||
*
|
||||
* These live in `src/` rather than in `scripts/` for two reasons: (1) the test
|
||||
* cannot import from `scripts/` without pulling a file outside the package's
|
||||
* tsc `rootDir` into the typecheck program, and (2) keeping a single canonical
|
||||
* parser means the build output and the drift guard can never diverge on how
|
||||
* the `[data-copilotkit]` block is located and parsed. The script imports these
|
||||
* from here; nothing in the shipped element does, so the bundler tree-shakes
|
||||
* them out of the published artifact.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts the light-mode `[data-copilotkit] { ... }` declaration block from the
|
||||
* canonical globals.css using a brace-balanced scan.
|
||||
*
|
||||
* Taking the first `}` after the opening brace would silently truncate the block
|
||||
* if it ever contains a nested brace (a `@media`/nested rule, or a brace inside a
|
||||
* value), dropping later tokens. Counting depth finds the matching close brace —
|
||||
* the true end of the block — instead.
|
||||
*
|
||||
* @param css - Full globals.css source text.
|
||||
* @param sourceLabel - Human-readable source name surfaced in error messages.
|
||||
* @returns The block body between (but excluding) the outer braces.
|
||||
* @throws If the block cannot be located or its braces are unbalanced.
|
||||
*/
|
||||
export function extractLightThemeBlock(
|
||||
css: string,
|
||||
sourceLabel = "the canonical theme",
|
||||
): string {
|
||||
const start = css.indexOf("[data-copilotkit] {");
|
||||
if (start === -1) {
|
||||
throw new Error(
|
||||
`[generate-tokens] Could not find "[data-copilotkit] {" in ${sourceLabel}`,
|
||||
);
|
||||
}
|
||||
const open = css.indexOf("{", start);
|
||||
if (open === -1) {
|
||||
throw new Error(
|
||||
`[generate-tokens] Malformed "[data-copilotkit]" block in ${sourceLabel}`,
|
||||
);
|
||||
}
|
||||
let depth = 0;
|
||||
let close = -1;
|
||||
for (let i = open; i < css.length; i++) {
|
||||
const char = css[i];
|
||||
if (char === "{") {
|
||||
depth++;
|
||||
} else if (char === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
close = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (close === -1) {
|
||||
throw new Error(
|
||||
`[generate-tokens] Unbalanced braces in "[data-copilotkit]" block in ${sourceLabel}`,
|
||||
);
|
||||
}
|
||||
return css.slice(open + 1, close);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `--name: value;` custom-property declarations from a CSS block into a
|
||||
* name→value map (names returned without the leading `--`).
|
||||
*
|
||||
* @param block - A CSS declaration block body.
|
||||
* @returns Map of custom-property name to trimmed value.
|
||||
*/
|
||||
export function parseCustomProperties(block: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const re = /--([\w-]+)\s*:\s*([^;]+);/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(block)) !== null) {
|
||||
out.set(match[1]!.trim(), match[2]!.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Public type surface for the `<copilotkit-threads-drawer>` custom element.
|
||||
*
|
||||
* The element is a pure VIEW: domain data flows IN as properties and user
|
||||
* intent flows OUT as DOM `CustomEvent`s. These types intentionally do NOT
|
||||
* import from `@copilotkit/core` or any framework — the element is
|
||||
* framework-agnostic and self-contained. The thread shape mirrors the fields
|
||||
* the drawer needs to render and is structurally compatible with core's thread
|
||||
* record (`id`, `name`, `archived`, timestamps).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal thread record the drawer renders. A `null` name signals an
|
||||
* as-yet-unnamed thread and is rendered as a "New thread" placeholder until the
|
||||
* async name arrives.
|
||||
*/
|
||||
export interface DrawerThread {
|
||||
/** Stable thread identifier; drives row keying and slot reconciliation. */
|
||||
readonly id: string;
|
||||
/** Display name, or `null` when not yet named. */
|
||||
readonly name: string | null;
|
||||
/** Whether the thread is archived (affects styling + available actions). */
|
||||
readonly archived: boolean;
|
||||
/** ISO timestamp the thread was created. */
|
||||
readonly createdAt: string;
|
||||
/** ISO timestamp the thread was last updated. */
|
||||
readonly updatedAt: string;
|
||||
/** ISO timestamp of the most recent run, if any. */
|
||||
readonly lastRunAt?: string;
|
||||
}
|
||||
|
||||
/** The Active/All filter the element owns authoritatively. */
|
||||
export type DrawerFilter = "active" | "all";
|
||||
|
||||
/** `detail` for the `thread-selected` event. */
|
||||
export interface ThreadSelectedDetail {
|
||||
readonly threadId: string;
|
||||
}
|
||||
|
||||
/** `detail` for the `archive` event. */
|
||||
export interface ArchiveDetail {
|
||||
readonly threadId: string;
|
||||
}
|
||||
|
||||
/** `detail` for the `unarchive` event. */
|
||||
export interface UnarchiveDetail {
|
||||
readonly threadId: string;
|
||||
}
|
||||
|
||||
/** `detail` for the `delete` event (fired only after in-element confirm). */
|
||||
export interface DeleteDetail {
|
||||
readonly threadId: string;
|
||||
}
|
||||
|
||||
/** `detail` for the `filter-change` event. */
|
||||
export interface FilterChangeDetail {
|
||||
readonly filter: DrawerFilter;
|
||||
}
|
||||
|
||||
/** `detail` for the `open-change` event. */
|
||||
export interface OpenChangeDetail {
|
||||
readonly open: boolean;
|
||||
}
|
||||
|
||||
/** `detail` for the `collapse-change` event (user-driven collapse toggle). */
|
||||
export interface CollapseChangeDetail {
|
||||
readonly collapsed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `detail` for the `retry` event, emitted from the actionable error state so a
|
||||
* wrapper can back it with a core refetch. `scope` distinguishes an initial
|
||||
* fetch retry from a fetch-more retry.
|
||||
*/
|
||||
export interface RetryDetail {
|
||||
readonly scope: "initial" | "fetch-more";
|
||||
}
|
||||
|
||||
/** `detail` for the `new-thread` event. */
|
||||
export type NewThreadDetail = Record<string, never>;
|
||||
|
||||
/**
|
||||
* `detail` for the `licensed` event (Upgrade CTA click). Carries the
|
||||
* `licenseUrl` the element will open in a new tab. The event is cancelable:
|
||||
* a host that calls `preventDefault()` suppresses the default navigation and
|
||||
* takes over (e.g. to route in-app or fire its own telemetry).
|
||||
*/
|
||||
export interface LicensedDetail {
|
||||
readonly licenseUrl: string | null;
|
||||
}
|
||||
|
||||
/** `detail` for the `load-more` event (advance pagination; no payload). */
|
||||
export type LoadMoreDetail = Record<string, never>;
|
||||
|
||||
/**
|
||||
* Strongly-typed event map for `<copilotkit-threads-drawer>`. Consumers can use this to
|
||||
* type `addEventListener` callbacks. All events bubble and are composed so they
|
||||
* cross the shadow boundary.
|
||||
*/
|
||||
export interface CopilotKitThreadsDrawerEventMap {
|
||||
"thread-selected": CustomEvent<ThreadSelectedDetail>;
|
||||
archive: CustomEvent<ArchiveDetail>;
|
||||
unarchive: CustomEvent<UnarchiveDetail>;
|
||||
delete: CustomEvent<DeleteDetail>;
|
||||
"new-thread": CustomEvent<NewThreadDetail>;
|
||||
"filter-change": CustomEvent<FilterChangeDetail>;
|
||||
"open-change": CustomEvent<OpenChangeDetail>;
|
||||
"collapse-change": CustomEvent<CollapseChangeDetail>;
|
||||
retry: CustomEvent<RetryDetail>;
|
||||
licensed: CustomEvent<LicensedDetail>;
|
||||
"load-more": CustomEvent<LoadMoreDetail>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"incremental": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSources": false,
|
||||
"preserveWatchOutput": true,
|
||||
"sourceMap": true,
|
||||
"target": "es2022",
|
||||
"module": "esnext",
|
||||
"lib": ["es2023", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": false,
|
||||
"experimentalDecorators": false,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
/**
|
||||
* Build configuration for `@copilotkit/web-components`.
|
||||
*
|
||||
* Mirrors the multi-format layout used by `packages/a2ui-renderer`:
|
||||
* an unbundled ESM/CJS build (with `.d.ts`) for the root and the
|
||||
* `./drawer` subpath, plus a single-file UMD bundle for `<script>`
|
||||
* consumers.
|
||||
*
|
||||
* `lit` handling differs by format on purpose:
|
||||
* - ESM/CJS externalize `lit` (it is a peer dependency) so an npm host that
|
||||
* already depends on Lit dedupes a single copy of the runtime.
|
||||
* - The UMD bundle INLINES `lit` so the advertised `<script>`/CDN path is
|
||||
* self-contained. Externalizing it there mapped Lit submodules to UMD
|
||||
* globals (`LitDirectivesRepeat`, …) that Lit's CDN build never exposes,
|
||||
* so the very first `repeat()`/`classMap()` call threw at runtime.
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ["src/index.ts", "src/threads-drawer/index.ts"],
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
target: "es2022",
|
||||
outDir: "dist",
|
||||
unbundle: true,
|
||||
external: (id) => {
|
||||
const externalPkgs = ["lit"];
|
||||
return externalPkgs.some((pkg) => id === pkg || id.startsWith(pkg + "/"));
|
||||
},
|
||||
exports: false,
|
||||
},
|
||||
{
|
||||
entry: ["src/index.ts"],
|
||||
format: ["umd"],
|
||||
globalName: "CopilotKitWebComponents",
|
||||
sourcemap: true,
|
||||
target: "es2018",
|
||||
outDir: "dist",
|
||||
// Force `lit` (a peer dependency, which tsdown would otherwise externalize)
|
||||
// to be INLINED into the UMD bundle so the single `<script>`/CDN artifact is
|
||||
// self-contained. Externalizing it emitted a bare `require('lit')` that has
|
||||
// no meaning in a browser global script. Inlining `lit` necessarily pulls in
|
||||
// its runtime deps (`@lit/reactive-element`, `lit-html`, `lit-element`,
|
||||
// `@lit-labs/ssr-dom-shim`); that transitive bundling is intentional here, so
|
||||
// disable tsdown's "unintended bundling" guard (which is fatal under CI).
|
||||
noExternal: [/^lit(\/|$)/, /^@lit\//, "lit-html", "lit-element"],
|
||||
inlineOnly: false,
|
||||
codeSplitting: false,
|
||||
outputOptions(options) {
|
||||
options.entryFileNames = "[name].umd.js";
|
||||
return options;
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,24 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["**/__tests__/**/*.{test,spec}.ts"],
|
||||
passWithNoTests: true,
|
||||
globals: true,
|
||||
server: {
|
||||
deps: {
|
||||
inline: ["lit", "@lit/reactive-element", "lit-element", "lit-html"],
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.join(dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user