Merge ben1/intelligence-threads-examples-rollout into codex/ent-734-crewai-crews

Graft resolution (her branch predates the crewai-crews rebuild on main):
- page.tsx: jpr5's rebuilt full demo (setThemeColor/updateProverb/
  get_weather tools, seed effect, delete buttons, WeatherCard) is the
  base; her threads shell (drawer + gate + provider) grafted around it;
  agent key AGENT_ID=default (was starterAgent on her branch)
- layout/route/package.json: rollout side (default key, AGENT_URL
  normalization, 1.59.3) + her drawer deps + intelligence route block
- contract test: crewai-crews added to the parameterized array (66/66)
- lockfile regenerated; .gitignore !.env.example negation added

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Benjamin Taylor
2026-06-04 10:26:23 -05:00
283 changed files with 184003 additions and 17040 deletions
@@ -1,192 +0,0 @@
---
name: showcase-demo-debugging
description: Build, debug, and regression-test CopilotKit showcase demos. Use when working under showcase/, creating new showcase demos/items/cells/pills, implementing showcase features, investigating showcase demo bugs, D5/e2e-deep failures, aimock fixture gaps, direct-LLM versus aimock behavior, recording fixtures, testing every demo pill/cell, verifying repeated and interleaved suggestion-pill fixture stability, enforcing langgraph-python 1:1 parity, or migrating a demo to the v2 CopilotKit showcase flow.
---
# Showcase Demo Work
## Start Here
Before changing code or fixtures, read the current local guides:
- `showcase/README.md`
- `showcase/RUNBOOK.md`
- `showcase/DEBUGGING.md`
- `showcase/INTEGRATION-CHECKLIST.md` when adding or changing a demo
Use `showcase/bin/showcase` for showcase operations. Do not use raw `docker compose`, raw `docker build`, or direct harness commands unless the CLI lacks the needed operation and you explain why.
Prefer isolated runs when another session may be using showcase:
```sh
showcase/bin/showcase test <slug> --d5 --verbose --cycle --isolate
```
## Baseline Rules
- Keep showcase demo code on CopilotKit v2 APIs.
- Treat `showcase/integrations/langgraph-python` as the gold standard for D5 behavior. Every other showcase demo must be a 1:1 match to `langgraph-python` for the same demo behavior, prompts, pills, tool flow, UI assertions, and D5 coverage unless the user explicitly approves a documented divergence.
- For new or migrated demos, compare dependency versions against `showcase/integrations/langgraph-python` before implementation and keep the same versions or newer unless the user explicitly approves a documented divergence.
- Use Google ADK as extra context only when it is relevant.
- For any D5 cell or migration, make every pill work against a real agent first, then implement fixtures, then run the local D5 validation until green.
- E2E tests for migrated demos should match the `langgraph-python` behavior and assertions unless the user explicitly approves a divergence.
- Every pill in a demo must be exercised and covered by aimock-backed D5 replay before the demo is complete.
- When implementing a new feature, click every suggestion pill in the demo and verify it works before declaring the feature complete.
- With aimock replay, fixture behavior must be stable when a pill is clicked once, clicked five times in a row, clicked after another pill, and clicked again after intervening pills. The same pill should produce the same expected output for the same fixture-backed prompt regardless of prior pill order.
- Verify locally before saying a fix or demo is done.
- Do not weaken a demo to satisfy a test. The demo behavior is the product.
## New Showcase Items
Use this workflow when creating a new showcase item, adding a new demo cell, adding suggestion pills, or implementing a new showcase feature. The same rules apply as debugging:
1. Read the current showcase guides listed above.
2. Find the matching `langgraph-python` demo or define the new `langgraph-python` behavior first.
3. Compare dependencies with `showcase/integrations/langgraph-python`; use the same versions or newer for shared demo/runtime/test dependencies unless a documented incompatibility requires otherwise.
4. Implement the demo behavior with CopilotKit v2 APIs and make every suggestion pill work against a real agent.
5. If any local step talks to a real LLM, run it through aimock record mode so fixtures are captured.
6. Convert recordings into deterministic source D5 fixtures.
7. Verify every pill once, repeated, and interleaved under aimock replay.
8. Add or update D5 E2E coverage for every pill.
9. Run local validation before marking the new showcase item complete.
Do not treat new showcase work as done after the UI appears to work once. New demos need the same fixture consistency, replay stability, and regression coverage as bug fixes.
## Bug Investigation Loop
1. Read the relevant demo, D5 script, and existing fixtures before reproducing.
2. Determine whether the flow is using aimock or a real LLM:
- Inspect `showcase/docker-compose.local.yml`, provider base URLs, and `showcase/harness/fixtures/d5/*.json`.
- Check whether the matching fixture exists in `showcase/aimock/d5-all.json` or source fixtures under `showcase/harness/fixtures/d5/`.
- Use `showcase/bin/showcase logs aimock --grep "fixture|match|NO match|404"` after a run.
3. If the flow is supposed to use aimock, reproduce with:
```sh
showcase/bin/showcase up aimock <slug>
showcase/bin/showcase test <slug> --d5 --verbose --cycle
```
4. If fixtures look suspect, run aimock locally and inspect fixture matching before editing app code.
5. If the flow bypasses aimock and talks directly to a real LLM, always run aimock in record mode locally for that step. Do not run an unrecorded real-LLM local flow. Use the recorded fixture output to make later replay deterministic and to reach aimock-backed D5 coverage.
6. Once root cause is pinpointed and a fix is implemented, ask the user to click through the exact flow and confirm it works before locking the regression coverage.
7. After the user confirms, add or update D5 fixtures and regression E2E coverage so the issue cannot silently return.
8. For every additional issue the user reports while clicking around, repeat this same loop: reproduce, root cause, user verification, fixture, regression test.
## Environment Setup
If a real LLM provider is needed, inspect `showcase/.env` without printing secret values. If the required key is missing or empty:
1. Ensure `showcase/.env` exists, copying from `showcase/.env.example` if needed.
2. Open it for the user:
```sh
code --new-window "F:\projects\cpk\CopilotKit\showcase\.env"
```
3. Ask the user to paste the required key, usually `OPENAI_API_KEY`; use `ANTHROPIC_API_KEY` or `GOOGLE_API_KEY` only when the target provider needs it.
4. Ask the user to tell you when the file is saved. Do not ask them to paste secrets into chat.
If env/config changes affect containers, use:
```sh
showcase/bin/showcase recreate <slug>
```
Use rebuild only for dependency, Dockerfile, or non-mounted source changes.
## Reproducing Interactively
If the user says they will reproduce manually, start the needed local services and ask them to click through the flow, then tell you when finished so you can inspect logs and recorded fixtures.
If the user asks you to reproduce from instructions, run the showcase demo in headed Playwright or the Browser MCP, interact until the bug is reproduced, and preserve the exact steps, prompt text, selected pill, URL, and observed failure.
Use headed D5 when useful:
```sh
showcase/bin/showcase test <slug> --d5 --headed --verbose --cycle
```
## Recording Fixtures
For a new demo or any local step that talks to a real LLM, run aimock in record mode so the interaction becomes fixture-backed. This is mandatory: real-provider traffic should produce fixtures that can be replayed later.
```sh
docker compose -f showcase/docker-compose.local.yml -f showcase/docker-compose.record.yml --profile infra up -d aimock <slug>
```
This record-mode compose override is the accepted exception to the normal "use `showcase/bin/showcase`" rule until the showcase CLI has a first-class record command.
Then drive every pill in the demo manually or with the D5 harness. Recorded calls land under `showcase/aimock/d5-recorded/`.
After recording:
1. Convert or move recorded output into source D5 fixtures under `showcase/harness/fixtures/d5/`.
2. Ensure `showcase/aimock/d5-all.json` contains the replay fixture path required by current docs/scripts.
3. Run fixture validation.
4. Re-run the demo with aimock replay only.
5. Add or update D5 E2E coverage for every pill.
When using the existing D5 recorder helper, read `showcase/scripts/record-d5-fixtures.mjs` first. It documents the current recorder patch requirement for multi-turn fixtures. If aimock cannot record the needed response shape, do not fake the behavior in the demo; discuss the smallest upstream aimock change with the user.
## Pill Replay Stability
When adding a feature, changing fixtures, or debugging any fixture-backed demo, verify suggestion-pill stability under aimock replay:
1. Click each suggestion pill once in a fresh session and confirm the expected UI and assistant output.
2. Click each suggestion pill repeatedly, including at least five consecutive clicks for pills that exercise LLM/tool behavior.
3. Click multiple different pills one after another, then return to a previously clicked pill and confirm it still produces the same expected output.
4. Inspect aimock logs for fixture misses, incorrect matches, repeated stale matches, or order-dependent matches.
5. Fix fixture matching if output depends on click order, prior pills, or how many times the same pill was clicked.
Fixtures must be consistent and replayable. Do not rely on `turnIndex` or conversation history when a stable `userMessage`, `toolCallId`, `hasToolResult`, or more specific fixture key can make the replay deterministic across repeated and interleaved pill clicks.
## Fixture Rules
- Source fixtures live under `showcase/harness/fixtures/d5/*.json`.
- Bundled runtime fixtures live in `showcase/aimock/d5-all.json`.
- Use `toolCallId` for multi-turn disambiguation when a stable tool id exists; use `hasToolResult` only when the exact tool id is unavailable or intentionally runtime-variable.
- Do not introduce `turnIndex` in new fixtures unless no stable request property can distinguish the turn and the divergence is documented.
- Prefer stable, specific `userMessage`, `systemMessage`, `toolCallId`, and tool-surface matching. Avoid broad catch-alls that can steal another pill's fixture.
- After fixture edits, validate and recreate aimock:
```sh
showcase/bin/showcase fixtures validate
showcase/bin/showcase recreate aimock
showcase/bin/showcase test <slug> --d5 --verbose --cycle
```
- If adding a new fixture file, update the compose mount and aimock Dockerfile copy rules when the current docs require it.
## Regression Tests
Add regression coverage after the user confirms the fixed flow works:
- Add or update the D5 fixture for the exact prompt/pill/tool flow.
- Add an E2E regression that fails on the original bug and passes with the fix.
- Cover every pill in the demo, including pills that only exist to expose edge cases.
- Include repeated and interleaved pill-click coverage when fixtures are involved, so a fixture cannot pass only on a single first click.
- Match the langgraph-python D5 assertion style and behavior 1:1 for equivalent demos.
- Include every separate user-reported demo issue as its own covered behavior unless a single test naturally covers the shared root cause.
- Run the narrow relevant D5 test first, then broader showcase validation when the change touches shared fixtures, harness logic, runtime behavior, or multiple demos.
## Aimock Upstream Gaps
If aimock lacks a feature needed to accurately replay the LLM provider response:
1. Confirm the gap with a recorded provider response or logs.
2. Avoid adding demo workarounds that hide the mismatch.
3. Ask the user what the easiest way is for you to file the upstream PR.
4. If approved, make the smallest aimock change, rebuild with:
```sh
showcase/bin/showcase aimock-rebuild --from <aimock-source-path>
```
5. Re-run the showcase D5 flow with the rebuilt aimock.
## Done Criteria
Before final status:
- The root cause is stated concretely.
- The affected local showcase demos are running and their local URLs are provided in the overview so the user can manually test the fixed flows.
- The user has confirmed the fixed UI flow when manual verification was requested.
- Fixtures are deterministic and validated.
- Any real-LLM local run was routed through aimock record mode and produced replayable fixtures.
- Every pill in the affected demo has aimock-backed D5 coverage.
- Aimock fixture replay is stable for single, repeated, and interleaved suggestion-pill clicks.
- Non-`langgraph-python` demo behavior matches `langgraph-python` 1:1 or has an explicit documented user-approved divergence.
- The relevant D5/e2e regression is present and passing locally.
- Any aimock limitation is either fixed upstream, linked to an upstream PR/plan, or explicitly called out as a blocker.
@@ -1,4 +0,0 @@
interface:
display_name: "Showcase Demo Work"
short_description: "Build and debug showcase demos with D5."
default_prompt: "Build or investigate this CopilotKit showcase demo using langgraph-python parity, aimock record/replay fixtures, every-pill verification, and D5 regression coverage."
@@ -1,149 +0,0 @@
---
name: copilotkit-demo-parity
description: Keeps examples/integrations/* demos aligned to the north-star (langgraph-python). Use when the user says "sync demos", "sync integrations", "port to north-star", "align integration demos", "parity check", or when working inside examples/integrations/ and tracked files diverge. Drives the pnpm parity:sync / parity:verify commands and handles the manual-merge zones (agent code, api route, Dockerfile).
metadata:
internal: true
---
# CopilotKit demo parity
Keeps the three (soon more) integration demos under `examples/integrations/`
aligned. `langgraph-python` is north-star; every other entry is an instance
that tracks it via `examples/integrations/_parity/manifest.json`.
## When this skill fires
- User says "sync demos", "align integrations", "port to north-star",
"parity check", "run parity sync".
- User edits a file under `examples/integrations/langgraph-python/**` that is
listed in `manifest.json` → tracked.verbatimFiles.
- User is about to add a new integration under `examples/integrations/`.
- `pnpm parity:verify` is failing locally or in CI.
## Ground truth
Read these first before making any parity decision — they override
anything memorized:
- `examples/integrations/_parity/manifest.json` — what is tracked, what is
allowed to diverge, per-instance overrides.
- `examples/integrations/_parity/canonical/PROMPT.md` — the canonical
system prompt. Every agent inlines this as a string literal in source.
The verifier greps the canonical first line against each agent's source.
- `examples/integrations/_parity/README.md` — human-facing how-to.
## Procedure: sync from north-star to instance(s)
Use when north-star changed and instances need to catch up, OR when a new
instance is being bootstrapped.
1. **Confirm the north-star change is intentional.** If the user changed
a file under `langgraph-python/`, ask whether it should propagate.
Changes to `agent/` are never auto-propagated.
2. **Dry-run first.**
```bash
pnpm parity:sync --target=<instance> --dry-run
```
Read the report: file count, rewritten package.json keys, prompt update.
3. **Apply.**
```bash
pnpm parity:sync --target=<instance>
# or --all to sync every instance
```
4. **Resolve manual-merge zones.** Sync does NOT touch:
- `agent/**` — port agent code by hand. See `tracked.agentSurface.toolNames`
and `stateKeys` in `manifest.json` for what must be present.
- `src/app/api/copilotkit/**` — api route differs across instances
(north-star=LangGraphAgent, Docker instances=HttpAgent).
- `Dockerfile`, `docker/Dockerfile.agent`, `serve.py`, `scripts/**` —
language-specific. Each instance keeps its own.
5. **Verify.**
```bash
pnpm parity:verify --target=<instance>
```
Exit 0 = green. Exit 1 = unresolved drift; fix until green.
## Procedure: verify (CI-equivalent)
```bash
pnpm parity:verify # all instances
pnpm parity:verify --target=<instance>
pnpm parity:verify --json # machine-readable
```
Output kinds:
- `verbatim-file` — byte-compare against north-star.
- `package-json` — tracked key mismatch vs expected value.
- `prompt` — canonical prompt first line not found in instance agent
source. Instance agent needs the prompt inlined as a string literal.
- `agent-tool` — tool name from manifest not found in instance agent
source. Usually means a tool was renamed or dropped; update manifest
OR port the tool.
- `agent-state` — state key from manifest not found (warn only).
## Procedure: add a new instance
1. Create the demo dir under `examples/integrations/<name>/` with the
usual Next.js frontend root and an `agent/` subdir.
2. Add an entry to `manifest.json` → `instances`:
```json
"<name>": {
"role": "instance",
"agent": { "language": "...", "runtime": "..." },
"allowedDivergence": [
"agent/**",
"src/app/api/copilotkit/**",
"Dockerfile",
"docker/Dockerfile.agent",
"serve.py",
"scripts/**"
],
"packageJsonOverrides": {
"scripts.dev:agent": "<invocation>",
"scripts.install:agent": "<install>"
}
}
```
3. `pnpm parity:sync --target=<name>`
4. Port agent code (tool implementations, state schema, prompt loading).
Agent must read prompt from `agent/PROMPT.md` at startup.
5. `pnpm parity:verify --target=<name>` until green.
## Procedure: change the canonical prompt
1. Edit `examples/integrations/_parity/canonical/PROMPT.md`.
2. `pnpm parity:sync --all` — writes the new prompt to every instance's
`agent/PROMPT.md`.
3. If the prompt change requires new tools or state keys, update
`manifest.json` → `tracked.agentSurface` to match, then port each
instance's agent.
## Procedure: change the tracked surface
To track a new file or key:
1. Edit `manifest.json` → `tracked.verbatimFiles` or `tracked.packageJsonPaths`.
2. Run `pnpm parity:verify` — instances will flag as drifted until synced.
3. `pnpm parity:sync --all` to apply.
To stop tracking something, remove it from `manifest.json`. The verifier
and sync stop touching it. Any drift that already exists stays.
## Red flags
| Signal | What it means | Do instead |
| ----------------------------------------------- | --------------------- | ----------------------------------------------- |
| "Just copy the file manually" | Bypasses the manifest | Add to `tracked.verbatimFiles` then sync |
| "Add a try/catch so verify doesn't fail" | Silencing drift | Resolve the drift or declare divergence |
| "Move this into `allowedDivergence` to unblock" | Scope creep | Only add to divergence with explicit reason |
| `pnpm parity:sync` on north-star directly | Overwrites canonical | Sync is instance-only; script refuses |
| Agent code copied between languages | Doesn't work | Port by hand; `tracked.agentSurface` names what |
## Related skills
- `copilotkit-integrations` — framework-specific wiring (LangGraph, CrewAI,
Mastra, etc.) when bootstrapping a new instance.
- `copilotkit-dev-workflow` — monorepo conventions, Nx, commit format.
- `docker-ci-safety` — Dockerfile safety when editing per-instance
container builds.
-72
View File
@@ -1,72 +0,0 @@
---
name: git-hooks
description: CopilotKit pre-commit hook reference. Load automatically when the user mentions git hooks failing, pre-commit errors, lefthook issues, commit blocked, or "hooks don't work", or when user wants to commit/push anything Contains the full hook topology so debugging skips discovery and goes straight to diagnosis.
metadata:
internal: true
---
# CopilotKit Git Hooks Reference
## Hook runner: Lefthook
The repo uses **lefthook** (not husky). The git hook at `.git/hooks/pre-commit` calls lefthook, which reads `lefthook.yml` at the repo root.
Config file: `lefthook.yml`
## Pre-commit commands (run in parallel)
```
sync-lockfile lint-fix test-and-check-packages
```
### 1. `sync-lockfile`
- **Trigger**: only when `**/package.json` files are staged
- **Command**: `pnpm i --lockfile-only`
- **`stage_fixed: true`**: auto-stages the updated lockfile
- **Fails if**: pnpm can't resolve dependencies
### 2. `lint-fix`
- **Command**: `pnpm run lint --fix && pnpm run format`
- Expands to: `nx run-many -t lint --projects=packages/**` then `prettier --write "**/*.{ts,tsx,md}"`
- **`stage_fixed: true`**: auto-stages any files it fixes
- **Fails if**: lint errors that `--fix` can't auto-correct
### 3. `test-and-check-packages` ← most common failure
- **Command**: `pnpm run test && pnpm run check:packages`
- Expands to:
1. `nx run-many -t test` — runs all unit tests across all packages
2. `nx run-many -t publint,attw --projects=packages/**` — checks package exports and types are correctly declared
- **`stage_fixed: false`** — does NOT auto-stage anything
- **Env**: `NX_TUI: "false"` (plain output, no interactive UI)
- **Fails if**:
- Any test fails
- `publint` finds malformed `package.json` exports
- `attw` (Are the Types Wrong?) finds type declaration issues
## Diagnosing a failure
The summary shows a boxing glove 🥊 for the failing command.
The error itself does not show directly.
To see the actual error:
```bash
# Run only the failing command manually:
pnpm run test # if test-and-check-packages failed
pnpm run check:packages # isolate publint/attw from test failures
nx run-many -t test --projects=<pkg> # narrow to a specific package
# Re-run lefthook manually (without committing):
pnpm lefthook run pre-commit
```
## npm scripts involved
| Script | Expands to |
| ------------------------- | ---------------------------------------------------- |
| `pnpm run lint` | `nx run-many -t lint --projects=packages/**` |
| `pnpm run format` | `prettier --write "**/*.{ts,tsx,md}"` |
| `pnpm run test` | `nx run-many -t test` |
| `pnpm run check:packages` | `nx run-many -t publint,attw --projects=packages/**` |
@@ -1,190 +0,0 @@
---
name: showcase-demo-debugging
description: Build, debug, and regression-test CopilotKit showcase demos. Use when working under showcase/, creating new showcase demos/items/cells/pills, implementing showcase features, investigating showcase demo bugs, D5/e2e-deep failures, aimock fixture gaps, direct-LLM versus aimock behavior, recording fixtures, testing every demo pill/cell, verifying repeated and interleaved suggestion-pill fixture stability, enforcing langgraph-python 1:1 parity, or migrating a demo to the v2 CopilotKit showcase flow.
metadata:
internal: true
---
# Showcase Demo Work
## Start Here
Before changing code or fixtures, read the current local guides:
- `showcase/README.md`
- `showcase/RUNBOOK.md`
- `showcase/DEBUGGING.md`
- `showcase/INTEGRATION-CHECKLIST.md` when adding or changing a demo
Use `showcase/bin/showcase` for showcase operations. Do not use raw `docker compose`, raw `docker build`, or direct harness commands unless the CLI lacks the needed operation and you explain why.
Prefer isolated runs when another session may be using showcase:
```sh
showcase/bin/showcase test <slug> --d5 --verbose --cycle --isolate
```
## Baseline Rules
- Keep showcase demo code on CopilotKit v2 APIs.
- Treat `showcase/integrations/langgraph-python` as the gold standard for D5 behavior. Every other showcase demo must be a 1:1 match to `langgraph-python` for the same demo behavior, prompts, pills, tool flow, UI assertions, and D5 coverage unless the user explicitly approves a documented divergence.
- Use Google ADK as extra context only when it is relevant.
- For any D5 cell or migration, make every pill work against a real agent first, then implement fixtures, then run the local D5 validation until green.
- E2E tests for migrated demos should match the `langgraph-python` behavior and assertions unless the user explicitly approves a divergence.
- Every pill in a demo must be exercised and covered by aimock-backed D5 replay before the demo is complete.
- When implementing a new feature, click every suggestion pill in the demo and verify it works before declaring the feature complete.
- With aimock replay, fixture behavior must be stable when a pill is clicked once, clicked five times in a row, clicked after another pill, and clicked again after intervening pills. The same pill should produce the same expected output for the same fixture-backed prompt regardless of prior pill order.
- Verify locally before saying a fix or demo is done.
- Do not weaken a demo to satisfy a test. The demo behavior is the product.
## New Showcase Items
Use this workflow when creating a new showcase item, adding a new demo cell, adding suggestion pills, or implementing a new showcase feature. The same rules apply as debugging:
1. Read the current showcase guides listed above.
2. Find the matching `langgraph-python` demo or define the new `langgraph-python` behavior first.
3. Implement the demo behavior with CopilotKit v2 APIs and make every suggestion pill work against a real agent.
4. If any local step talks to a real LLM, run it through aimock record mode so fixtures are captured.
5. Convert recordings into deterministic source D5 fixtures.
6. Verify every pill once, repeated, and interleaved under aimock replay.
7. Add or update D5 E2E coverage for every pill.
8. Run local validation before marking the new showcase item complete.
Do not treat new showcase work as done after the UI appears to work once. New demos need the same fixture consistency, replay stability, and regression coverage as bug fixes.
## Bug Investigation Loop
1. Read the relevant demo, D5 script, and existing fixtures before reproducing.
2. Determine whether the flow is using aimock or a real LLM:
- Inspect `showcase/docker-compose.local.yml`, provider base URLs, and `showcase/harness/fixtures/d5/*.json`.
- Check whether the matching fixture exists in `showcase/aimock/d5-all.json` or source fixtures under `showcase/harness/fixtures/d5/`.
- Use `showcase/bin/showcase logs aimock --grep "fixture|match|NO match|404"` after a run.
3. If the flow is supposed to use aimock, reproduce with:
```sh
showcase/bin/showcase up aimock <slug>
showcase/bin/showcase test <slug> --d5 --verbose --cycle
```
4. If fixtures look suspect, run aimock locally and inspect fixture matching before editing app code.
5. If the flow bypasses aimock and talks directly to a real LLM, always run aimock in record mode locally for that step. Do not run an unrecorded real-LLM local flow. Use the recorded fixture output to make later replay deterministic and to reach aimock-backed D5 coverage.
6. Once root cause is pinpointed and a fix is implemented, ask the user to click through the exact flow and confirm it works before locking the regression coverage.
7. After the user confirms, add or update D5 fixtures and regression E2E coverage so the issue cannot silently return.
8. For every additional issue the user reports while clicking around, repeat this same loop: reproduce, root cause, user verification, fixture, regression test.
## Environment Setup
If a real LLM provider is needed, inspect `showcase/.env` without printing secret values. If the required key is missing or empty:
1. Ensure `showcase/.env` exists, copying from `showcase/.env.example` if needed.
2. Open it for the user:
```sh
code --new-window "F:\projects\cpk\CopilotKit\showcase\.env"
```
3. Ask the user to paste the required key, usually `OPENAI_API_KEY`; use `ANTHROPIC_API_KEY` or `GOOGLE_API_KEY` only when the target provider needs it.
4. Ask the user to tell you when the file is saved. Do not ask them to paste secrets into chat.
If env/config changes affect containers, use:
```sh
showcase/bin/showcase recreate <slug>
```
Use rebuild only for dependency, Dockerfile, or non-mounted source changes.
## Reproducing Interactively
If the user says they will reproduce manually, start the needed local services and ask them to click through the flow, then tell you when finished so you can inspect logs and recorded fixtures.
If the user asks you to reproduce from instructions, run the showcase demo in headed Playwright or the Browser MCP, interact until the bug is reproduced, and preserve the exact steps, prompt text, selected pill, URL, and observed failure.
Use headed D5 when useful:
```sh
showcase/bin/showcase test <slug> --d5 --headed --verbose --cycle
```
## Recording Fixtures
For a new demo or any local step that talks to a real LLM, run aimock in record mode so the interaction becomes fixture-backed. This is mandatory: real-provider traffic should produce fixtures that can be replayed later.
```sh
docker compose -f showcase/docker-compose.local.yml -f showcase/docker-compose.record.yml --profile infra up -d aimock <slug>
```
This record-mode compose override is the accepted exception to the normal "use `showcase/bin/showcase`" rule until the showcase CLI has a first-class record command.
Then drive every pill in the demo manually or with the D5 harness. Recorded calls land under `showcase/aimock/d5-recorded/`.
After recording:
1. Convert or move recorded output into source D5 fixtures under `showcase/harness/fixtures/d5/`.
2. Ensure `showcase/aimock/d5-all.json` contains the replay fixture path required by current docs/scripts.
3. Run fixture validation.
4. Re-run the demo with aimock replay only.
5. Add or update D5 E2E coverage for every pill.
When using the existing D5 recorder helper, read `showcase/scripts/record-d5-fixtures.mjs` first. It documents the current recorder patch requirement for multi-turn fixtures. If aimock cannot record the needed response shape, do not fake the behavior in the demo; discuss the smallest upstream aimock change with the user.
## Pill Replay Stability
When adding a feature, changing fixtures, or debugging any fixture-backed demo, verify suggestion-pill stability under aimock replay:
1. Click each suggestion pill once in a fresh session and confirm the expected UI and assistant output.
2. Click each suggestion pill repeatedly, including at least five consecutive clicks for pills that exercise LLM/tool behavior.
3. Click multiple different pills one after another, then return to a previously clicked pill and confirm it still produces the same expected output.
4. Inspect aimock logs for fixture misses, incorrect matches, repeated stale matches, or order-dependent matches.
5. Fix fixture matching if output depends on click order, prior pills, or how many times the same pill was clicked.
Fixtures must be consistent and replayable. Do not rely on `turnIndex` or conversation history when a stable `userMessage`, `toolCallId`, `hasToolResult`, or more specific fixture key can make the replay deterministic across repeated and interleaved pill clicks.
## Fixture Rules
- Source fixtures live under `showcase/harness/fixtures/d5/*.json`.
- Bundled runtime fixtures live in `showcase/aimock/d5-all.json`.
- Use `hasToolResult` for multi-turn disambiguation; do not introduce `turnIndex` except for documented cases such as multi-step `mcp-subagents`.
- Prefer stable, specific `userMessage`, `toolCallId`, and tool-surface matching. Avoid broad catch-alls that can steal another pill's fixture.
- After fixture edits, validate and recreate aimock:
```sh
showcase/bin/showcase fixtures validate
showcase/bin/showcase recreate aimock
showcase/bin/showcase test <slug> --d5 --verbose --cycle
```
- If adding a new fixture file, update the compose mount and aimock Dockerfile copy rules when the current docs require it.
## Regression Tests
Add regression coverage after the user confirms the fixed flow works:
- Add or update the D5 fixture for the exact prompt/pill/tool flow.
- Add an E2E regression that fails on the original bug and passes with the fix.
- Cover every pill in the demo, including pills that only exist to expose edge cases.
- Include repeated and interleaved pill-click coverage when fixtures are involved, so a fixture cannot pass only on a single first click.
- Match the langgraph-python D5 assertion style and behavior 1:1 for equivalent demos.
- Include every separate user-reported demo issue as its own covered behavior unless a single test naturally covers the shared root cause.
- Run the narrow relevant D5 test first, then broader showcase validation when the change touches shared fixtures, harness logic, runtime behavior, or multiple demos.
## Aimock Upstream Gaps
If aimock lacks a feature needed to accurately replay the LLM provider response:
1. Confirm the gap with a recorded provider response or logs.
2. Avoid adding demo workarounds that hide the mismatch.
3. Ask the user what the easiest way is for you to file the upstream PR.
4. If approved, make the smallest aimock change, rebuild with:
```sh
showcase/bin/showcase aimock-rebuild --from <aimock-source-path>
```
5. Re-run the showcase D5 flow with the rebuilt aimock.
## Done Criteria
Before final status:
- The root cause is stated concretely.
- The user has confirmed the fixed UI flow when manual verification was requested.
- Fixtures are deterministic and validated.
- Any real-LLM local run was routed through aimock record mode and produced replayable fixtures.
- Every pill in the affected demo has aimock-backed D5 coverage.
- Aimock fixture replay is stable for single, repeated, and interleaved suggestion-pill clicks.
- Non-`langgraph-python` demo behavior matches `langgraph-python` 1:1 or has an explicit documented user-approved divergence.
- The relevant D5/e2e regression is present and passing locally.
- Any aimock limitation is either fixed upstream, linked to an upstream PR/plan, or explicitly called out as a blocker.
@@ -1,4 +0,0 @@
interface:
display_name: "Showcase Demo Work"
short_description: "Build and debug showcase demos with D5."
default_prompt: "Build or investigate this CopilotKit showcase demo using langgraph-python parity, aimock record/replay fixtures, every-pill verification, and D5 regression coverage."
+223
View File
@@ -386,6 +386,13 @@ jobs:
echo "- Publish step was skipped; no npm publish, no git tag, no GitHub Release."
} >> "$GITHUB_STEP_SUMMARY"
# Populated only on a stable, non-dry-run success (the publish/tag steps are
# gated on mode != prerelease && dry-run != true). On prerelease, dry-run, or
# failure these are empty — the notify job gates on that emptiness.
outputs:
version: ${{ steps.publish.outputs.version }}
tag: ${{ steps.tag.outputs.tag }}
# ===========================================================================
# Python SDK publish lane
#
@@ -411,6 +418,13 @@ jobs:
should_publish: ${{ steps.detect.outputs.should_publish }}
version: ${{ steps.detect.outputs.version }}
name: ${{ steps.detect.outputs.name }}
# Earliest Python-release intent signal: emitted by the `changed` step
# BEFORE any failure-prone step (setup-python, detect, build). The notify
# job gates the PyPI FAILURE alert on this (not should_publish, which is
# emitted only at the END of detect) so a build-python failure at/before
# detect on a genuine release still pages instead of being silently
# swallowed.
pyproject_changed: ${{ steps.changed.outputs.pyproject_changed }}
steps:
- name: Checkout merged main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -620,3 +634,212 @@ jobs:
echo "- \`copilotkit@${PY_VERSION}\`"
echo "- https://pypi.org/project/copilotkit/${PY_VERSION}/"
} >> "$GITHUB_STEP_SUMMARY"
# ===========================================================================
# Slack #engr notification
#
# A single concise post to #engr when a release publishes (or fails).
# Runs after both lanes regardless of their outcome (`if: always()`). The
# load-bearing truth table lives in the unit-tested pure builder at
# scripts/release/lib/build-release-notification.ts; this job only feeds it the
# needs.* signals and posts what it returns. Suppressed entirely for canaries
# (mode=prerelease) and dry-runs — the builder returns should_post=false there.
# Webhook empty-guard mirrors showcase_validate.yml so an unset
# SLACK_WEBHOOK_ENGR secret does not break the shell or red this step.
# ===========================================================================
notify:
needs: [build, publish, build-python, publish-python]
# always() so we still report on a failed lane, but guard on a real release
# context: a workflow_dispatch (manual release) OR a *merged* PR. A
# closed-unmerged PR is not a release attempt and must not notify.
if: >
always() &&
(github.event_name == 'workflow_dispatch' ||
github.event.pull_request.merged == true)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_ENGR }}
steps:
# Determine release intent IN THIS JOB, from the github.event payload +
# the PR changed-files API — NOT from needs.build*/needs.build-python
# outputs. The build jobs emit their intent signals (should_publish,
# pyproject_changed) AFTER failure-prone steps (SHA guards, setup-python,
# the PyPI version-compare), so a build job that dies before emitting them
# on a REAL release would leave the intent empty → no alert. Computing
# intent here, independent of whether the build jobs ran at all, closes
# that silent-swallow class. The builder gates the npm/PyPI FAILURE arms on
# these signals.
#
# This step runs FIRST — before Checkout/Setup/Install — on purpose:
# computing intent before any infra step means steps.intent.outputs.* are
# always populated even if a later infra step (the dependency install,
# checkout, or setup) fails. The failure() self-alert below gates its
# best-effort Slack post on these outputs, so running intent first
# guarantees that gate can still fire when the notify job dies during
# install — exactly the silent-swallow the self-watchdog exists to prevent.
# It needs only `gh api` (preinstalled), the github.event context, and
# GITHUB_TOKEN (in its own env), so it has no dependency on checkout/deps.
- name: Determine release intent
id: intent
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# npm intent: a manual dispatch, or a merged release/publish/* PR.
# Computed purely from event-context expressions (no API needed).
NPM_INTENDED="${{ (github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/publish/'))) && 'true' || 'false' }}"
echo "npm_intended=$NPM_INTENDED" >> "$GITHUB_OUTPUT"
# Python intent: a python_publish dispatch, OR a merged PR that changed
# sdk-python/pyproject.toml (per the GitHub PR changed-files API —
# robust to an uncomputed local merge_commit_sha). Default false.
PY_INTENDED="false"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
if [ "${{ inputs.python_publish }}" = "true" ]; then PY_INTENDED="true"; fi
elif [ "${{ github.event.pull_request.merged }}" = "true" ]; then
# Query the merged PR's changed files. Fail TOWARD paging: if the API
# call fails on a merged PR, default PY_INTENDED=true (never toward
# silence) and emit a ::warning::.
if FILES="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename' 2>/dev/null)"; then
if printf '%s\n' "$FILES" | grep -qx 'sdk-python/pyproject.toml'; then PY_INTENDED="true"; fi
else
echo "::warning::Could not list changed files for PR #${{ github.event.pull_request.number }} via the GitHub API — defaulting py_intended=true (fail toward paging, never toward silence)."
PY_INTENDED="true"
fi
fi
echo "py_intended=$PY_INTENDED" >> "$GITHUB_OUTPUT"
echo "npm_intended=$NPM_INTENDED py_intended=$PY_INTENDED"
- name: Checkout Repo
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
# Restore node_modules so `pnpm tsx` (and the release-config import the
# notifier does) resolves. The build/publish jobs install before running
# tsx for the same reason; without this the notify step fails and NO
# release notification can ever post.
- name: Install Dependencies
run: pnpm install --frozen-lockfile
# Compute the scope-correct npm URL: the monorepo packages live under the
# @copilotkit org page, but the angular release is a single package.
- name: Resolve npm URL for scope
id: npmurl
env:
SCOPE: ${{ needs.build.outputs.scope }}
run: |
set -euo pipefail
case "$SCOPE" in
angular)
NPM_URL="https://www.npmjs.com/package/@copilotkitnext/angular"
;;
*)
NPM_URL="https://www.npmjs.com/org/copilotkit"
;;
esac
echo "npm_url=$NPM_URL" >> "$GITHUB_OUTPUT"
- name: Build notification message
id: build
env:
MODE: ${{ needs.build.outputs.mode }}
NPM_RESULT: ${{ needs.publish.result }}
NPM_VER: ${{ needs.publish.outputs.version }}
BUILD_RESULT: ${{ needs.build.result }}
# Event-derived release intent computed in the `intent` step above
# (independent of the build jobs). The builder gates the npm/PyPI
# FAILURE arms on these so a build-job failure on a genuine release
# always pages, even if the build jobs emitted no usable outputs.
NPM_INTENDED: ${{ steps.intent.outputs.npm_intended }}
PY_INTENDED: ${{ steps.intent.outputs.py_intended }}
# should_publish still legitimately gates the PyPI SUCCESS arm (a real
# success means detect ran and emitted it).
PY_PUB: ${{ needs.build-python.outputs.should_publish }}
PY_RESULT: ${{ needs.publish-python.result }}
PY_BUILD_RESULT: ${{ needs.build-python.result }}
PY_VER: ${{ needs.build-python.outputs.version }}
SCOPE: ${{ needs.build.outputs.scope }}
DRY_RUN: ${{ inputs.dry-run }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Empty when no tag was created (non-stable / dry-run). The builder's
# empty-releaseUrl guard that consumes this is retained as
# DEFENSE-IN-DEPTH: the empty-releaseUrl-on-SUCCESS state is NOT
# currently reachable — the tag step is `if: success()`, so a tag-step
# failure flips the publish JOB to `failure` and routes to the failure
# arm rather than rendering an empty link. The guard protects against a
# FUTURE change making the tag step continue-on-error (publish success
# + empty tag output), which would otherwise render a broken empty
# "<|Release notes>" / "/releases/tag/" link — do NOT remove it.
RELEASE_URL: ${{ needs.publish.outputs.tag && format('{0}/{1}/releases/tag/{2}', github.server_url, github.repository, needs.publish.outputs.tag) || '' }}
NPM_URL: ${{ steps.npmurl.outputs.npm_url }}
# Empty-version guard, mirroring RELEASE_URL above: with no version
# the per-version PyPI URL would be a broken ".../copilotkit//" link,
# so fall back to the project root page.
PY_URL: ${{ needs.build-python.outputs.version && format('https://pypi.org/project/copilotkit/{0}/', needs.build-python.outputs.version) || 'https://pypi.org/project/copilotkit/' }}
run: pnpm tsx scripts/release/build-release-notification.ts
- name: Post to #engr
if: ${{ steps.build.outputs.should_post == 'true' && env.SLACK_WEBHOOK != '' && inputs.dry-run != true }}
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_ENGR }}
webhook-type: incoming-webhook
payload: |
{
"text": ${{ toJSON(steps.build.outputs.message) }}
}
- name: Log (no Slack — webhook unset)
if: ${{ steps.build.outputs.should_post == 'true' && env.SLACK_WEBHOOK == '' }}
run: |
echo "::warning::A release notification was ready to post but SLACK_WEBHOOK_ENGR is not set; no Slack notification sent."
# Self-watchdog: if any earlier step in THIS job failed (e.g. the
# dependency install or the builder crashed), the notifier itself is the
# thing that broke — so a real release alert could be silently swallowed.
# Emit a ::error:: and, when the webhook is configured, a minimal
# best-effort Slack post so the failure isn't completely invisible.
- name: Notifier failed — self-alert
if: ${{ failure() }}
run: |
echo "::error::The release notify job failed before it could post — a release alert may have been swallowed. Check this run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Notifier failed — best-effort Slack
# Guard on dry-run: the self-watchdog Slack post must honor the same
# silence invariant as the real notification — a dry-run is silent
# EVERYWHERE, so a notify-job failure during one must not post a false
# red page.
#
# ALSO guard on real-release-context via the robust, build-job-INDEPENDENT
# intent computed in the `intent` step: the notify job runs on EVERY
# merged PR (always()), so a routine non-release merge whose notify job
# hits a transient install/builder flake would otherwise self-page even
# though no release was attempted. Only self-alert when a release was
# actually in flight (npm_intended OR py_intended). This replaces the old
# npm-biased `mode != 'prerelease'` + should_publish/pyproject_changed
# heuristic — which both relied on the build jobs' outputs (the very
# signals that may be empty if a build job died) AND would have suppressed
# a python_publish self-alert under a prerelease-mode dispatch. The intent
# gate is correct and robust. (The ::error:: echo step above stays
# unconditional — only the Slack POST needs these guards.)
if: ${{ failure() && env.SLACK_WEBHOOK != '' && inputs.dry-run != true && (steps.intent.outputs.npm_intended == 'true' || steps.intent.outputs.py_intended == 'true') }}
uses: slackapi/slack-github-action@b0fa283ad8fea605de13dc3f449259339835fc52 # v2.1.0
with:
webhook: ${{ secrets.SLACK_WEBHOOK_ENGR }}
webhook-type: incoming-webhook
payload: |
{
"text": ${{ toJSON(format('🔴 *CopilotKit release notifier failed* — a release alert may have been swallowed · <{0}/{1}/actions/runs/{2}|View run>', github.server_url, github.repository, github.run_id)) }}
}
+208 -4
View File
@@ -15,6 +15,7 @@ on:
branches: [main]
paths:
- "showcase/**"
- "examples/integrations/**"
- ".github/workflows/showcase_build.yml"
- ".github/workflows/showcase_build_check.yml"
workflow_dispatch:
@@ -52,6 +53,22 @@ on:
- showcase-harness
- showcase-aimock
- webhooks
# Per-starter image builds (model B, §b stage 1). These map to the
# build-starters job below, NOT the showcase `build` job. "all"
# builds the full showcase fleet AND all 12 starters; a specific
# starter slug narrows to that one starter via STARTER_DISPATCH.
- starter-langgraph-python
- starter-mastra
- starter-langgraph-js
- starter-crewai-crews
- starter-pydantic-ai
- starter-adk
- starter-agno
- starter-llamaindex
- starter-langgraph-fastapi
- starter-strands-python
- starter-ms-agent-framework-python
- starter-ms-agent-framework-dotnet
# No top-level concurrency group. Every build run completes. This is the
# whole point of the decoupling: rapid pushes to main no longer cancel
@@ -245,10 +262,23 @@ jobs:
# done — a common "did my dispatch deploy?" footgun. The `all` and
# empty (push-event) modes legitimately produce [] when there are
# no changes and must still succeed.
if [ "$MATRIX" = "[]" ] && [ -n "$DISPATCH" ] && [ "$DISPATCH" != "all" ]; then
echo "::error::workflow_dispatch service='$DISPATCH' did not match any entry in ALL_SERVICES — check the dispatch_name spelling"
exit 1
fi
#
# The `starter-*` namespace is OWNED by the detect-starter-changes
# job (which scopes its OWN fail-loud to `starter-*`). A
# `service=starter-<slug>` dispatch legitimately yields [] here
# (no showcase service is named `starter-*`), so it must SKIP — an
# empty showcase matrix, NOT a fail-loud exit 1. Mirrors how the
# starter job scopes its fail-loud to the `starter-*` namespace.
case "$DISPATCH" in
starter-*) ;; # starter namespace → empty showcase matrix + skip
"" | "all") ;; # push / full-fleet → [] is legitimate
*)
if [ "$MATRIX" = "[]" ]; then
echo "::error::workflow_dispatch service='$DISPATCH' did not match any entry in ALL_SERVICES — check the dispatch_name spelling"
exit 1
fi
;;
esac
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
if [ "$MATRIX" = "[]" ]; then
@@ -436,6 +466,180 @@ jobs:
if-no-files-found: error
retention-days: 7
# ────────────────────────────────────────────────────────────────────
# Per-starter image publish (model B, §b stage 1 / Phase 1).
#
# Fully decoupled from the showcase `build`→`aggregate`→`redeploy` chain
# above: starters are self-contained npm projects (no monorepo-source
# build, no shared-module copy) and build from their own root Dockerfile
# at examples/integrations/<slug>/Dockerfile (the single-image deployable:
# Next.js frontend + agent, EXPOSE 3000, CMD entrypoint.sh — distinct from
# the docker/Dockerfile.app + docker/Dockerfile.agent split stack used by
# docker-compose.test.yml). Published to ghcr.io/copilotkit/starter-<slug>
# (the `starter-` prefix is disjoint from `showcase-*`; S2's harness
# discovery filters on namePrefix "starter-"). Railway deploy is the
# gated S5 — NOT done here.
#
# The 12 starter slugs are the matrix source of truth (the smoke matrix in
# test_smoke-starter.yml and STARTER_TO_COLUMN in
# showcase/harness/src/probes/helpers/starter-mapping.ts). The dashboard
# column remap lives in the harness (§a), so this layer uses raw starter
# slugs.
detect-starter-changes:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
matrix: ${{ steps.starter-matrix.outputs.matrix }}
has_changes: ${{ steps.starter-matrix.outputs.has_changes }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Detect changed starter paths
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
workflow_config:
- '.github/workflows/showcase_build.yml'
langgraph_python:
- 'examples/integrations/langgraph-python/**'
mastra:
- 'examples/integrations/mastra/**'
langgraph_js:
- 'examples/integrations/langgraph-js/**'
crewai_crews:
- 'examples/integrations/crewai-crews/**'
pydantic_ai:
- 'examples/integrations/pydantic-ai/**'
adk:
- 'examples/integrations/adk/**'
agno:
- 'examples/integrations/agno/**'
llamaindex:
- 'examples/integrations/llamaindex/**'
langgraph_fastapi:
- 'examples/integrations/langgraph-fastapi/**'
strands_python:
- 'examples/integrations/strands-python/**'
ms_agent_framework_python:
- 'examples/integrations/ms-agent-framework-python/**'
ms_agent_framework_dotnet:
- 'examples/integrations/ms-agent-framework-dotnet/**'
- name: Build starter matrix
id: starter-matrix
env:
DISPATCH_SERVICE: ${{ github.event.inputs.service }}
FILTER_CHANGES: ${{ steps.filter.outputs.changes }}
run: |
set -euo pipefail
# One slot per starter. `slug` is the examples/integrations/<slug>
# directory name; `image` is the published GHCR repo (starter-<slug>);
# `filter_key` matches the paths-filter key above.
ALL_STARTERS='[
{"slug":"langgraph-python","image":"starter-langgraph-python","filter_key":"langgraph_python"},
{"slug":"mastra","image":"starter-mastra","filter_key":"mastra"},
{"slug":"langgraph-js","image":"starter-langgraph-js","filter_key":"langgraph_js"},
{"slug":"crewai-crews","image":"starter-crewai-crews","filter_key":"crewai_crews"},
{"slug":"pydantic-ai","image":"starter-pydantic-ai","filter_key":"pydantic_ai"},
{"slug":"adk","image":"starter-adk","filter_key":"adk"},
{"slug":"agno","image":"starter-agno","filter_key":"agno"},
{"slug":"llamaindex","image":"starter-llamaindex","filter_key":"llamaindex"},
{"slug":"langgraph-fastapi","image":"starter-langgraph-fastapi","filter_key":"langgraph_fastapi"},
{"slug":"strands-python","image":"starter-strands-python","filter_key":"strands_python"},
{"slug":"ms-agent-framework-python","image":"starter-ms-agent-framework-python","filter_key":"ms_agent_framework_python"},
{"slug":"ms-agent-framework-dotnet","image":"starter-ms-agent-framework-dotnet","filter_key":"ms_agent_framework_dotnet"}
]'
# Dispatch modes (mirror the showcase detect-changes job):
# "all" → every starter (full-fleet rebuild).
# "starter-<slug>" → that one starter (strip "starter-" prefix to match .image).
# "<showcase service slug>" → no starters (this is a showcase-only dispatch).
# "" (push event) → starters whose filter_key appears in CHANGES
# (or workflow_config touched → rebuild all).
DISPATCH="${DISPATCH_SERVICE:-}"
CHANGES="${FILTER_CHANGES:-[]}"
# `.image` is exactly "starter-<slug>", which is also the
# workflow_dispatch choice value, so a specific-starter dispatch
# matches `$dispatch == .image` directly.
MATRIX=$(echo "$ALL_STARTERS" | jq -c --arg dispatch "$DISPATCH" --argjson changes "$CHANGES" '
[.[] |
(.filter_key as $fk | select(
$dispatch == "all" or
($dispatch != "" and $dispatch != "all" and $dispatch == .image) or
($dispatch == "" and (($changes | index("workflow_config") != null) or ($changes | index($fk) != null)))
))]
')
# Fail loudly on a typo'd starter dispatch (mirrors showcase job).
# Only applies to the starter-* dispatch namespace; a showcase
# service slug or "all" legitimately yields [] here.
case "$DISPATCH" in
starter-*)
if [ "$MATRIX" = "[]" ]; then
echo "::error::workflow_dispatch service='$DISPATCH' did not match any starter — check the slug"
exit 1
fi
;;
esac
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
if [ "$MATRIX" = "[]" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
build-starters:
needs: [detect-starter-changes]
if: needs.detect-starter-changes.outputs.has_changes == 'true'
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 20
permissions:
id-token: write
contents: read
packages: write
strategy:
fail-fast: false
matrix:
starter: ${{ fromJSON(needs.detect-starter-changes.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Setup Depot
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1
- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push starter image
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
with:
project: m2kw2wmmcp
context: examples/integrations/${{ matrix.starter.slug }}
file: examples/integrations/${{ matrix.starter.slug }}/Dockerfile
# Pin amd64: Railway and GHCR serve x86 hosts. Depot defaults to
# the runner's native arch, so an arm64-only image would crash on
# pull with "does not have a linux/amd64 variant available".
platforms: linux/amd64
push: true
tags: |
ghcr.io/copilotkit/${{ matrix.starter.image }}:latest
ghcr.io/copilotkit/${{ matrix.starter.image }}:${{ github.sha }}
aggregate-build-results:
name: Aggregate build results
needs: [detect-changes, build]
+82 -15
View File
@@ -117,6 +117,14 @@ jobs:
exit 1
fi
CSV=$(jq -r '.services[] | select(.probe.prod == true) | .name' "$GENERATED" | sort -u | tr '\n' ',' | sed 's/,$//')
# Fail loud if 'all' resolved to nothing (e.g. an SSOT regression
# dropped every probe.prod entry). An empty CSV would otherwise
# propagate downstream with exit 0; mirror the single-service
# branch's fail-loud style.
if [ -z "$CSV" ]; then
echo "::error::'all' resolved to zero prod-eligible services"
exit 1
fi
else
# Capture the FULL match set (no `head` — that would silently mask
# an ambiguous match, and piping jq into head under pipefail can
@@ -178,12 +186,31 @@ jobs:
promote:
needs: [resolve-targets, verify-staging-precondition]
# Do NOT hard-depend on verify-staging-precondition's RESULT. That job
# probes the FULL requested set all-or-nothing, so a single staging-red
# service (e.g. a chronically-broken integration in `all`) fails it and
# — under the default `if: success()` — would SKIP promote entirely,
# re-blocking the whole fleet. bin/railway enforces staging-green
# PER-SERVICE (spec §7 P2 = latest staging deploy must be SUCCESS, P3 =
# live staging probe, default-on), so a red service is refused on its own
# and lands in promote-fleet.sh's failed set (reds the run) without
# taking the greens down with it. verify-staging-precondition therefore
# stays as an advisory early signal surfaced in the notify payload, not a
# hard blocker. `!cancelled()` keeps a human-cancelled run from promoting;
# mirrors the verify-prod gate idiom below.
if: ${{ !cancelled() && needs.resolve-targets.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 20
environment: railway
permissions:
contents: read
packages: read
outputs:
# CSV of the services that ACTUALLY promoted (best-effort: a partial
# failure still exposes the succeeded subset). verify-prod scopes its
# prod verification to exactly this set so a single failed service can't
# red the verification of the services that did promote.
succeeded_csv: ${{ steps.promote.outputs.succeeded_csv }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -200,6 +227,7 @@ jobs:
npm ci
npx tsx emit-railway-envs-json.ts
- name: bin/railway promote
id: promote
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -211,22 +239,30 @@ jobs:
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
# The local promote runs each service in turn; bin/railway
# handles spec §7 preconditions (P1..P6). --require-staging-green
# is default-on; the prior job already established staging is
# green but we keep the script-level guard as defense in depth.
IFS=',' read -ra SVCS <<< "$SERVICES_CSV"
for svc in "${SVCS[@]}"; do
args=(promote "$svc" --yes --non-interactive)
if [ -n "${DIGEST:-}" ]; then
args+=(--digest "$DIGEST")
fi
echo "==> showcase/bin/railway ${args[*]}"
showcase/bin/railway "${args[@]}"
done
# promote-fleet.sh runs each service in turn BEST-EFFORT: a single
# red service (e.g. a chronically-broken integration in the `all`
# set) must not abort promotion of the rest of the fleet. The script
# attempts every service, aggregates the succeeded/failed sets, emits
# a step summary, and exits non-zero iff ANY service failed (so the
# notify job still fires) — but only AFTER attempting all of them.
# bin/railway itself handles spec §7 preconditions (P1..P6);
# --require-staging-green is default-on and the prior job already
# established staging is green (defense in depth). We deliberately do
# NOT pass --confirm-divergence: WARN-divergence refusals are a real
# signal that must fail the run.
RAILWAY_BIN="showcase/bin/railway" \
showcase/scripts/promote-fleet.sh
verify-prod:
needs: [resolve-targets, promote]
# Run whenever promote actually RAN — success OR partial failure — so the
# services that DID promote still get prod verification. `!cancelled()`
# excludes a human-cancelled run; `needs.promote.result != 'skipped'`
# excludes the case where promote never ran (e.g. an upstream abort/skip).
# Under the default `if: success()` this job was SKIPPED on any partial
# promote failure, leaving the promoted services with zero prod
# verification — that is the bug this gate fixes.
if: ${{ !cancelled() && needs.promote.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 20
environment: railway
@@ -244,12 +280,27 @@ jobs:
- name: Run verify-deploy --env prod
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
SERVICES_CSV: ${{ needs.resolve-targets.outputs.services_csv }}
# Scope verification to the services that ACTUALLY promoted (from the
# promote job's best-effort succeeded set), NOT the full requested set
# (resolve-targets) — verifying a service that failed to promote would
# guarantee a red verify and mask the health of the ones that did
# promote.
SERVICES_CSV: ${{ needs.promote.outputs.succeeded_csv }}
run: |
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
# If NOTHING promoted (every requested service failed, so the
# succeeded set is empty), there is nothing to verify — skip with a
# clear log line rather than calling verify-deploy.ts with an empty
# --services (which would either error or vacuously pass). The promote
# job already exited non-zero in that case, so the overall run is red
# via the notify state machine regardless.
if [ -z "$SERVICES_CSV" ]; then
echo "::notice::succeeded_csv is empty (no services promoted, or promote did not run); skipping prod verification. The run is red via the promote job result if anything failed."
exit 0
fi
npx tsx showcase/scripts/verify-deploy.ts --env prod --services "$SERVICES_CSV"
notify:
@@ -264,6 +315,17 @@ jobs:
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
steps:
# Best-effort promote invariant (do NOT reintroduce a PRE gate below):
# resolve-targets = HARD precondition (must succeed).
# verify-staging-precondition = ADVISORY only — surfaced in the Slack
# payload (`pre-staging`), never gates
# promote or run success. bin/railway
# enforces staging-green per-service, so
# PRE is a leading indicator, not a gate.
# promote = best-effort (exits non-zero iff a
# service failed).
# verify-prod = verifies the succeeded subset.
# run success = PROMOTE == success AND PROD == success.
- name: Compute state
id: state
env:
@@ -281,7 +343,12 @@ jobs:
# This is NOT a failed promote — emit a neutral state so no red
# alert pages #oss-alerts.
STATE="aborted"; ICON=":heavy_minus_sign:"
elif [ "$PRE" = "success" ] && [ "$PROMOTE" = "success" ] && [ "$PROD" = "success" ]; then
elif [ "$PROMOTE" = "success" ] && [ "$PROD" = "success" ]; then
# PRE (verify-staging-precondition) is ADVISORY and intentionally
# NOT in this gate: bin/railway enforces staging-green per-service,
# so a service that was staging-red at precondition time but still
# promoted+verified cleanly must not red the run. PRE remains
# surfaced in the Slack payload as the `pre-staging` field.
STATE="success"; ICON=":white_check_mark:"
elif [ "$RESOLVE" = "cancelled" ] || [ "$PRE" = "cancelled" ] || [ "$PROMOTE" = "cancelled" ] || [ "$PROD" = "cancelled" ]; then
# A human cancelled the run (e.g. via the Actions UI). This is a
+39 -7
View File
@@ -528,6 +528,35 @@ jobs:
run: |
echo "::warning::showcase_validate failed on push but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
shell-script-tests:
name: Shell script tests (bats + shellcheck)
# Separate job (mirrors python-unit-tests) so the showcase shell-script
# regression suite runs independently of the JS/TS validate job. Runs on
# ubuntu-latest where shellcheck is preinstalled; bats is apt-installed.
# These tests gate the promote-fleet.sh best-effort loop + succeeded_csv
# export that the promote → verify-prod handoff depends on.
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Install bats
run: |
sudo apt-get update
sudo apt-get install -y bats
- name: Shellcheck promote-fleet.sh
# shellcheck is preinstalled on ubuntu-latest.
run: shellcheck showcase/scripts/promote-fleet.sh
- name: Run bats suite
run: bats showcase/scripts/__tests__/
python-unit-tests:
name: Python unit tests (${{ matrix.python-version }})
# Separate job so pre-existing `validate-parity` failures don't mask new
@@ -641,11 +670,12 @@ jobs:
# red runs surface uniformly across the showcase pipeline. The validate
# job has its own inline (and richer) push-only notifier that extracts
# the failing step + error excerpt; this job is the workflow-level
# safety net that also covers the python-unit-tests matrix job — which
# otherwise had no Slack signal at all. Webhook empty-guard mirrors
# promote.yml so an unset SLACK_WEBHOOK_OSS_ALERTS secret does not
# break the shell or red the workflow on this step.
needs: [validate, python-unit-tests]
# safety net that also covers the python-unit-tests matrix job and the
# shell-script-tests job — which otherwise had no Slack signal at all.
# Webhook empty-guard mirrors promote.yml so an unset
# SLACK_WEBHOOK_OSS_ALERTS secret does not break the shell or red the
# workflow on this step.
needs: [validate, python-unit-tests, shell-script-tests]
if: always() && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 3
@@ -660,9 +690,10 @@ jobs:
env:
VALIDATE: ${{ needs.validate.result }}
PYTEST: ${{ needs.python-unit-tests.result }}
SHELL: ${{ needs.shell-script-tests.result }}
run: |
set -euo pipefail
if [ "$VALIDATE" = "success" ] && [ "$PYTEST" = "success" ]; then
if [ "$VALIDATE" = "success" ] && [ "$PYTEST" = "success" ] && [ "$SHELL" = "success" ]; then
STATE="success"; ICON=":white_check_mark:"
else
STATE="failure"; ICON=":x:"
@@ -680,11 +711,12 @@ jobs:
payload: |
{
"text": ${{ toJSON(format(
'{0} *showcase_validate failed on {1}*\nvalidate={2} python-unit-tests={3}\n<{4}/{5}/actions/runs/{6}|View run>',
'{0} *showcase_validate failed on {1}*\nvalidate={2} python-unit-tests={3} shell-script-tests={4}\n<{5}/{6}/actions/runs/{7}|View run>',
steps.state.outputs.icon,
github.ref,
needs.validate.result,
needs.python-unit-tests.result,
needs.shell-script-tests.result,
github.server_url,
github.repository,
github.run_id
+19 -1
View File
@@ -1,8 +1,26 @@
name: test / smoke / starter
# PR build-sanity gate (model B, §e / Phase 5) PLUS the post-merge 6h
# floating-dependency breakage detector. The eventual live dashboard signal
# for starter health comes from the harness HTTP-probing the deployed
# (sleepable) Railway starter services — but those S5 Railway services do
# NOT exist yet, so this `schedule` cron remains the ONLY post-merge
# detector that catches a starter broken by a floating transitive dependency
# or an upstream CopilotKit release. We KEEP the 6h cron (and the harness
# alert path, alerts/alert-engine.ts → #oss-alerts, carries the
# scheduled-failure Slack signal too) until S5 starter-service probing is
# confirmed live, at which point the cron can be retired in favour of the
# harness signal. The fast pre-merge build-sanity gate is the
# `pull_request` trigger on examples/integrations/**: it builds + smoke-
# tests each starter offline against aimock on every starter change. The
# post-merge GHCR `:latest` publish that feeds the Railway services lives in
# showcase_build.yml's build-starters job (push:main, §b stage 1), so it is
# intentionally NOT duplicated here.
on:
schedule:
# Every 6 hours — catches floating dep breakage
# Every 6h — post-merge floating-dependency / upstream-release breakage
# detector. Stays until S5 harness probing of live Railway starter
# services is confirmed, then retire in favour of the harness signal.
- cron: "0 */6 * * *"
workflow_run:
workflows: ["publish / release"]
+3 -8
View File
@@ -151,10 +151,7 @@
"src/app/api/copilotkit/**",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"scripts/**",
"src/components/threads-drawer/**",
"src/app/page.tsx",
"next.config.ts"
"scripts/**"
],
"packageJsonOverrides": {
"scripts.dev:agent": "cd agent && uv run main.py",
@@ -173,11 +170,9 @@
"Dockerfile",
"docker/Dockerfile.agent",
"docker-compose.test.yml",
"entrypoint.sh",
"serve.py",
"scripts/**",
"src/components/threads-drawer/**",
"src/app/page.tsx",
"next.config.ts"
"scripts/**"
],
"packageJsonOverrides": {
"scripts.dev:agent": "./scripts/run-agent.sh || scripts\\\\run-agent.bat",
@@ -0,0 +1,7 @@
OPENAI_API_KEY=your-api-key-here
# --- copilotkit:intelligence (optional local threads stack) ---
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_KEY=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
@@ -1,21 +1,106 @@
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");
const agent = new A2AAgent({ a2aClient });
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,
},
runner: new InMemoryAgentRunner(),
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({
@@ -25,3 +110,5 @@ const app = createCopilotEndpoint({
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -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}>
copilotkit add-intelligence
</code>
</div>
<button
type="button"
className={styles.lockedCta}
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</button>
</div>
</div>
</aside>
);
}
@@ -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
add-intelligence command in a muted code well, and a near-black primary CTA
pill matching the sidebar's send button. */
.lockedPanel {
display: flex;
width: 20rem;
flex-shrink: 0;
flex-direction: column;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
}
.lockedBody {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
padding: 1rem;
}
.lockedCard {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.lockedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
}
.lockedHeading {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.lockedTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.lockedDescription {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.lockedCommand {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
}
.lockedCommandCode {
font-family: var(--font-code);
font-size: 0.75rem;
white-space: nowrap;
color: var(--secondary-foreground);
}
.lockedCta {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.25rem;
padding: 0 0.875rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 140ms ease;
}
.lockedCta:hover {
opacity: 0.9;
}
.lockedCta:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
radius-xl, soft elevated shadow. */
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
color: var(--foreground);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.4rem;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1.1rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.dialogButtonSecondary {
background: var(--card);
border: 1px solid var(--border);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--accent);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--accent);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
and a fixed-width panel leaves a dead column). */
.lockedPanel {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -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&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -11,6 +11,31 @@
: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) {
@@ -25,3 +50,9 @@
@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);
}
+37 -10
View File
@@ -1,28 +1,55 @@
"use client";
import { CopilotChat, CopilotKitProvider } from "@copilotkit/react-core/v2";
import { createA2UIMessageRenderer } from "@copilotkit/a2ui-renderer";
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 A2UIMessageRenderer = createA2UIMessageRenderer({ theme });
const activityRenderers = [A2UIMessageRenderer];
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}
>
<main
className="h-full overflow-auto w-screen"
style={{ minHeight: "100dvh" }}
>
<CopilotChat className="h-full" />;
</main>
<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>
);
}
@@ -1,4 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: "standalone",
serverExternalPackages: ["@copilotkit/runtime"],
env: {
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -17,10 +17,13 @@
"@a2a-js/sdk": "0.2.5",
"@a2ui/lit": "^0.8.1",
"@ag-ui/a2a": "0.00.6",
"@copilotkit/a2ui-renderer": "1.55.2",
"@copilotkit/react-core": "1.55.2",
"@copilotkit/runtime": "1.55.2",
"@ag-ui/client": "0.0.53",
"@ag-ui/core": "0.0.53",
"@copilotkit/a2ui-renderer": "1.59.3",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"hono": "^4.6.18",
"lucide-react": "^0.577.0",
"next": "^16.0.10",
"react": "^19.2.3",
"react-dom": "^19.2.3",
@@ -39,3 +39,13 @@ RESEARCH_PORT=9001
# Analysis Agent
ANALYSIS_PORT=9002
# ========================================
# CopilotKit Intelligence Threads (Optional)
# ========================================
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_KEY=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
@@ -1,81 +1,161 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { HttpAgent } from "@ag-ui/client";
import type {
AgentSubscriber,
RunAgentInput,
RunAgentParameters,
RunAgentResult,
} from "@ag-ui/client";
import { A2AMiddlewareAgent } from "@ag-ui/a2a-middleware";
import type { A2AAgentConfig } from "@ag-ui/a2a-middleware";
import { handle } from "hono/vercel";
export async function POST(request: Request) {
const researchAgentUrl =
process.env.RESEARCH_AGENT_URL || "http://localhost:9001";
const analysisAgentUrl =
process.env.ANALYSIS_AGENT_URL || "http://localhost:9002";
const orchestratorUrl =
process.env.ORCHESTRATOR_URL || "http://localhost:9000";
const researchAgentUrl =
process.env.RESEARCH_AGENT_URL || "http://localhost:9001";
const analysisAgentUrl =
process.env.ANALYSIS_AGENT_URL || "http://localhost:9002";
const orchestratorUrl = process.env.ORCHESTRATOR_URL || "http://localhost:9000";
const orchestrationAgent = new HttpAgent({
url: orchestratorUrl,
});
type RuntimeRunAgentInput = RunAgentParameters &
Partial<Pick<RunAgentInput, "messages" | "state" | "threadId">>;
const a2aMiddlewareAgent = new A2AMiddlewareAgent({
description:
"Research assistant with 2 specialized agents: Research (LangGraph) and Analysis (ADK)",
agentUrls: [researchAgentUrl, analysisAgentUrl],
orchestrationAgent,
instructions: `
You are a research assistant that orchestrates between 2 specialized agents.
type RuntimeA2AMiddlewareAgentConfig = Omit<
A2AAgentConfig,
"orchestrationAgent"
> & {
orchestrationAgentUrl: string;
};
AVAILABLE AGENTS:
class RuntimeA2AMiddlewareAgent extends A2AMiddlewareAgent {
private readonly config: RuntimeA2AMiddlewareAgentConfig;
- Research Agent (LangGraph): Gathers and summarizes information about a topic
- Analysis Agent (ADK): Analyzes research findings and provides insights
constructor(config: RuntimeA2AMiddlewareAgentConfig) {
super({
...config,
orchestrationAgent: new HttpAgent({
url: config.orchestrationAgentUrl,
}),
});
this.config = config;
}
WORKFLOW STRATEGY (SEQUENTIAL - ONE AT A TIME):
async runAgent(
parameters: RuntimeRunAgentInput = {},
subscriber?: AgentSubscriber,
): Promise<RunAgentResult> {
const isolatedAgent = new A2AMiddlewareAgent({
...this.config,
agentId: this.agentId,
debug: this.debug,
description: this.description,
initialMessages: this.messages,
initialState: this.state,
threadId: parameters.threadId ?? this.threadId,
orchestrationAgent: new HttpAgent({
url: this.config.orchestrationAgentUrl,
}),
});
When the user asks to research a topic:
if (parameters.state) {
isolatedAgent.setState(parameters.state);
}
1. Research Agent - First, gather information about the topic
- Pass: The user's research query or topic
- The agent will return structured JSON with research findings
if (parameters.messages) {
isolatedAgent.setMessages(parameters.messages);
}
2. Analysis Agent - Then, analyze the research results
- Pass: The research results from step 1
- The agent will return structured JSON with analysis and insights
return isolatedAgent.runAgent(
{
context: parameters.context,
forwardedProps: parameters.forwardedProps,
runId: parameters.runId,
tools: parameters.tools,
},
subscriber,
);
}
3. Present the complete research and analysis to the user
CRITICAL RULES:
- Call agents ONE AT A TIME, wait for results before making next call
- Pass information from earlier agents to later agents
- Synthesize all gathered information in final response
`,
});
const runtime = new CopilotRuntime({
agents: {
a2a_chat: a2aMiddlewareAgent,
},
runner: new InMemoryAgentRunner(),
});
const app = createCopilotEndpoint({
runtime,
basePath: "/api/copilotkit",
});
return handle(app)(request);
clone(): RuntimeA2AMiddlewareAgent {
return new RuntimeA2AMiddlewareAgent({
...this.config,
agentId: this.agentId,
debug: this.debug,
description: this.description,
initialMessages: this.messages,
initialState: this.state,
threadId: this.threadId,
});
}
}
export async function GET(request: Request) {
const runtime = new CopilotRuntime({
agents: {},
runner: new InMemoryAgentRunner(),
});
const app = createCopilotEndpoint({
runtime,
basePath: "/api/copilotkit",
});
return handle(app)(request);
}
const a2aMiddlewareAgent = new RuntimeA2AMiddlewareAgent({
orchestrationAgentUrl: orchestratorUrl,
agentId: "a2a_chat",
description:
"Research assistant with 2 specialized agents: Research (LangGraph) and Analysis (ADK)",
agentUrls: [researchAgentUrl, analysisAgentUrl],
instructions: `
You are a research assistant that orchestrates between 2 specialized agents.
AVAILABLE AGENTS:
- Research Agent (LangGraph): Gathers and summarizes information about a topic
- Analysis Agent (ADK): Analyzes research findings and provides insights
WORKFLOW STRATEGY (SEQUENTIAL - ONE AT A TIME):
When the user asks to research a topic:
1. Research Agent - First, gather information about the topic
- Pass: The user's research query or topic
- The agent will return structured JSON with research findings
2. Analysis Agent - Then, analyze the research results
- Pass: The research results from step 1
- The agent will return structured JSON with analysis and insights
3. Present the complete research and analysis to the user
CRITICAL RULES:
- Call agents ONE AT A TIME, wait for results before making next call
- Pass information from earlier agents to later agents
- Synthesize all gathered information in final response
`,
});
const runtime = new CopilotRuntime({
agents: {
a2a_chat: a2aMiddlewareAgent,
},
// --- 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);
@@ -1,6 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@config "../tailwind.config.ts";
@layer base {
:root {
@@ -60,3 +59,29 @@
.a2a-message-enter {
animation: slide-in 0.3s ease-out;
}
.threadsLayout,
body > [role="presentation"] {
--foreground: oklch(0.145 0 0);
--background: oklch(1 0 0);
--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);
}
@@ -2,6 +2,13 @@
import { useState } from "react";
import Chat from "@/components/chat";
import {
CopilotChatConfigurationProvider,
CopilotKitProvider,
} from "@copilotkit/react-core/v2";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
export type ResearchData = {
topic: string;
@@ -24,12 +31,15 @@ export type AnalysisData = {
conclusion: string;
};
export default function Home() {
// Disable static optimization for this page
export const dynamic = "force-dynamic";
function ResearchAssistant() {
const [researchData, setResearchData] = useState<ResearchData | null>(null);
const [analysisData, setAnalysisData] = useState<AnalysisData | null>(null);
return (
<div className="relative flex h-screen overflow-hidden bg-[#DEDEE9] p-2">
<div className="relative flex min-h-dvh overflow-hidden bg-[#DEDEE9] p-2">
{/* Background blur circles - Creating the gradient effect */}
<div
className="absolute w-[445px] h-[445px] left-[1040px] top-[11px] rounded-full z-0"
@@ -51,8 +61,8 @@ export default function Home() {
}}
/>
<div className="flex flex-1 overflow-hidden z-10 gap-2">
<div className="w-[450px] flex-shrink-0 border-2 border-white bg-white/50 backdrop-blur-md shadow-elevation-lg flex flex-col rounded-lg overflow-hidden">
<div className="flex flex-1 flex-col gap-2 overflow-y-auto z-10 lg:flex-row lg:overflow-hidden">
<div className="flex min-h-[calc(100dvh-1rem)] w-full flex-shrink-0 flex-col overflow-hidden rounded-lg border-2 border-white bg-white/50 shadow-elevation-lg backdrop-blur-md lg:w-[450px]">
<div className="p-6 border-b border-[#DBDBE5]">
<h1 className="text-2xl font-semibold text-[#010507] mb-1">
Research Assistant
@@ -76,8 +86,8 @@ export default function Home() {
</div>
</div>
<div className="flex-1 overflow-y-auto rounded-lg bg-white/30 backdrop-blur-sm">
<div className="mx-auto p-8">
<div className="min-h-[520px] flex-1 overflow-y-auto rounded-lg bg-white/30 backdrop-blur-sm lg:min-h-0">
<div className="mx-auto p-4 sm:p-8">
<div className="mb-8">
<h2 className="text-3xl font-semibold text-[#010507] mb-2">
Research Results
@@ -104,7 +114,7 @@ export default function Home() {
</div>
)}
<div className="flex flex-row gap-2 items-stretch">
<div className="flex flex-col gap-2 items-stretch xl:flex-row">
{researchData && (
<div className="flex-1 bg-white/60 backdrop-blur-md rounded-xl border-2 border-[#DBDBE5] shadow-elevation-md p-6">
<div className="flex flex-col gap-0 mb-4">
@@ -189,3 +199,33 @@ export default function Home() {
</div>
);
}
export default function Home() {
const [threadId, setThreadId] = useState<string | undefined>(undefined);
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit"
showDevConsole="auto"
useSingleEndpoint={false}
>
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId="a2a_chat"
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId="a2a_chat"
threadId={threadId}
>
<ResearchAssistant />
</CopilotChatConfigurationProvider>
</div>
</div>
</CopilotKitProvider>
);
}
@@ -7,13 +7,10 @@
import React, { useEffect } from "react";
import {
CopilotKit,
useAgent,
useFrontendTool,
CopilotChat,
} from "@copilotkit/react-core/v2";
// NOTE: useCopilotChat has no v2 equivalent; kept on v1 import path
import { useCopilotChat } from "@copilotkit/react-core";
import "@copilotkit/react-core/v2/styles.css";
import { z } from "zod";
import { MessageToA2A } from "./a2a/MessageToA2A";
import { MessageFromA2A } from "./a2a/MessageFromA2A";
@@ -37,27 +34,27 @@ type ChatProps = {
onAnalysisUpdate: (data: AnalysisData | null) => void;
};
const ChatInner = ({ onResearchUpdate, onAnalysisUpdate }: ChatProps) => {
const { visibleMessages } = useCopilotChat();
export default function Chat({
onResearchUpdate,
onAnalysisUpdate,
}: ChatProps) {
const { agent } = useAgent({ agentId: "a2a_chat" });
// Extract structured JSON from A2A agent responses and pass to parent
useEffect(() => {
const extractDataFromMessages = () => {
for (const message of visibleMessages) {
for (const message of agent.messages) {
const msg = message as any;
if (
msg.type === "ResultMessage" &&
msg.actionName === "send_message_to_a2a_agent"
) {
if (msg.role === "tool" && typeof msg.content !== "undefined") {
try {
const result = msg.result;
const result = msg.content;
let parsed;
if (typeof result === "string") {
let cleanResult = result;
if (result.startsWith("A2A Agent Response: ")) {
cleanResult = result.substring("A2A Agent Response: ".length);
cleanResult = result.slice("A2A Agent Response: ".length);
}
try {
parsed = JSON.parse(cleanResult);
@@ -83,13 +80,13 @@ const ChatInner = ({ onResearchUpdate, onAnalysisUpdate }: ChatProps) => {
};
extractDataFromMessages();
}, [visibleMessages, onResearchUpdate, onAnalysisUpdate]);
}, [agent.messages, onResearchUpdate, onAnalysisUpdate]);
// Register action to render A2A message flow visualization
useFrontendTool({
name: "send_message_to_a2a_agent",
description: "Sends a message to an A2A agent",
available: "frontend",
available: true,
parameters: z.object({
agentName: z
.string()
@@ -109,25 +106,11 @@ const ChatInner = ({ onResearchUpdate, onAnalysisUpdate }: ChatProps) => {
return (
<CopilotChat
labels={{
title: "Research Assistant",
initial:
modalHeaderTitle: "Research Assistant",
welcomeMessageText:
'👋 Hi! I\'m your research assistant. I can help you research any topic.\n\nFor example, try:\n- "Research quantum computing"\n- "Tell me about artificial intelligence"\n- "Research renewable energy"\n\nI\'ll coordinate with specialized agents to gather information and provide insights!',
}}
className="h-full"
/>
);
};
export default function Chat({
onResearchUpdate,
onAnalysisUpdate,
}: ChatProps) {
return (
<CopilotKit runtimeUrl="/api/copilotkit" agent="a2a_chat">
<ChatInner
onResearchUpdate={onResearchUpdate}
onAnalysisUpdate={onAnalysisUpdate}
/>
</CopilotKit>
);
}
@@ -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}>
copilotkit add-intelligence
</code>
</div>
<button
type="button"
className={styles.lockedCta}
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</button>
</div>
</div>
</aside>
);
}
@@ -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
add-intelligence command in a muted code well, and a near-black primary CTA
pill matching the sidebar's send button. */
.lockedPanel {
display: flex;
width: 20rem;
flex-shrink: 0;
flex-direction: column;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
}
.lockedBody {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
padding: 1rem;
}
.lockedCard {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.lockedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
}
.lockedHeading {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.lockedTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.lockedDescription {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.lockedCommand {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
}
.lockedCommandCode {
font-family: var(--font-code);
font-size: 0.75rem;
white-space: nowrap;
color: var(--secondary-foreground);
}
.lockedCta {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.25rem;
padding: 0 0.875rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 140ms ease;
}
.lockedCta:hover {
opacity: 0.9;
}
.lockedCta:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
radius-xl, soft elevated shadow. */
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
color: var(--foreground);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.4rem;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1.1rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.dialogButtonSecondary {
background: var(--card);
border: 1px solid var(--border);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--accent);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--accent);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
and a fixed-width panel leaves a dead column). */
.lockedPanel {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -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&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -1,7 +1,13 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
serverExternalPackages: ["@copilotkit/runtime"],
env: {
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -16,27 +16,31 @@
"dependencies": {
"@a2a-js/sdk": "latest",
"@ag-ui/a2a-middleware": "0.0.2",
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "latest",
"@copilotkit/runtime": "latest",
"@ag-ui/client": "0.0.53",
"@ag-ui/core": "0.0.53",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"hono": "^4",
"lucide-react": "^0.577.0",
"next": "15.5.15",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"concurrently": "^9.1.2",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tailwindcss": "^4.1.13",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
},
"overrides": {
"@ag-ui/client": "0.0.52"
"@ag-ui/client": "0.0.53",
"@ag-ui/core": "0.0.53"
}
}
@@ -1,8 +1,6 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
plugins: ["@tailwindcss/postcss"],
};
export default config;
+150 -422
View File
@@ -9,9 +9,9 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@ag-ui/client": "0.0.53",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -21,7 +21,8 @@
"react": "^19.2.1",
"react-dom": "^19.2.1",
"shiki": "^3.19.0",
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"zod": "^3.24.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -59,15 +60,6 @@
"zod-to-json-schema": "^3.25.1"
}
},
"node_modules/@a2ui/web_core/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@ag-ui/a2ui-middleware": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@ag-ui/a2ui-middleware/-/a2ui-middleware-0.0.5.tgz",
@@ -81,13 +73,13 @@
}
},
"node_modules/@ag-ui/client": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.52.tgz",
"integrity": "sha512-U407VvDDwR5qs8TiyN1qY38x87qMWc2n0epw8iA5aa1qwzCKBBDgg3Fkm4JogQf0X4jwNsz8HUbIZrBB56mrpg==",
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.52",
"@ag-ui/encoder": "0.0.52",
"@ag-ui/proto": "0.0.52",
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
@@ -97,39 +89,21 @@
"zod": "^3.22.4"
}
},
"node_modules/@ag-ui/client/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@ag-ui/core": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.52.tgz",
"integrity": "sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==",
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@ag-ui/core/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@ag-ui/encoder": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.52.tgz",
"integrity": "sha512-6GVDTb1dv2rjap7VVnmXYypDutZi6nrsTcdfxoP6ryDG5ynlXtmmS+FSDAt62JbIMD5CtEE963xNCb6d1iXw9g==",
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.52",
"@ag-ui/proto": "0.0.52"
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@ag-ui/langgraph": {
@@ -161,11 +135,11 @@
}
},
"node_modules/@ag-ui/proto": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.52.tgz",
"integrity": "sha512-+iCGzNUNL50YIoThVmsolWPjG4MJidl+R9k8QAGVwErEfHRtQ64KFyrdpeOXNVuWtM3SViJqPSgFyv7eGVS63A==",
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.52",
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
@@ -187,9 +161,9 @@
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.122",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.122.tgz",
"integrity": "sha512-U1k2fk7cSH/tS5CZ3ujROiUCOLFwkzb792OqR/Org8Mfm27dKSIdRZG4ZuJUifT8alUWa61IoaRu4foXKlP5TQ==",
"version": "3.0.124",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.124.tgz",
"integrity": "sha512-h8CrmbSG+8X0C+M/E1M4oiDHYevqwbzAPN+uLRHS0eJaatF2MZ+juNtOHXNOjk7Bsk9mD2RjYMjJO9dFkb9I7Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
@@ -301,9 +275,9 @@
}
},
"node_modules/@ai-sdk/mcp": {
"version": "1.0.45",
"resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-1.0.45.tgz",
"integrity": "sha512-0J+pVPu5IlBGVirY2nnLlHPuSEqnN0kd7zefL6zfBkezs6x93t0ET8OKgpGaCzEHrj/W+d7TvZ9AhwywWP7wnQ==",
"version": "1.0.46",
"resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-1.0.46.tgz",
"integrity": "sha512-owU0wAP87KzsTzr+2JE9sT9lpsCWKg8ZwHhce/KQmD9D/kbhe69sUZ+lsFF5MoGvV/ZzMujrhAvC1MgmufwMeQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
@@ -458,9 +432,9 @@
"license": "Apache-2.0"
},
"node_modules/@copilotkit/a2ui-renderer": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/a2ui-renderer/-/a2ui-renderer-1.59.1.tgz",
"integrity": "sha512-jRltYlLKqwWREiS430pJ0ukpA1L7hralerVFYlsgtkMhRMf9DzSMl+Gbt48FDGt/QteS7YwsT+nZrUWanOXJXg==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/a2ui-renderer/-/a2ui-renderer-1.59.3.tgz",
"integrity": "sha512-51Ik/RLaCKDF7fA5zlV72F9vKEdO3+Udn4PxaUAx1xyz+3pq1ylFCenVH3kv8ZAX1ieLQ5FANnZoPUJvorynzg==",
"license": "MIT",
"dependencies": {
"@a2ui/web_core": "0.9.0",
@@ -473,22 +447,13 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@copilotkit/a2ui-renderer/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@copilotkit/core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/core/-/core-1.59.1.tgz",
"integrity": "sha512-i1cdGADVNR/TkkZcMWGyrInBMutnHlCVxvLoujet9x8OkgMVOF/T6w9Ep7PNNY5Wwlu+i2XwGQPz5bb1/NyUdw==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/core/-/core-1.59.3.tgz",
"integrity": "sha512-RtkKoN7C2fsDPcfq2kWRlC4avAexKzV0VCu4QUGI1wSJTkF5JAFhW9Tm2GGAxCISPveUGj/B1iPOzt6O8DaPKw==",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@tanstack/pacer": "^0.20.1",
"phoenix": "^1.8.4",
"rxjs": "7.8.1",
@@ -498,59 +463,6 @@
"node": ">=18"
}
},
"node_modules/@copilotkit/core/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/core/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/core/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/core/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/core/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@copilotkit/license-verifier": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@copilotkit/license-verifier/-/license-verifier-0.4.2.tgz",
@@ -558,18 +470,18 @@
"license": "MIT"
},
"node_modules/@copilotkit/react-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.59.1.tgz",
"integrity": "sha512-vteM29uKKhN1IfS9iKIMDw28IUGMSuUJd3eB6gAJhE2lI4GUr0K02vytUclqHKxR1/vUZpsvqvPJuDLCFO4ujw==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.59.3.tgz",
"integrity": "sha512-4S36QZAQAsygKPocdmSeM59hNQVzDcHoBEMN+xVgHtqf9stmILdr0CH161zT6hP+kwIg/MOYDdvj4bVZU61lyA==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@ag-ui/core": "0.0.53",
"@copilotkit/a2ui-renderer": "1.59.1",
"@copilotkit/core": "1.59.1",
"@copilotkit/runtime-client-gql": "1.59.1",
"@copilotkit/shared": "1.59.1",
"@copilotkit/web-inspector": "1.59.1",
"@copilotkit/a2ui-renderer": "1.59.3",
"@copilotkit/core": "1.59.3",
"@copilotkit/runtime-client-gql": "1.59.3",
"@copilotkit/shared": "1.59.3",
"@copilotkit/web-inspector": "1.59.3",
"@jetbrains/websandbox": "^1.1.3",
"@lit-labs/react": "^2.0.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -596,68 +508,6 @@
"zod": ">=3.0.0"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/client/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/core/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/react-core/node_modules/lucide-react": {
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
@@ -668,9 +518,9 @@
}
},
"node_modules/@copilotkit/runtime": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime/-/runtime-1.59.1.tgz",
"integrity": "sha512-0Rl+8i2xj3dGiy+NG6tnItg0wGgXxOnB8BVkMsHC6PxRQp7am3PqUNrOQTFI67i7bvXfEPNdZINm97djzOvVSQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime/-/runtime-1.59.3.tgz",
"integrity": "sha512-ZWj9VQUUBup7T2N8A+0tj7h3ZK2LW1O+agtVdcYjxLN3RCk0L2a/TuYxvmVXrxUBZwAahgU9ZUgc2QIoo9LmQg==",
"license": "MIT",
"dependencies": {
"@ag-ui/a2ui-middleware": "0.0.5",
@@ -685,7 +535,7 @@
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/openai": "^3.0.36",
"@copilotkit/license-verifier": "~0.4.2",
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@graphql-yoga/plugin-defer-stream": "^3.3.1",
"@hono/node-server": "^1.13.5",
"@modelcontextprotocol/sdk": "^1.18.2",
@@ -757,12 +607,12 @@
}
},
"node_modules/@copilotkit/runtime-client-gql": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime-client-gql/-/runtime-client-gql-1.59.1.tgz",
"integrity": "sha512-JLBDy5Y37LiZhKWIwSm4ETQBOzy3vHDUvbvjtxDzF6kxsddswyXEMtjQUEc93RDwDCh1XuH2FLnd1ha48kmOLQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime-client-gql/-/runtime-client-gql-1.59.3.tgz",
"integrity": "sha512-TS+VJTxXxC+9shUf5VsTPpf+8zny1spUCHaX66m96paa+tGoFCmKscpT+Fgo/IQY7/7XzuJ6R4HWDm5MyiVd9g==",
"license": "MIT",
"dependencies": {
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@urql/core": "^5.0.3",
"untruncate-json": "^0.0.1",
"urql": "^4.1.0"
@@ -771,63 +621,6 @@
"react": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/client/node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/runtime/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
@@ -842,19 +635,10 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@copilotkit/runtime/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@copilotkit/shared": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.1.tgz",
"integrity": "sha512-vCJB4bao5Tu54mc7D/h/vLfUZsDECl/kgW/GVli2soMzIJW6UAHFhtt2D9MN206xDtE6gXqBnBIMKNG+JO61SQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.3.tgz",
"integrity": "sha512-Q2FXKha5y/PkWe9xPLWRSi28iMDNVG3lMvARwKOA5esvaf1gsxBr5B6pKdtWOOoy/03Yk/hmAEFxlhBE56vi7g==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
@@ -872,82 +656,13 @@
"@ag-ui/core": ">=0.0.48"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/client/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/encoder/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/proto/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/shared/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@copilotkit/web-inspector": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/web-inspector/-/web-inspector-1.59.1.tgz",
"integrity": "sha512-vA+xhTiXLh7x0IKP84O7neAAZfx6FYzzwGAr1lVPBLDABGb8Ax2ZuSGywMEEtAiu9r5ravPTN+oT71yRNjD4yA==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/web-inspector/-/web-inspector-1.59.3.tgz",
"integrity": "sha512-6V+Z3xQO0tWVOA1rGOlfDr6iD7TuAwSLYrGxlkv6p5LJ1iwXJQFpidcDPiqPvGWCdHOQBAHcvrI0oedayzhWwQ==",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@copilotkit/core": "1.59.1",
"@copilotkit/core": "1.59.3",
"lit": "^3.2.0",
"lucide": "^0.525.0",
"marked": "^12.0.2"
@@ -956,59 +671,6 @@
"node": ">=18"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -1918,9 +1580,9 @@
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.9.13",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.13.tgz",
"integrity": "sha512-c3yq7kfGncMxJT9YCua5KGytTQbEyZhhjt6y3wSFHvuhNXGTJV5kBctK6jh1xFrWKwjFcdqRtVBE8pqz1GiSrA==",
"version": "1.9.15",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.15.tgz",
"integrity": "sha512-QUW9c7ikycmTkpr/eQYkKA4gpriqizG1OJ6H2YLbDOVkEYLSFnJeTO4MG6pBBO8EqHHa8m1zavKi5ug259VSQQ==",
"license": "MIT",
"dependencies": {
"@langchain/protocol": "^0.0.16",
@@ -3642,6 +3304,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
@@ -4294,12 +4022,12 @@
}
},
"node_modules/ai": {
"version": "6.0.194",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.194.tgz",
"integrity": "sha512-0MkYqrSZZuC1zTECppcaUT0i54aocXpYaUMVue3V8z/weBHCytfO5/CcwZCU80msZpfkbBUKYSSrkZFotEO5wQ==",
"version": "6.0.196",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.196.tgz",
"integrity": "sha512-2T45UeqKL4a11KQ14I5i1YYHOvCFrMF478E1k6PVjlQSGUvXSv4xrxIaQbUL4qgv91DADSbddwv3oR49pPAK3g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.122",
"@ai-sdk/gateway": "3.0.124",
"@ai-sdk/provider": "3.0.10",
"@ai-sdk/provider-utils": "4.0.27",
"@opentelemetry/api": "^1.9.0"
@@ -5556,9 +5284,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5628,9 +5356,9 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.22.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
"version": "5.22.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz",
"integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7525,9 +7253,9 @@
}
},
"node_modules/langsmith": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.7.3.tgz",
"integrity": "sha512-Gg+IeRGoF1/aStu80aEnIXJCu+N6+4NoV4tAVFS51ZPRBsRa2KG0LkS7K7/ryZ/yES7O9xdqah5QuuWMIeMjQw==",
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.7.4.tgz",
"integrity": "sha512-EGYCw85etSarYazeTgj8DICVIFg+26gsVZ0zq8V7kjIb59huURJpZZJqVFkvRpZFxmfyYrpIhtk2qtHgGR8K+w==",
"license": "MIT",
"dependencies": {
"p-queue": "6.6.2"
@@ -7564,9 +7292,9 @@
"license": "MIT"
},
"node_modules/libphonenumber-js": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.4.tgz",
"integrity": "sha512-/lhWr7vq8foWN9Apksnd9v8/cfwzW6g6qKOCo25XBGkNaVCHucXO57hLy4CWHGvytvLz6Nt3J5Gs8p3jlCGFXA==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.5.tgz",
"integrity": "sha512-7/kRezHmQlMfO6pmvt34orO/g3j1C47k8FCBXFgj/mklTLwQdBca1LkhDK6RM8UyM6JqHFAIikMdkKkyfQy39A==",
"license": "MIT"
},
"node_modules/lightningcss": {
@@ -15179,9 +14907,9 @@
}
},
"node_modules/openai": {
"version": "6.41.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.41.0.tgz",
"integrity": "sha512-IGWPopZq6Rjoynjfb3NSLf/z2MTw7UiOsm9TAjPGAjUESH7Uq41Trg4QWehBEn58p74i+m7uoRPV2vXcpPXhyA==",
"version": "6.42.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz",
"integrity": "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==",
"license": "Apache-2.0",
"peerDependencies": {
"ws": "^8.18.0",
@@ -18820,9 +18548,9 @@
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+3 -3
View File
@@ -13,9 +13,9 @@
"postinstall": "npm run install:agent"
},
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@ag-ui/client": "0.0.53",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -9,7 +9,7 @@ import { handle } from "hono/vercel";
const runtime = new CopilotRuntime({
agents: {
my_agent: new HttpAgent({
default: new HttpAgent({
url: process.env.AGENT_URL || "http://localhost:8000/",
}),
},
@@ -20,7 +20,6 @@ export default function RootLayout({
{/* Force REST transport so runtime-info + threads both hit the multi-route endpoint (auto-detect races the lazily-compiled API route in next dev). */}
<CopilotKit
runtimeUrl="/api/copilotkit"
agent="my_agent"
useSingleEndpoint={false}
>
{children}
+63 -55
View File
@@ -7,21 +7,19 @@ import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import { AgentState } from "@/lib/types";
import {
useAgent,
useDefaultRenderTool,
useConfigureSuggestions,
useFrontendTool,
useHumanInTheLoop,
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { z } from "zod";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
// The agent key registered in the runtime route (`agents: { my_agent: ... }`)
// and the id passed to `useAgent({ agentId: "my_agent" })` below.
const AGENT_ID = "my_agent";
// The agent key registered in the runtime route (`agents: { default: ... }`)
// and the id passed to `useAgent({ agentId: "default" })` below.
const AGENT_ID = "default";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
@@ -35,11 +33,39 @@ export default function CopilotKitPage() {
.string()
.describe("The theme color to set. Make sure to pick nice colors."),
}),
handler({ themeColor }) {
handler: async ({ themeColor }) => {
setThemeColor(themeColor);
return `Changing theme color to ${themeColor}`;
},
});
// 🪁 Suggestions: https://docs.copilotkit.ai/adk/suggestions
useConfigureSuggestions({
suggestions: [
{
title: "Generative UI",
message: "Get the weather in San Francisco.",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Write Agent State",
message: "Add a proverb about AI.",
},
{
title: "Update Agent State",
message:
"Please remove 1 random proverb from the list if there are any.",
},
{
title: "Read Agent State",
message: "What are the proverbs?",
},
],
});
return (
// Share the active threadId with the chat + agent. `useAgent()` and the
// CopilotSidebar fall back to this provider's threadId when called without
@@ -68,32 +94,10 @@ export default function CopilotKitPage() {
<CopilotSidebar
defaultOpen={true}
labels={{
title: "Popup Assistant",
initial: "👋 Hi, there! You're chatting with an agent.",
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
"👋 Hi, there! You're chatting with an agent.",
}}
suggestions={[
{
title: "Generative UI",
message: "Get the weather in San Francisco.",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Write Agent State",
message: "Add a proverb about AI.",
},
{
title: "Update Agent State",
message:
"Please remove 1 random proverb from the list if there are any.",
},
{
title: "Read Agent State",
message: "What are the proverbs?",
},
]}
/>
</main>
</div>
@@ -103,31 +107,35 @@ export default function CopilotKitPage() {
function YourMainContent({ themeColor }: { themeColor: string }) {
// 🪁 Shared State: https://docs.copilotkit.ai/adk/shared-state
const { agent } = useAgent({
agentId: AGENT_ID,
});
const state = (agent.state ?? {
proverbs: [
"CopilotKit may be new, but its the best thing since sliced bread.",
],
}) as AgentState;
const setState = (newState: AgentState) => agent.setState(newState);
// V2: useAgent returns the agent; read agent.state and write via agent.setState.
const { agent } = useAgent({ agentId: AGENT_ID });
const state = (agent.state as AgentState | undefined) ?? { proverbs: [] };
const setState = (next: AgentState) => agent.setState(next);
// Seed an initial proverb once (the V2 agent starts with empty state).
useEffect(() => {
if ((agent.state as AgentState | undefined)?.proverbs === undefined) {
agent.setState({
proverbs: [
"CopilotKit may be new, but it's the best thing since sliced bread.",
],
});
}
}, [agent]);
//🪁 Generative UI: https://docs.copilotkit.ai/adk/generative-ui
useRenderTool(
{
name: "get_weather",
render: ({ parameters, result }) => {
return (
<WeatherCard
location={(parameters as any)?.location}
themeColor={themeColor}
/>
);
},
useFrontendTool({
name: "get_weather",
description: "Get the weather for a given location.",
available: false,
parameters: z.object({
location: z.string(),
}),
render: ({ args }) => {
return <WeatherCard location={args.location} themeColor={themeColor} />;
},
[themeColor],
);
followUp: false,
});
return (
<div
@@ -0,0 +1,8 @@
OPENAI_API_KEY=your-api-key-here
AGENT_URL=http://localhost:8000/
# Optional: enable CopilotKit Intelligence Threads locally
COPILOTKIT_LICENSE_TOKEN=
INTELLIGENCE_API_KEY=
INTELLIGENCE_API_URL=http://localhost:4201
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
+2 -1
View File
@@ -33,6 +33,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env
.env*
!.env.example
# vercel
.vercel
@@ -51,4 +52,4 @@ __pycache__
# docs
.chalk/
.claude/
.claude/
@@ -1,4 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@copilotkit/runtime"],
env: {
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -16,14 +16,14 @@
},
"dependencies": {
"@a2ui/lit": "^0.8.1",
"@ag-ui/a2ui-middleware": "^0.0.2",
"@ag-ui/client": "0.0.52",
"@ag-ui/core": "^0.0.46",
"@ag-ui/encoder": "^0.0.46",
"@ag-ui/proto": "^0.0.46",
"@copilotkit/a2ui-renderer": "1.57.1",
"@copilotkit/react-core": "1.57.1",
"@copilotkit/runtime": "1.57.1",
"@ag-ui/a2ui-middleware": "0.0.5",
"@ag-ui/client": "0.0.53",
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@copilotkit/a2ui-renderer": "1.59.3",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"hono": "^4.11.4",
"next": "16.0.10",
"react": "^19.2.3",
@@ -1,5 +1,6 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
@@ -18,10 +19,28 @@ agent.use(new A2UIMiddleware({ injectA2UITool: true }));
const runtime = new CopilotRuntime({
agents: { my_a2ui_agent: agent },
runner: new InMemoryAgentRunner(),
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);
+170 -21
View File
@@ -3,17 +3,28 @@
import "@copilotkit/react-core/v2/styles.css";
import {
CopilotChat,
CopilotChatConfigurationProvider,
CopilotKitProvider,
createA2UIMessageRenderer,
ToolCallStatus,
useFrontendTool,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import {
createA2UIMessageRenderer,
A2UIViewer,
A2UIProvider,
A2UIRenderer,
useA2UIActions,
viewerTheme,
} from "@copilotkit/a2ui-renderer";
import { z } from "zod";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import {
useState,
useCallback,
useEffect,
useId,
useMemo,
useRef,
} from "react";
import { withA2UIActivityMessage } from "@/components/a2ui-activity-wrapper";
import { theme } from "./theme";
@@ -26,6 +37,9 @@ import {
EmailComposeLoadingState,
} from "@/components/email-compose-view";
import type { EmailComposeData } from "@/components/email-compose-view";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
// Disable static optimization for this page
export const dynamic = "force-dynamic";
@@ -54,6 +68,123 @@ interface CanvasState {
content: CanvasContent | null;
}
function A2UIStaticViewer({
root,
components,
data,
styles,
className,
}: {
root: string;
components: any[];
data: Record<string, unknown>;
styles?: Record<string, string>;
className?: string;
}) {
const baseId = useId();
const surfaceId = useMemo(() => {
const definitionKey = `${root}-${JSON.stringify(components)}`;
let hash = 0;
for (let i = 0; i < definitionKey.length; i++) {
hash = (hash << 5) - hash + definitionKey.charCodeAt(i);
hash &= hash;
}
return `surface${baseId.replace(/:/g, "-")}${hash}`;
}, [baseId, root, components]);
if (!components.length) {
return (
<div className={className} style={{ padding: 16, color: "#666" }}>
No content to display
</div>
);
}
return (
<A2UIProvider theme={viewerTheme}>
<A2UIStaticViewerInner
surfaceId={surfaceId}
root={root}
components={components}
data={data}
styles={styles}
className={className}
/>
</A2UIProvider>
);
}
function A2UIStaticViewerInner({
surfaceId,
root,
components,
data,
styles,
className,
}: {
surfaceId: string;
root: string;
components: any[];
data: Record<string, unknown>;
styles?: Record<string, string>;
className?: string;
}) {
const { processMessages } = useA2UIActions();
const lastProcessedRef = useRef<string>("");
useEffect(() => {
const key = `${surfaceId}-${JSON.stringify(components)}-${JSON.stringify(data)}`;
if (key === lastProcessedRef.current) return;
lastProcessedRef.current = key;
const messages: any[] = [
{ beginRendering: { surfaceId, root, styles: styles ?? {} } },
{ surfaceUpdate: { surfaceId, components } },
];
const contents = objectToValueMaps(data);
if (contents.length) {
messages.push({
dataModelUpdate: { surfaceId, path: "/", contents },
});
}
processMessages(messages);
}, [processMessages, surfaceId, root, components, data, styles]);
return (
<div className={className}>
<A2UIRenderer surfaceId={surfaceId} />
</div>
);
}
function objectToValueMaps(obj: Record<string, unknown>): any[] {
return Object.entries(obj).map(([key, value]) => valueToValueMap(key, value));
}
function valueToValueMap(key: string, value: unknown): any {
if (typeof value === "string") return { key, valueString: value };
if (typeof value === "number") return { key, valueNumber: value };
if (typeof value === "boolean") return { key, valueBoolean: value };
if (value === null || value === undefined) return { key };
if (Array.isArray(value)) {
return {
key,
valueMap: value.map((item, index) =>
valueToValueMap(String(index), item),
),
};
}
if (typeof value === "object") {
return {
key,
valueMap: objectToValueMaps(value as Record<string, unknown>),
};
}
return { key };
}
const CANVAS_TITLES: Record<CanvasContent["type"], string> = {
dashboard: "Daily Brief",
calendar: "Schedule",
@@ -106,7 +237,7 @@ function parseEmailList(raw: string): Email[] {
id: String(i),
from: e.from,
subject: e.subject,
preview: e.body?.substring(0, 80) || "",
preview: e.body?.slice(0, 80) || "",
body: e.body || "",
date: e.date || "Today",
isRead: e.isRead ?? false,
@@ -286,7 +417,7 @@ function CanvasContentRenderer({ content }: { content: CanvasContent }) {
switch (content.type) {
case "dashboard":
return (
<A2UIViewer
<A2UIStaticViewer
root={content.root}
components={content.components}
data={content.data}
@@ -711,6 +842,7 @@ export default function Page() {
mode: "chat",
content: null,
});
const [threadId, setThreadId] = useState<string | undefined>(undefined);
const handleCanvasUpdate = useCallback((content: CanvasContent) => {
setCanvas({ mode: "canvas", content });
@@ -732,26 +864,43 @@ export default function Page() {
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit"
useSingleEndpoint={false}
showDevConsole="auto"
renderActivityMessages={activityRenderers}
>
<div
className={`a2ui-chat-container flex h-full min-h-0 overflow-hidden ${isCanvasMode ? "layout-split" : "layout-chat"}`}
>
{isCanvasMode && canvas.content && (
<Canvas content={canvas.content} onClose={handleShowChat} />
)}
<div
className={`chat-panel flex flex-col min-h-0 overflow-hidden ${isCanvasMode ? "chat-sidebar" : "flex-1"}`}
{...(isCanvasMode ? { "data-sidebar-chat": true } : {})}
>
<Chat
isCanvasMode={isCanvasMode}
hasCanvasContent={canvas.content !== null}
onCanvasUpdate={handleCanvasUpdate}
onShowChat={handleShowChat}
onShowCanvas={handleShowCanvas}
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId="my_a2ui_agent"
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId="my_a2ui_agent"
threadId={threadId}
>
<div
className={`a2ui-chat-container flex h-full min-h-0 overflow-hidden ${isCanvasMode ? "layout-split" : "layout-chat"}`}
>
{isCanvasMode && canvas.content && (
<Canvas content={canvas.content} onClose={handleShowChat} />
)}
<div
className={`chat-panel flex flex-col min-h-0 overflow-hidden ${isCanvasMode ? "chat-sidebar" : "flex-1"}`}
{...(isCanvasMode ? { "data-sidebar-chat": true } : {})}
>
<Chat
isCanvasMode={isCanvasMode}
hasCanvasContent={canvas.content !== null}
onCanvasUpdate={handleCanvasUpdate}
onShowChat={handleShowChat}
onShowCanvas={handleShowCanvas}
/>
</div>
</div>
</CopilotChatConfigurationProvider>
</div>
</div>
</CopilotKitProvider>
@@ -0,0 +1,45 @@
# Threads Panel — Design Notes (agent-spec)
These are agent-spec'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 agent-spec's chat surface 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
agent-spec's chat surface 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 agent-spec-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}>
copilotkit add-intelligence
</code>
</div>
<button
type="button"
className={styles.lockedCta}
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</button>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,891 @@
/* Threads panel — a left-side companion to agent-spec's chat surface.
Every surface, border, radius, and type ramp here is lifted from CopilotKit's
V2 design system (@copilotkit/react-core/src/v2) so the panel reads as the
same product as the chat on the right:
- surfaces: white card on a white app; borders are the --border hairline
- radius: --radius (0.625rem) for rectangular controls; rounded-full
(999px) for icon buttons, the segmented control, and the New-thread
affordance — echoing the sidebar's close button, suggestion pills, and
send button
- type: 0.875rem base / font-medium / tracking-tight, matching the
sidebar header + pills (no heavy 700 weights)
- primary action color is the sidebar's near-black --primary, NOT a brand
accent — the V2 sidebar's primary buttons render bg-black/text-white */
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
/* Header — mirrors CopilotModalHeader: hairline bottom border, generous
px-4 py-4 rhythm, medium-weight tracking-tight title (not bold). */
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.drawerTitle {
margin: 0;
font-size: 1rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.01em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Icon button — the sidebar's close button: size-8, rounded-full, muted
foreground, hover lifts to bg-muted + foreground. */
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: 999px;
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--muted);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
/* New-thread affordance — a compact pill in the sidebar's near-black
--primary, echoing the send button (bg-black, rounded-full, medium text). */
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 2rem;
padding: 0 0.75rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition:
background-color 140ms ease,
opacity 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
}
/* Segmented control — a single muted track with a white "lifted" active tab,
the same surface relationship the suggestion pills use (bg over muted). */
.segmented {
display: inline-flex;
width: 100%;
padding: 0.1875rem;
border-radius: 999px;
background: var(--muted);
gap: 0.1875rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: 999px;
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.125rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.25rem 0.5rem 0.75rem;
}
.threadRow {
position: relative;
}
/* Thread row — a quiet hover/selected surface in the sidebar's accent gray,
--radius corners, no hairline rule between rows (the chat list is borderless
too). */
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--accent);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected,
.threadItemSelected:hover {
background: var(--accent);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* A slim left accent rail; muted by default, fills to --primary when selected
— the same charcoal that drives the primary action buttons. */
.threadAccent {
flex: none;
width: 0.1875rem;
height: 1.5rem;
border-radius: 999px;
background: transparent;
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.125rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.3;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 400;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.6875rem;
line-height: 1.3;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 400;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
/* Archived chip — a muted pill, the same surface/type as the suggestion
pills (bg muted, font-medium, tiny). */
.archivedBadge {
display: inline-block;
margin-left: 0.375rem;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--muted);
font-size: 0.625rem;
font-weight: 500;
color: var(--muted-foreground);
vertical-align: middle;
}
/* Load-more — a full-width outline pill, the sidebar's suggestion-pill
treatment (border, bg-background, hover to accent). */
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.375rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--card);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--accent);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.375rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.125rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
/* Tooltip — matches the V2 dropdown/popover surface (white card, hairline
border, soft shadow, rounded-md), not an inverted chip. */
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
/* Render below the trigger: a CSS pseudo-tooltip can't escape the drawer's
overflow containers, and "above" clips on the top row / collapsed rail. */
top: calc(100% + 0.35rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--card);
color: var(--foreground);
font-family: var(--font-body);
font-size: 0.6875rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--muted-foreground);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: color-mix(in oklch, var(--destructive) 10%, transparent);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.25rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.1875rem;
height: 1.5rem;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--muted);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
/* Empty/error card — clean white card with hairline border and the V2
radius-lg, same card vocabulary as the locked state. */
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
/* Locked state — same panel chrome (white surface, hairline right border,
header rhythm) as the unlocked drawer, with a single clean card that speaks
the V2 card vocabulary: rounded-lg, hairline border, muted icon chip, the
add-intelligence command in a muted code well, and a near-black primary CTA
pill matching the sidebar's send button. */
.lockedPanel {
display: flex;
width: 20rem;
flex-shrink: 0;
flex-direction: column;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
}
.lockedBody {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
padding: 1rem;
}
.lockedCard {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.lockedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
}
.lockedHeading {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.lockedTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.lockedDescription {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.lockedCommand {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
}
.lockedCommandCode {
font-family: var(--font-code);
font-size: 0.75rem;
white-space: nowrap;
color: var(--secondary-foreground);
}
.lockedCta {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.25rem;
padding: 0 0.875rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 140ms ease;
}
.lockedCta:hover {
opacity: 0.9;
}
.lockedCta:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
radius-xl, soft elevated shadow. */
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
color: var(--foreground);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.4rem;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1.1rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.dialogButtonSecondary {
background: var(--card);
border: 1px solid var(--border);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--accent);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--accent);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
and a fixed-width panel leaves a dead column). */
.lockedPanel {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -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&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -14,4 +14,11 @@ AWS_DEFAULT_REGION=us-east-1
# ── Agent selection ────────────────────────────────────────────────────────────
# Which agent to run locally: langgraph or strands (default: strands)
AGENT=strands
AGENT=strands
# ── CopilotKit Intelligence / Threads (optional) ──────────────────────────────
# Enables persistent Threads in the CopilotKit bridge + frontend.
COPILOTKIT_LICENSE_TOKEN=
INTELLIGENCE_API_KEY=
INTELLIGENCE_API_URL=http://localhost:4201
INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
@@ -67,6 +67,10 @@ services:
- AGENTCORE_AG_UI_URL=http://agent:8080/invocations
- PORT=3001
- OTEL_SDK_DISABLED=true
- COPILOTKIT_LICENSE_TOKEN=${COPILOTKIT_LICENSE_TOKEN}
- INTELLIGENCE_API_KEY=${INTELLIGENCE_API_KEY}
- INTELLIGENCE_API_URL=${INTELLIGENCE_API_URL:-http://localhost:4201}
- INTELLIGENCE_GATEWAY_WS_URL=${INTELLIGENCE_GATEWAY_WS_URL:-ws://localhost:4401}
depends_on:
agent:
condition: service_healthy
@@ -84,6 +88,7 @@ services:
- /app/node_modules
environment:
- NODE_ENV=development
- VITE_COPILOTKIT_THREADS_ENABLED=${COPILOTKIT_LICENSE_TOKEN:+true}
depends_on:
bridge:
condition: service_started
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,7 @@
"clean": "rm -rf build/ node_modules/ .vite/"
},
"dependencies": {
"@copilotkit/react-core": "^1.54.0",
"@copilotkit/react-core": "1.59.3",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
@@ -23,10 +23,12 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"oidc-client-ts": "^3.5.0",
"radix-ui": "^1.4.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-dropzone": "^14.3.8",
"react-is": "^19.2.7",
"react-markdown": "^10.1.0",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.21.0",
@@ -2,24 +2,40 @@
"use client";
import "@copilotkit/react-core/v2/styles.css";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
CopilotChat,
CopilotChatConfigurationProvider,
CopilotKitProvider,
useFrontendTool,
} from "@copilotkit/react-core/v2";
import { useAuth as useOidcAuth } from "react-oidc-context";
import { loadAwsConfig, type AwsExportsConfig } from "@/lib/runtime-config";
import { loadAwsConfig } from "@/lib/runtime-config";
import type { AwsExportsConfig } from "@/lib/runtime-config";
import { useExampleSuggestions } from "@/hooks/useExampleSuggestions";
import { useCopilotExamples } from "@/hooks/useCopilotExamples";
import { ThemeProvider } from "@/hooks/useTheme";
import { TodoCanvas } from "@/components/canvas/TodoCanvas";
import { ModeToggle } from "@/components/ui/mode-toggle";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
const COPILOTKIT_AGENT_ID = "default";
type ResolvedAwsExportsConfig = AwsExportsConfig & {
copilotKitRuntimeUrl: string;
};
function CopilotChatContent() {
function CopilotChatContent({
runtimeUrl,
headers,
}: {
runtimeUrl: string;
headers: Record<string, string> | undefined;
}) {
const [mode, setMode] = useState<"chat" | "app">("chat");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
useExampleSuggestions();
useCopilotExamples();
@@ -41,30 +57,77 @@ function CopilotChatContent() {
});
return (
<div className="h-full flex flex-row">
<ModeToggle mode={mode} onModeChange={setMode} />
<div
className={`max-h-full overflow-y-auto [&_.copilotKitChat]:h-full [&_.copilotKitChat]:border-0 [&_.copilotKitChat]:shadow-none ${
mode === "app" ? "w-1/3 px-6 max-lg:hidden" : "flex-1 px-4 lg:px-6"
}`}
>
<CopilotChat agentId={COPILOTKIT_AGENT_ID} className="h-full" />
</div>
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-2/3 border-l dark:border-zinc-700 max-lg:w-full max-lg:border-l-0"
: "w-0 border-l-0"
}`}
>
<div className="h-full w-full lg:w-[66.666vw]">
<TodoCanvas />
</div>
<div className={styles.layout}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId={COPILOTKIT_AGENT_ID}
threadId={threadId}
onThreadChange={setThreadId}
runtimeUrl={runtimeUrl}
headers={headers}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId={COPILOTKIT_AGENT_ID}
threadId={threadId}
>
<div className="h-full flex flex-row">
<ModeToggle mode={mode} onModeChange={setMode} />
<div
className={`max-h-full overflow-y-auto [&_.copilotKitChat]:h-full [&_.copilotKitChat]:border-0 [&_.copilotKitChat]:shadow-none ${
mode === "app"
? "w-1/3 px-6 max-lg:hidden"
: "flex-1 px-4 lg:px-6"
}`}
>
<CopilotChat agentId={COPILOTKIT_AGENT_ID} className="h-full" />
</div>
<div
className={`h-full overflow-hidden ${
mode === "app"
? "w-2/3 border-l dark:border-zinc-700 max-lg:w-full max-lg:border-l-0"
: "w-0 border-l-0"
}`}
>
<div className="h-full w-full lg:w-[66.666vw]">
<TodoCanvas />
</div>
</div>
</div>
</CopilotChatConfigurationProvider>
</div>
</div>
);
}
function CopilotKitShell({
config,
accessToken,
}: {
config: ResolvedAwsExportsConfig;
accessToken: string | undefined;
}) {
const headers = useMemo(
() =>
accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
[accessToken],
);
return (
<CopilotKitProvider
runtimeUrl={config.copilotKitRuntimeUrl}
headers={headers}
useSingleEndpoint={false}
>
<CopilotChatContent
runtimeUrl={config.copilotKitRuntimeUrl}
headers={headers}
/>
</CopilotKitProvider>
);
}
export default function CopilotChatInterface() {
const auth = useOidcAuth();
const [config, setConfig] = useState<AwsExportsConfig | null>(null);
@@ -117,14 +180,10 @@ export default function CopilotChatInterface() {
return (
<ThemeProvider>
<div className="h-full bg-[#f5f7fb]">
<CopilotKitProvider
runtimeUrl={config.copilotKitRuntimeUrl}
headers={
accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined
}
>
<CopilotChatContent />
</CopilotKitProvider>
<CopilotKitShell
config={config as ResolvedAwsExportsConfig}
accessToken={accessToken}
/>
</div>
</ThemeProvider>
);
@@ -0,0 +1,53 @@
# Threads Drawer — Theming Contract
The threads-drawer is a BASE component. It is fully driven by CSS variables and
contains no hardcoded colors, shadows, or surface radii. To theme it for an
example, (re)define the tokens below on any ancestor (e.g. `:root`, `body`, or a
wrapper element) — **never edit the drawer files**.
The drawer first consumes the shared design-system tokens (`--card`,
`--border`, `--radius`, …) that `ui/card.tsx` and `ui/button.tsx` also consume.
For a handful of drawer-specific visuals (scrim, shadows, delete-hover tint) it
exposes dedicated `--threads-*` tokens, each with a fallback to a shared token or
the original literal — so defining nothing reproduces the default look exactly.
## Shared design-system tokens consumed
| Token | Controls |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--background` | Tooltip text color (`color: var(--background)` on the dark tooltip body) |
| `--foreground` | Drawer/dialog title + body text, active segment text, tooltip surface bg |
| `--card` | Drawer surface bg (via `--threads-drawer-bg`), active segment bg, empty/dialog/load-more bg |
| `--border` | Drawer + header + filter + dialog borders, thread accent (idle), selected-row inset ring, secondary-button hover bg, tooltip border |
| `--radius` | Drawer/dialog/button/segment/thread/empty-card radii; tooltip radius derives from it |
| `--primary` | New-thread button bg, primary dialog button bg, selected thread accent |
| `--primary-foreground` | New-thread button text, primary dialog button text |
| `--secondary` | Icon-button + thread-row + load-more hover bg, segment track, archived badge bg, secondary dialog button bg, loading skeleton bars, thread-enter start bg |
| `--secondary-foreground` | (locked-state) inline code text |
| `--muted-foreground` | Icon-button idle color, segment idle text, meta text, placeholder/archived titles, empty/dialog description, load-more text, collapsed-rail icon |
| `--accent` | Selected thread-row bg |
| `--ring` | Focus-visible outline on buttons, thread items, segments, dialog buttons |
| `--destructive` | Delete-button icon color + delete-hover text |
| `--destructive-foreground` | Destructive dialog button text |
| `--font-body` | Header, segments, tooltip, empty card, and dialog typography |
(locked-state additionally uses `--secondary`, `--muted-foreground`, `--border`,
`--radius`, `--secondary-foreground`, and the `ui/card` + `ui/button` tokens via
those components.)
## Drawer-specific tokens (with fallbacks)
| Token | Controls | Fallback |
| --------------------------------- | ----------------------------------------- | --------------------------------------------------------- |
| `--threads-drawer-bg` | Drawer surface background | `var(--card)` |
| `--threads-drawer-border` | Drawer right border color | `var(--border)` |
| `--threads-drawer-shadow` | Open-drawer drop shadow | `4px 0 20px rgb(0 0 0 / 0.04)` |
| `--threads-segment-active-shadow` | Active filter-segment shadow | `0 1px 2px rgb(0 0 0 / 0.06)` |
| `--threads-delete-hover-bg` | Delete-button hover/focus background tint | `color-mix(in srgb, var(--destructive) 10%, transparent)` |
| `--threads-overlay-bg` | Confirm-dialog overlay scrim | `rgb(0 0 0 / 0.5)` |
| `--threads-dialog-shadow` | Confirm-dialog drop shadow | `0 20px 50px rgb(0 0 0 / 0.25)` |
| `--threads-tooltip-radius` | Action-button tooltip corner radius | `calc(var(--radius) - 0.45rem)` (= `0.3rem` at default) |
All fallbacks resolve to the original hardcoded values in the north-star, so an
example that defines none of the `--threads-*` tokens renders pixel-identical to
the pre-tokenization drawer.
@@ -0,0 +1,4 @@
"use client";
export { default as ThreadsDrawer } from "./threads-drawer";
export type { ThreadsDrawerProps } from "./threads-drawer";
@@ -0,0 +1,91 @@
"use client";
import * as React from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import styles from "./threads-drawer.module.css";
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
// The Threads drawer reads a client-only external store (useThreads /
// useSyncExternalStore) with no server snapshot, so it must not render during
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
if (import.meta.env.VITE_COPILOTKIT_THREADS_ENABLED === "true") {
if (!mounted) {
// SSR / first-paint placeholder: matches the open drawer's footprint +
// surface (and collapses to nothing on mobile) so the panel doesn't flash
// a bare-background column or shift the content when the drawer mounts.
return <div className={styles.drawerPlaceholder} aria-hidden />;
}
return <>{children}</>;
}
return (
<div className="flex w-80 shrink-0 flex-col items-center justify-center p-4 bg-[var(--threads-drawer-bg,var(--card))] border-r border-[var(--threads-drawer-border,var(--border))] max-lg:hidden">
<Card className="w-full">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-full bg-[var(--secondary)]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[var(--muted-foreground)]"
aria-hidden="true"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<CardTitle>Threads</CardTitle>
<CardDescription>
Threads is a licensed CopilotKit Intelligence feature. Unlock
persistent conversation history, multi-session context, and thread
management across your application.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-[var(--muted-foreground)]">
To enable Threads, add a CopilotKit Intelligence license to your
project with:
</p>
</CardContent>
<CardFooter className="flex-col items-start gap-3">
<div className="w-full rounded-[var(--radius)] border border-[var(--border)] bg-[var(--secondary)] px-3 py-2">
<code className="text-xs whitespace-nowrap text-[var(--secondary-foreground)]">
copilotkit add-intelligence
</code>
</div>
<Button
variant="default"
size="sm"
className="w-full"
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</Button>
</CardFooter>
</Card>
</div>
);
}
@@ -0,0 +1,738 @@
.layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
width: 100%;
overflow: hidden;
}
.drawer {
position: relative;
display: flex;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
transition:
width 180ms ease,
box-shadow 180ms ease;
}
.drawerOpen {
width: 18rem;
box-shadow: var(--threads-drawer-shadow, 4px 0 20px rgb(0 0 0 / 0.04));
}
.drawerClosed {
width: 3.5rem;
}
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
drawer mounts). Matches the open drawer's footprint + surface so there's no
bare-background column flash and no content shift on mount. On mobile the
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
.drawerPlaceholder {
width: 18rem;
flex-shrink: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
background: var(--threads-drawer-bg, var(--card));
border-right: 1px solid var(--threads-drawer-border, var(--border));
}
.drawerSurface {
display: flex;
flex: 1;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.drawerHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.drawerHeaderMain {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
font-family: var(--font-body);
}
.drawerTitle {
margin: 0;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--foreground);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 0;
border-radius: var(--radius);
color: var(--muted-foreground);
background: transparent;
transition:
background-color 140ms ease,
color 140ms ease;
cursor: pointer;
}
.iconButton:hover,
.iconButton:focus-visible {
background: var(--secondary);
color: var(--foreground);
}
.iconButton:focus-visible,
.threadItem:focus-visible,
.newThreadButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.newThreadButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 2.25rem;
padding: 0.5rem 0.75rem;
border: 0;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.newThreadButton:hover {
opacity: 0.9;
}
.filterBar {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
}
.segmented {
display: inline-flex;
width: 100%;
padding: 0.2rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--secondary);
gap: 0.15rem;
}
.segmentedOption {
flex: 1;
min-height: 1.75rem;
padding: 0.3rem 0.75rem;
border: 0;
border-radius: calc(var(--radius) - 0.15rem);
background: transparent;
font-family: var(--font-body);
font-size: 0.75rem;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
box-shadow 140ms ease;
}
.segmentedOption:hover {
color: var(--foreground);
}
.segmentedOption:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.segmentedOptionActive {
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-segment-active-shadow, 0 1px 2px rgb(0 0 0 / 0.06));
}
.drawerContent {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.threadList {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 0.25rem;
overflow-y: auto;
/* Reserve scrollbar space so the list doesn't shift horizontally
when the scrollbar appears during the thread-enter animation. */
scrollbar-gutter: stable;
padding: 0.75rem 0.5rem;
}
.threadRow {
position: relative;
}
.threadItem {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border: 0;
border-radius: var(--radius);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 140ms ease,
box-shadow 140ms ease,
padding-right 140ms ease;
}
.threadItem:hover,
.threadItem:focus-visible {
background: var(--secondary);
}
.threadRow:hover .threadItem,
.threadRow:focus-within .threadItem {
padding-right: 3.5rem;
}
.threadItemSelected {
background: var(--accent);
box-shadow: inset 0 0 0 1px var(--border);
}
.threadItemAnimatingIn {
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.threadAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--border);
transition: background 140ms ease;
}
.threadItemSelected .threadAccent {
background: var(--primary);
}
.threadBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.threadTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
font-weight: 600;
color: var(--foreground);
}
.threadTitlePlaceholder {
color: var(--muted-foreground);
font-weight: 500;
}
.threadTitleAnimated {
display: inline-block;
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: left center;
}
.threadMeta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7rem;
color: var(--muted-foreground);
}
.threadItemArchived .threadTitle {
color: var(--muted-foreground);
font-weight: 500;
}
.threadItemArchived .threadAccent {
opacity: 0.5;
}
.archivedBadge {
display: inline-block;
margin-left: 0.35rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
background: var(--secondary);
font-size: 0.6rem;
font-weight: 600;
color: var(--muted-foreground);
vertical-align: middle;
}
.loadMoreButton {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 2rem;
margin-top: 0.25rem;
padding: 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-size: 0.78rem;
font-weight: 600;
color: var(--muted-foreground);
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease;
}
.loadMoreButton:hover:not(:disabled) {
background: var(--secondary);
color: var(--foreground);
}
.loadMoreButton:disabled {
opacity: 0.6;
cursor: default;
}
.threadActions {
position: absolute;
right: 0.4rem;
top: 50%;
display: flex;
align-items: center;
gap: 0.15rem;
transform: translateY(-50%) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 140ms ease,
transform 140ms ease;
}
.threadRow:hover .threadActions,
.threadRow:focus-within .threadActions {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.threadActionButton {
width: 1.75rem;
height: 1.75rem;
}
.tooltip {
position: relative;
}
.tooltip::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 0.3rem);
left: 50%;
transform: translateX(-50%) translateY(-2px);
padding: 0.2rem 0.45rem;
border-radius: var(--threads-tooltip-radius, calc(var(--radius) - 0.45rem));
border: 1px solid var(--border);
background: var(--foreground);
color: var(--background);
font-family: var(--font-body);
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition:
opacity 110ms ease 200ms,
transform 110ms ease 200ms;
z-index: 20;
}
.tooltip:hover::after,
.tooltip:focus-visible::after {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.deleteButton {
color: var(--destructive);
}
.deleteButton:hover,
.deleteButton:focus-visible {
background: var(
--threads-delete-hover-bg,
color-mix(in srgb, var(--destructive) 10%, transparent)
);
color: var(--destructive);
}
.loadingList {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.2rem 0;
}
.loadingRow {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.65rem;
border-radius: var(--radius);
}
.loadingAccent {
flex: none;
width: 0.35rem;
height: 1.75rem;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.4rem;
}
.loadingTitleBar {
height: 0.6rem;
width: 60%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
}
.loadingMetaBar {
height: 0.45rem;
width: 35%;
border-radius: 999px;
background: var(--secondary);
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
animation-delay: 140ms;
}
@keyframes threadsDrawerPulse {
0%,
100% {
opacity: 0.45;
}
50% {
opacity: 0.9;
}
}
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.emptyCard {
display: flex;
max-width: 13rem;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
font-family: var(--font-body);
}
.emptyTitle {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--foreground);
}
.emptyMessage {
margin: 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--muted-foreground);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--threads-overlay-bg, rgb(0 0 0 / 0.5));
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.1rem 1.1rem 1rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 0.25rem);
background: var(--card);
color: var(--foreground);
box-shadow: var(--threads-dialog-shadow, 0 20px 50px rgb(0 0 0 / 0.25));
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1rem;
font-size: 0.82rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.dialogButtonSecondary {
background: var(--secondary);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--border);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--secondary);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.125rem;
padding: 0.1875rem;
overflow: visible;
border-radius: 999px;
background: var(--threads-drawer-bg, var(--card));
border: 1px solid var(--threads-drawer-border, var(--border));
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Trim the launcher's icon buttons so the pill's height lines up with the
top-right Chat/App toggle instead of towering over it. */
.drawerClosed .collapsedRail .iconButton {
width: 1.75rem;
height: 1.75rem;
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -0,0 +1,589 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useThreads } from "@copilotkit/react-core/v2";
import styles from "./threads-drawer.module.css";
export interface ThreadsDrawerProps {
agentId: string;
threadId: string | undefined;
onThreadChange: (threadId: string | undefined) => void;
runtimeUrl: string;
headers?: Record<string, string>;
}
interface DrawerThread {
id: string;
name: string | null;
updatedAt: string;
archived: boolean;
lastRunAt?: string;
}
const THREAD_ENTRY_ANIMATION_MS = 420;
const TITLE_ANIMATION_MS = 360;
const UNTITLED_THREAD_LABEL = "New thread";
function formatThreadTimestamp(updatedAt: string): string {
const timestamp = new Date(updatedAt);
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
function cx(...classNames: Array<string | false | undefined>): string {
return classNames.filter(Boolean).join(" ");
}
export default function ThreadsDrawer({
agentId,
threadId,
onThreadChange,
runtimeUrl,
headers,
}: ThreadsDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
// Start collapsed on narrow screens (tablet + phone) so the panel — which
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
// chat on load. The drawer is client-mounted, so reading window here is safe
// and won't cause a hydration mismatch.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.innerWidth > 1024,
);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
} | null>(null);
const deleteTriggerRef = useRef<HTMLElement | null>(null);
const {
threads,
archiveThread,
deleteThread,
error,
isLoading,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId,
includeArchived: showArchived,
limit: 20,
});
const restoreThread = useCallback(
async (id: string) => {
const response = await fetch(
`${runtimeUrl}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId, headers, runtimeUrl],
);
const hasMountedRef = useRef(false);
const hasLoadedOnceRef = useRef(false);
const stableThreadsRef = useRef<DrawerThread[]>(threads);
const previousThreadIdsRef = useRef<Set<string>>(new Set());
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
if (!isLoading) {
hasLoadedOnceRef.current = true;
stableThreadsRef.current = threads;
}
const displayThreads: DrawerThread[] =
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
const [enteringThreadIds, setEnteringThreadIds] = useState<
Record<string, true>
>({});
const [revealedTitleIds, setRevealedTitleIds] = useState<
Record<string, true>
>({});
useEffect(() => {
return () => {
for (const timeoutId of entryTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
for (const timeoutId of titleTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
// Skip diffing while the store is refetching (e.g. after a filter change
// clears the list). Otherwise every thread would be treated as newly
// added once the new page lands.
if (isLoading) return;
const nextThreadIds = new Set(threads.map((t) => t.id));
if (!hasMountedRef.current) {
hasMountedRef.current = true;
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
return;
}
const addedThreadIds = threads
.filter((t) => !previousThreadIdsRef.current.has(t.id))
.map((t) => t.id);
if (addedThreadIds.length > 0) {
setEnteringThreadIds((current) => {
const next = { ...current };
for (const id of addedThreadIds) {
next[id] = true;
const existing = entryTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setEnteringThreadIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
entryTimeoutsRef.current.delete(id);
}, THREAD_ENTRY_ANIMATION_MS);
entryTimeoutsRef.current.set(id, tid);
}
return next;
});
}
const renamedThreadIds = threads
.filter((t) => {
// Only reveal when an already-tracked thread's name transitions from
// null → named. Threads appearing for the first time (e.g. on a
// filter switch) already have their final name and should not trigger
// the title reveal animation — that would layer a blur/translateY
// onto the row's enter animation and produce a visible jitter.
if (!previousNamesRef.current.has(t.id)) return false;
const prev = previousNamesRef.current.get(t.id) ?? null;
return prev === null && t.name !== null;
})
.map((t) => t.id);
if (renamedThreadIds.length > 0) {
setRevealedTitleIds((current) => {
const next = { ...current };
for (const id of renamedThreadIds) {
next[id] = true;
const existing = titleTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setRevealedTitleIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
titleTimeoutsRef.current.delete(id);
}, TITLE_ANIMATION_MS);
titleTimeoutsRef.current.set(id, tid);
}
return next;
});
}
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
}, [threads, isLoading]);
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
if (error) {
console.error("Unable to load threads", error);
}
if (!isOpen) {
return (
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerClosed)}
>
<div className={styles.collapsedRail}>
{/* Native title here (not the styled ::after): the collapsed rail
sits at the viewport's left edge where a centered tooltip clips. */}
<button
aria-label="Open threads drawer"
title="Expand"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(true)}
>
<ChevronRight size={18} />
</button>
<button
aria-label="Create thread"
title="New thread"
className={styles.iconButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={18} />
</button>
</div>
</aside>
);
}
const closeDeleteDialog = () => {
setPendingDelete(null);
const trigger = deleteTriggerRef.current;
deleteTriggerRef.current = null;
trigger?.focus?.();
};
return (
<>
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerOpen)}
>
<div className={styles.drawerSurface}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
<div className={styles.headerActions}>
<button
aria-label="Create thread"
className={styles.newThreadButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={14} />
<span>New thread</span>
</button>
<button
aria-label="Collapse threads drawer"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(false)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
<div className={styles.filterBar}>
<div
aria-label="Thread filter"
className={styles.segmented}
role="tablist"
>
<button
aria-selected={!showArchived}
className={cx(
styles.segmentedOption,
!showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(false)}
>
Active
</button>
<button
aria-selected={showArchived}
className={cx(
styles.segmentedOption,
showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(true)}
>
All
</button>
</div>
</div>
<div className={styles.drawerContent}>
{error ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>
Couldn&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -70,6 +70,7 @@
--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);
@@ -92,6 +93,7 @@
"Geist Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
--font-geist-mono: "Geist Mono", "Courier New", Consolas, Monaco, monospace;
--font-body: var(--font-geist-sans);
}
.dark {
@@ -110,6 +112,7 @@
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
@@ -9,6 +9,13 @@ import path from "path";
export default defineConfig({
plugins: [react()],
define: {
"import.meta.env.VITE_COPILOTKIT_THREADS_ENABLED": JSON.stringify(
process.env.VITE_COPILOTKIT_THREADS_ENABLED ??
(process.env.COPILOTKIT_LICENSE_TOKEN ? "true" : "false"),
),
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
File diff suppressed because it is too large Load Diff
@@ -8,9 +8,9 @@
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@ag-ui/client": "^0.0.47",
"@ag-ui/client": "0.0.53",
"@ag-ui/mcp-apps-middleware": "^0.0.3",
"@copilotkit/runtime": "^1.54.0",
"@copilotkit/runtime": "1.59.3",
"@hono/node-server": "^1.13.7",
"hono": "^4.11.4",
"rxjs": "^7.8.2"
@@ -2,9 +2,11 @@
* Shared CopilotKit runtime — the single source of truth.
* Imported by index.ts (Lambda) and server.ts (local dev).
*/
import { EventType, HttpAgent, type BaseEvent } from "@ag-ui/client";
import { EventType, HttpAgent } from "@ag-ui/client";
import type { BaseEvent } from "@ag-ui/client";
import { MCPAppsMiddleware } from "@ag-ui/mcp-apps-middleware";
import {
CopilotKitIntelligence,
CopilotRuntime,
createCopilotEndpoint,
InMemoryAgentRunner,
@@ -126,7 +128,22 @@ export function buildApp() {
const runtime = new CopilotRuntime({
agents: { ...agents, default: defaultAgent },
runner: new AgentCoreRunner(),
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your real auth-derived user identity before any
// multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new AgentCoreRunner() }),
// --- /copilotkit:intelligence ---
});
return createCopilotEndpoint({ runtime, basePath: "/copilotkit" });
+154 -337
View File
@@ -9,9 +9,9 @@
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@ag-ui/client": "0.0.53",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -72,13 +72,13 @@
}
},
"node_modules/@ag-ui/client": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.52.tgz",
"integrity": "sha512-U407VvDDwR5qs8TiyN1qY38x87qMWc2n0epw8iA5aa1qwzCKBBDgg3Fkm4JogQf0X4jwNsz8HUbIZrBB56mrpg==",
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.52",
"@ag-ui/encoder": "0.0.52",
"@ag-ui/proto": "0.0.52",
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
@@ -89,20 +89,20 @@
}
},
"node_modules/@ag-ui/core": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.52.tgz",
"integrity": "sha512-Xo0bUaNV56EqylzcrAuhUkQX7et7+SZIrqZZtEByGwEq/I1EHny6ZMkWHLkKR7UNi0FJZwJyhKYmKJS3B2SEgA==",
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@ag-ui/encoder": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.52.tgz",
"integrity": "sha512-6GVDTb1dv2rjap7VVnmXYypDutZi6nrsTcdfxoP6ryDG5ynlXtmmS+FSDAt62JbIMD5CtEE963xNCb6d1iXw9g==",
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.52",
"@ag-ui/proto": "0.0.52"
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@ag-ui/langgraph": {
@@ -134,11 +134,11 @@
}
},
"node_modules/@ag-ui/proto": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.52.tgz",
"integrity": "sha512-+iCGzNUNL50YIoThVmsolWPjG4MJidl+R9k8QAGVwErEfHRtQ64KFyrdpeOXNVuWtM3SViJqPSgFyv7eGVS63A==",
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.52",
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
@@ -160,9 +160,9 @@
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.122",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.122.tgz",
"integrity": "sha512-U1k2fk7cSH/tS5CZ3ujROiUCOLFwkzb792OqR/Org8Mfm27dKSIdRZG4ZuJUifT8alUWa61IoaRu4foXKlP5TQ==",
"version": "3.0.124",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.124.tgz",
"integrity": "sha512-h8CrmbSG+8X0C+M/E1M4oiDHYevqwbzAPN+uLRHS0eJaatF2MZ+juNtOHXNOjk7Bsk9mD2RjYMjJO9dFkb9I7Q==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
@@ -274,9 +274,9 @@
}
},
"node_modules/@ai-sdk/mcp": {
"version": "1.0.45",
"resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-1.0.45.tgz",
"integrity": "sha512-0J+pVPu5IlBGVirY2nnLlHPuSEqnN0kd7zefL6zfBkezs6x93t0ET8OKgpGaCzEHrj/W+d7TvZ9AhwywWP7wnQ==",
"version": "1.0.46",
"resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-1.0.46.tgz",
"integrity": "sha512-owU0wAP87KzsTzr+2JE9sT9lpsCWKg8ZwHhce/KQmD9D/kbhe69sUZ+lsFF5MoGvV/ZzMujrhAvC1MgmufwMeQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.10",
@@ -431,9 +431,9 @@
"license": "Apache-2.0"
},
"node_modules/@copilotkit/a2ui-renderer": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/a2ui-renderer/-/a2ui-renderer-1.59.1.tgz",
"integrity": "sha512-jRltYlLKqwWREiS430pJ0ukpA1L7hralerVFYlsgtkMhRMf9DzSMl+Gbt48FDGt/QteS7YwsT+nZrUWanOXJXg==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/a2ui-renderer/-/a2ui-renderer-1.59.3.tgz",
"integrity": "sha512-51Ik/RLaCKDF7fA5zlV72F9vKEdO3+Udn4PxaUAx1xyz+3pq1ylFCenVH3kv8ZAX1ieLQ5FANnZoPUJvorynzg==",
"license": "MIT",
"dependencies": {
"@a2ui/web_core": "0.9.0",
@@ -447,12 +447,12 @@
}
},
"node_modules/@copilotkit/core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/core/-/core-1.59.1.tgz",
"integrity": "sha512-i1cdGADVNR/TkkZcMWGyrInBMutnHlCVxvLoujet9x8OkgMVOF/T6w9Ep7PNNY5Wwlu+i2XwGQPz5bb1/NyUdw==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/core/-/core-1.59.3.tgz",
"integrity": "sha512-RtkKoN7C2fsDPcfq2kWRlC4avAexKzV0VCu4QUGI1wSJTkF5JAFhW9Tm2GGAxCISPveUGj/B1iPOzt6O8DaPKw==",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@tanstack/pacer": "^0.20.1",
"phoenix": "^1.8.4",
"rxjs": "7.8.1",
@@ -462,50 +462,6 @@
"node": ">=18"
}
},
"node_modules/@copilotkit/core/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/core/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/core/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/core/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/license-verifier": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@copilotkit/license-verifier/-/license-verifier-0.4.2.tgz",
@@ -513,18 +469,18 @@
"license": "MIT"
},
"node_modules/@copilotkit/react-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.59.1.tgz",
"integrity": "sha512-vteM29uKKhN1IfS9iKIMDw28IUGMSuUJd3eB6gAJhE2lI4GUr0K02vytUclqHKxR1/vUZpsvqvPJuDLCFO4ujw==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.59.3.tgz",
"integrity": "sha512-4S36QZAQAsygKPocdmSeM59hNQVzDcHoBEMN+xVgHtqf9stmILdr0CH161zT6hP+kwIg/MOYDdvj4bVZU61lyA==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@ag-ui/core": "0.0.53",
"@copilotkit/a2ui-renderer": "1.59.1",
"@copilotkit/core": "1.59.1",
"@copilotkit/runtime-client-gql": "1.59.1",
"@copilotkit/shared": "1.59.1",
"@copilotkit/web-inspector": "1.59.1",
"@copilotkit/a2ui-renderer": "1.59.3",
"@copilotkit/core": "1.59.3",
"@copilotkit/runtime-client-gql": "1.59.3",
"@copilotkit/shared": "1.59.3",
"@copilotkit/web-inspector": "1.59.3",
"@jetbrains/websandbox": "^1.1.3",
"@lit-labs/react": "^2.0.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -551,54 +507,10 @@
"zod": ">=3.0.0"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/react-core/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/runtime": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime/-/runtime-1.59.1.tgz",
"integrity": "sha512-0Rl+8i2xj3dGiy+NG6tnItg0wGgXxOnB8BVkMsHC6PxRQp7am3PqUNrOQTFI67i7bvXfEPNdZINm97djzOvVSQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime/-/runtime-1.59.3.tgz",
"integrity": "sha512-ZWj9VQUUBup7T2N8A+0tj7h3ZK2LW1O+agtVdcYjxLN3RCk0L2a/TuYxvmVXrxUBZwAahgU9ZUgc2QIoo9LmQg==",
"license": "MIT",
"dependencies": {
"@ag-ui/a2ui-middleware": "0.0.5",
@@ -613,7 +525,7 @@
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/openai": "^3.0.36",
"@copilotkit/license-verifier": "~0.4.2",
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@graphql-yoga/plugin-defer-stream": "^3.3.1",
"@hono/node-server": "^1.13.5",
"@modelcontextprotocol/sdk": "^1.18.2",
@@ -685,12 +597,12 @@
}
},
"node_modules/@copilotkit/runtime-client-gql": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime-client-gql/-/runtime-client-gql-1.59.1.tgz",
"integrity": "sha512-JLBDy5Y37LiZhKWIwSm4ETQBOzy3vHDUvbvjtxDzF6kxsddswyXEMtjQUEc93RDwDCh1XuH2FLnd1ha48kmOLQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime-client-gql/-/runtime-client-gql-1.59.3.tgz",
"integrity": "sha512-TS+VJTxXxC+9shUf5VsTPpf+8zny1spUCHaX66m96paa+tGoFCmKscpT+Fgo/IQY7/7XzuJ6R4HWDm5MyiVd9g==",
"license": "MIT",
"dependencies": {
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@urql/core": "^5.0.3",
"untruncate-json": "^0.0.1",
"urql": "^4.1.0"
@@ -699,63 +611,6 @@
"react": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/client/node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/runtime/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/runtime/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
@@ -771,9 +626,9 @@
}
},
"node_modules/@copilotkit/shared": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.1.tgz",
"integrity": "sha512-vCJB4bao5Tu54mc7D/h/vLfUZsDECl/kgW/GVli2soMzIJW6UAHFhtt2D9MN206xDtE6gXqBnBIMKNG+JO61SQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.3.tgz",
"integrity": "sha512-Q2FXKha5y/PkWe9xPLWRSi28iMDNVG3lMvARwKOA5esvaf1gsxBr5B6pKdtWOOoy/03Yk/hmAEFxlhBE56vi7g==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
@@ -791,73 +646,13 @@
"@ag-ui/core": ">=0.0.48"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/client/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/encoder/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@copilotkit/shared/node_modules/@ag-ui/proto/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/web-inspector": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/web-inspector/-/web-inspector-1.59.1.tgz",
"integrity": "sha512-vA+xhTiXLh7x0IKP84O7neAAZfx6FYzzwGAr1lVPBLDABGb8Ax2ZuSGywMEEtAiu9r5ravPTN+oT71yRNjD4yA==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/web-inspector/-/web-inspector-1.59.3.tgz",
"integrity": "sha512-6V+Z3xQO0tWVOA1rGOlfDr6iD7TuAwSLYrGxlkv6p5LJ1iwXJQFpidcDPiqPvGWCdHOQBAHcvrI0oedayzhWwQ==",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@copilotkit/core": "1.59.1",
"@copilotkit/core": "1.59.3",
"lit": "^3.2.0",
"lucide": "^0.525.0",
"marked": "^12.0.2"
@@ -866,50 +661,6 @@
"node": ">=18"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@copilotkit/web-inspector/node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -1819,9 +1570,9 @@
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.9.13",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.13.tgz",
"integrity": "sha512-c3yq7kfGncMxJT9YCua5KGytTQbEyZhhjt6y3wSFHvuhNXGTJV5kBctK6jh1xFrWKwjFcdqRtVBE8pqz1GiSrA==",
"version": "1.9.15",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.15.tgz",
"integrity": "sha512-QUW9c7ikycmTkpr/eQYkKA4gpriqizG1OJ6H2YLbDOVkEYLSFnJeTO4MG6pBBO8EqHHa8m1zavKi5ug259VSQQ==",
"license": "MIT",
"dependencies": {
"@langchain/protocol": "^0.0.16",
@@ -3543,6 +3294,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
@@ -4195,12 +4012,12 @@
}
},
"node_modules/ai": {
"version": "6.0.194",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.194.tgz",
"integrity": "sha512-0MkYqrSZZuC1zTECppcaUT0i54aocXpYaUMVue3V8z/weBHCytfO5/CcwZCU80msZpfkbBUKYSSrkZFotEO5wQ==",
"version": "6.0.196",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.196.tgz",
"integrity": "sha512-2T45UeqKL4a11KQ14I5i1YYHOvCFrMF478E1k6PVjlQSGUvXSv4xrxIaQbUL4qgv91DADSbddwv3oR49pPAK3g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.122",
"@ai-sdk/gateway": "3.0.124",
"@ai-sdk/provider": "3.0.10",
"@ai-sdk/provider-utils": "4.0.27",
"@opentelemetry/api": "^1.9.0"
@@ -5445,9 +5262,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5517,9 +5334,9 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.22.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
"version": "5.22.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz",
"integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5807,15 +5624,6 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fetch-blob/node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -7423,9 +7231,9 @@
}
},
"node_modules/langsmith": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.7.3.tgz",
"integrity": "sha512-Gg+IeRGoF1/aStu80aEnIXJCu+N6+4NoV4tAVFS51ZPRBsRa2KG0LkS7K7/ryZ/yES7O9xdqah5QuuWMIeMjQw==",
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.7.4.tgz",
"integrity": "sha512-EGYCw85etSarYazeTgj8DICVIFg+26gsVZ0zq8V7kjIb59huURJpZZJqVFkvRpZFxmfyYrpIhtk2qtHgGR8K+w==",
"license": "MIT",
"dependencies": {
"p-queue": "6.6.2"
@@ -7462,9 +7270,9 @@
"license": "MIT"
},
"node_modules/libphonenumber-js": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.4.tgz",
"integrity": "sha512-/lhWr7vq8foWN9Apksnd9v8/cfwzW6g6qKOCo25XBGkNaVCHucXO57hLy4CWHGvytvLz6Nt3J5Gs8p3jlCGFXA==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.5.tgz",
"integrity": "sha512-7/kRezHmQlMfO6pmvt34orO/g3j1C47k8FCBXFgj/mklTLwQdBca1LkhDK6RM8UyM6JqHFAIikMdkKkyfQy39A==",
"license": "MIT"
},
"node_modules/lightningcss": {
@@ -15077,9 +14885,9 @@
}
},
"node_modules/openai": {
"version": "6.41.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.41.0.tgz",
"integrity": "sha512-IGWPopZq6Rjoynjfb3NSLf/z2MTw7UiOsm9TAjPGAjUESH7Uq41Trg4QWehBEn58p74i+m7uoRPV2vXcpPXhyA==",
"version": "6.42.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz",
"integrity": "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==",
"license": "Apache-2.0",
"peerDependencies": {
"ws": "^8.18.0",
@@ -18587,6 +18395,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+3 -3
View File
@@ -12,9 +12,9 @@
"postinstall": "npm run install:agent"
},
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@ag-ui/client": "0.0.53",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"@hono/node-server": "^1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -9,8 +9,10 @@ import { handle } from "hono/vercel";
const runtime = new CopilotRuntime({
agents: {
agno_agent: new HttpAgent({
url: (process.env.AGENT_URL || "http://localhost:8000") + "/agui",
default: new HttpAgent({
url:
(process.env.AGENT_URL || "http://localhost:8000").replace(/\/$/, "") +
"/agui",
}),
},
// --- copilotkit:intelligence (remove this block to opt out) ---
@@ -5,8 +5,8 @@ import "./globals.css";
import "@copilotkit/react-core/v2/styles.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Agno + CopilotKit Starter",
description: "A starter demo connecting an Agno agent to CopilotKit.",
};
export default function RootLayout({
@@ -20,7 +20,6 @@ export default function RootLayout({
{/* Force REST transport so runtime-info + threads both hit the multi-route endpoint (auto-detect races the lazily-compiled API route in next dev). */}
<CopilotKit
runtimeUrl="/api/copilotkit"
agent="agno_agent"
useSingleEndpoint={false}
>
{children}
+71 -38
View File
@@ -1,13 +1,15 @@
"use client";
import {
useAgent,
useConfigureSuggestions,
useDefaultRenderTool,
useFrontendTool,
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { z } from "zod";
import { DefaultToolComponent } from "@/components/default-tool-ui";
import { WeatherCard } from "@/components/weather";
@@ -15,11 +17,10 @@ import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
// agno registers a single agent under the key "agno_agent" (see
// src/app/api/copilotkit/[[...slug]]/route.ts) and CopilotKit is mounted with
// agent="agno_agent" in layout.tsx, so the threads drawer + chat config provider
// must address that same agent id.
const AGENT_ID = "agno_agent";
// agno registers a single agent under the key "default" (see
// src/app/api/copilotkit/[[...slug]]/route.ts), so the threads drawer + chat
// config provider must address that same agent id.
const AGENT_ID = "default";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
@@ -33,11 +34,35 @@ export default function CopilotKitPage() {
.string()
.describe("The theme color to set. Make sure to pick nice colors."),
}),
handler({ theme_color }) {
handler: async ({ theme_color }) => {
setThemeColor(theme_color);
return `Changing theme color to ${theme_color}`;
},
});
// 🪁 Suggestions: https://docs.copilotkit.ai/guides/suggestions
useConfigureSuggestions({
available: "always",
suggestions: [
{
title: "Generative UI",
message: "What's the weather in San Francisco?",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Default Tool Rendering",
message: "What's the latest price of Apple stock?",
},
{
title: "Writing Agent State",
message: "Add a proverb about AI.",
},
],
});
return (
<div className={styles.layout}>
{/* Bespoke threads panel, themed in globals.css to match agno's chat. */}
@@ -66,28 +91,10 @@ export default function CopilotKitPage() {
defaultOpen={true}
// Adds an initial message to the chat
labels={{
title: "Popup Assistant",
initial: "👋 Hi, there! You're chatting with an Agno agent.",
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
"👋 Hi, there! You're chatting with an Agno agent.",
}}
// Suggestions for guiding users
suggestions={[
{
title: "Generative UI",
message: "What's the weather in San Francisco?",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Default Tool Rendering",
message: "What's the latest price of Apple stock?",
},
{
title: "Writing Agent State",
message: "Add a proverb about AI.",
},
]}
/>
{/* CopilotSidebar self-docks; main content renders as a sibling. */}
</main>
@@ -96,12 +103,28 @@ export default function CopilotKitPage() {
);
}
// State of the agent, make sure this aligns with your agent's state.
type AgentState = {
proverbs: string[];
};
function YourMainContent({ themeColor }: { themeColor: string }) {
const [state, setState] = useState<{ proverbs: string[] }>({
proverbs: [
"CopilotKit may be new, but its the best thing since sliced bread.",
],
});
// 🪁 Shared State: https://docs.copilotkit.ai/coagents/shared-state
// V2: useAgent returns the agent; read agent.state and write via agent.setState.
const { agent } = useAgent({ agentId: "default" });
const state = (agent.state as AgentState | undefined) ?? { proverbs: [] };
const setState = (next: AgentState) => agent.setState(next);
// Seed an initial proverb once (the V2 agent starts with empty state).
useEffect(() => {
if ((agent.state as AgentState | undefined)?.proverbs === undefined) {
agent.setState({
proverbs: [
"CopilotKit may be new, but it's the best thing since sliced bread.",
],
});
}
}, [agent]);
// 🪁 Frontend Actions: https://docs.copilotkit.ai/agno/frontend-tools
useFrontendTool({
@@ -111,11 +134,16 @@ function YourMainContent({ themeColor }: { themeColor: string }) {
.string()
.describe("The proverb to add. Make it witty, short and concise."),
}),
handler: ({ proverb }) => {
setState({
...state,
proverbs: [...state.proverbs, proverb],
handler: async ({ proverb }) => {
// Read agent.state at call time so rapid successive adds don't drop
// earlier proverbs via a stale closure over `state`.
agent.setState({
proverbs: [
...((agent.state as AgentState | undefined)?.proverbs ?? []),
proverb,
],
});
return `Added proverb: ${proverb}`;
},
});
@@ -123,7 +151,12 @@ function YourMainContent({ themeColor }: { themeColor: string }) {
useRenderTool(
{
name: "get_weather",
render: (props) => <WeatherCard themeColor={themeColor} {...props} />,
parameters: z.object({
location: z.string(),
}),
render: ({ parameters }) => (
<WeatherCard themeColor={themeColor} location={parameters.location} />
),
},
[themeColor],
);
@@ -88,7 +88,7 @@ export function DefaultToolComponent({
</div>
{/* Arguments */}
{parameters &&
{!!parameters &&
typeof parameters === "object" &&
Object.keys(parameters as Record<string, unknown>).length > 0 && (
<div className="mb-3">
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
@@ -1,5 +1,5 @@
crewai==0.130.0
ag-ui-crewai==0.1.5
ag-ui-crewai>=0.2.0,<0.3.0
ag-ui-protocol>=0.1.10
python-dotenv==1.0.1
uvicorn==0.34.3
+116 -50
View File
@@ -10,8 +10,8 @@
"hasInstallScript": true,
"dependencies": {
"@ag-ui/crewai": "^0.0.2",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4",
@@ -400,9 +400,9 @@
"license": "Apache-2.0"
},
"node_modules/@copilotkit/a2ui-renderer": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/a2ui-renderer/-/a2ui-renderer-1.59.1.tgz",
"integrity": "sha512-jRltYlLKqwWREiS430pJ0ukpA1L7hralerVFYlsgtkMhRMf9DzSMl+Gbt48FDGt/QteS7YwsT+nZrUWanOXJXg==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/a2ui-renderer/-/a2ui-renderer-1.59.3.tgz",
"integrity": "sha512-51Ik/RLaCKDF7fA5zlV72F9vKEdO3+Udn4PxaUAx1xyz+3pq1ylFCenVH3kv8ZAX1ieLQ5FANnZoPUJvorynzg==",
"license": "MIT",
"dependencies": {
"@a2ui/web_core": "0.9.0",
@@ -416,12 +416,12 @@
}
},
"node_modules/@copilotkit/core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/core/-/core-1.59.1.tgz",
"integrity": "sha512-i1cdGADVNR/TkkZcMWGyrInBMutnHlCVxvLoujet9x8OkgMVOF/T6w9Ep7PNNY5Wwlu+i2XwGQPz5bb1/NyUdw==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/core/-/core-1.59.3.tgz",
"integrity": "sha512-RtkKoN7C2fsDPcfq2kWRlC4avAexKzV0VCu4QUGI1wSJTkF5JAFhW9Tm2GGAxCISPveUGj/B1iPOzt6O8DaPKw==",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@tanstack/pacer": "^0.20.1",
"phoenix": "^1.8.4",
"rxjs": "7.8.1",
@@ -501,9 +501,9 @@
}
},
"node_modules/@copilotkit/core/node_modules/@copilotkit/shared": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.1.tgz",
"integrity": "sha512-vCJB4bao5Tu54mc7D/h/vLfUZsDECl/kgW/GVli2soMzIJW6UAHFhtt2D9MN206xDtE6gXqBnBIMKNG+JO61SQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.3.tgz",
"integrity": "sha512-Q2FXKha5y/PkWe9xPLWRSi28iMDNVG3lMvARwKOA5esvaf1gsxBr5B6pKdtWOOoy/03Yk/hmAEFxlhBE56vi7g==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
@@ -528,18 +528,18 @@
"license": "MIT"
},
"node_modules/@copilotkit/react-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.59.1.tgz",
"integrity": "sha512-vteM29uKKhN1IfS9iKIMDw28IUGMSuUJd3eB6gAJhE2lI4GUr0K02vytUclqHKxR1/vUZpsvqvPJuDLCFO4ujw==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/react-core/-/react-core-1.59.3.tgz",
"integrity": "sha512-4S36QZAQAsygKPocdmSeM59hNQVzDcHoBEMN+xVgHtqf9stmILdr0CH161zT6hP+kwIg/MOYDdvj4bVZU61lyA==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@ag-ui/core": "0.0.53",
"@copilotkit/a2ui-renderer": "1.59.1",
"@copilotkit/core": "1.59.1",
"@copilotkit/runtime-client-gql": "1.59.1",
"@copilotkit/shared": "1.59.1",
"@copilotkit/web-inspector": "1.59.1",
"@copilotkit/a2ui-renderer": "1.59.3",
"@copilotkit/core": "1.59.3",
"@copilotkit/runtime-client-gql": "1.59.3",
"@copilotkit/shared": "1.59.3",
"@copilotkit/web-inspector": "1.59.3",
"@jetbrains/websandbox": "^1.1.3",
"@lit-labs/react": "^2.0.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -611,9 +611,9 @@
}
},
"node_modules/@copilotkit/react-core/node_modules/@copilotkit/shared": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.1.tgz",
"integrity": "sha512-vCJB4bao5Tu54mc7D/h/vLfUZsDECl/kgW/GVli2soMzIJW6UAHFhtt2D9MN206xDtE6gXqBnBIMKNG+JO61SQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.3.tgz",
"integrity": "sha512-Q2FXKha5y/PkWe9xPLWRSi28iMDNVG3lMvARwKOA5esvaf1gsxBr5B6pKdtWOOoy/03Yk/hmAEFxlhBE56vi7g==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
@@ -632,9 +632,9 @@
}
},
"node_modules/@copilotkit/runtime": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime/-/runtime-1.59.1.tgz",
"integrity": "sha512-0Rl+8i2xj3dGiy+NG6tnItg0wGgXxOnB8BVkMsHC6PxRQp7am3PqUNrOQTFI67i7bvXfEPNdZINm97djzOvVSQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime/-/runtime-1.59.3.tgz",
"integrity": "sha512-ZWj9VQUUBup7T2N8A+0tj7h3ZK2LW1O+agtVdcYjxLN3RCk0L2a/TuYxvmVXrxUBZwAahgU9ZUgc2QIoo9LmQg==",
"license": "MIT",
"dependencies": {
"@ag-ui/a2ui-middleware": "0.0.5",
@@ -649,7 +649,7 @@
"@ai-sdk/mcp": "^1.0.21",
"@ai-sdk/openai": "^3.0.36",
"@copilotkit/license-verifier": "~0.4.2",
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@graphql-yoga/plugin-defer-stream": "^3.3.1",
"@hono/node-server": "^1.13.5",
"@modelcontextprotocol/sdk": "^1.18.2",
@@ -721,12 +721,12 @@
}
},
"node_modules/@copilotkit/runtime-client-gql": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime-client-gql/-/runtime-client-gql-1.59.1.tgz",
"integrity": "sha512-JLBDy5Y37LiZhKWIwSm4ETQBOzy3vHDUvbvjtxDzF6kxsddswyXEMtjQUEc93RDwDCh1XuH2FLnd1ha48kmOLQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/runtime-client-gql/-/runtime-client-gql-1.59.3.tgz",
"integrity": "sha512-TS+VJTxXxC+9shUf5VsTPpf+8zny1spUCHaX66m96paa+tGoFCmKscpT+Fgo/IQY7/7XzuJ6R4HWDm5MyiVd9g==",
"license": "MIT",
"dependencies": {
"@copilotkit/shared": "1.59.1",
"@copilotkit/shared": "1.59.3",
"@urql/core": "^5.0.3",
"untruncate-json": "^0.0.1",
"urql": "^4.1.0"
@@ -805,9 +805,9 @@
}
},
"node_modules/@copilotkit/runtime-client-gql/node_modules/@copilotkit/shared": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.1.tgz",
"integrity": "sha512-vCJB4bao5Tu54mc7D/h/vLfUZsDECl/kgW/GVli2soMzIJW6UAHFhtt2D9MN206xDtE6gXqBnBIMKNG+JO61SQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.3.tgz",
"integrity": "sha512-Q2FXKha5y/PkWe9xPLWRSi28iMDNVG3lMvARwKOA5esvaf1gsxBr5B6pKdtWOOoy/03Yk/hmAEFxlhBE56vi7g==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
@@ -923,9 +923,9 @@
}
},
"node_modules/@copilotkit/runtime/node_modules/@copilotkit/shared": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.1.tgz",
"integrity": "sha512-vCJB4bao5Tu54mc7D/h/vLfUZsDECl/kgW/GVli2soMzIJW6UAHFhtt2D9MN206xDtE6gXqBnBIMKNG+JO61SQ==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/shared/-/shared-1.59.3.tgz",
"integrity": "sha512-Q2FXKha5y/PkWe9xPLWRSi28iMDNVG3lMvARwKOA5esvaf1gsxBr5B6pKdtWOOoy/03Yk/hmAEFxlhBE56vi7g==",
"license": "MIT",
"dependencies": {
"@ag-ui/client": "0.0.53",
@@ -971,12 +971,12 @@
}
},
"node_modules/@copilotkit/web-inspector": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@copilotkit/web-inspector/-/web-inspector-1.59.1.tgz",
"integrity": "sha512-vA+xhTiXLh7x0IKP84O7neAAZfx6FYzzwGAr1lVPBLDABGb8Ax2ZuSGywMEEtAiu9r5ravPTN+oT71yRNjD4yA==",
"version": "1.59.3",
"resolved": "https://registry.npmjs.org/@copilotkit/web-inspector/-/web-inspector-1.59.3.tgz",
"integrity": "sha512-6V+Z3xQO0tWVOA1rGOlfDr6iD7TuAwSLYrGxlkv6p5LJ1iwXJQFpidcDPiqPvGWCdHOQBAHcvrI0oedayzhWwQ==",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@copilotkit/core": "1.59.1",
"@copilotkit/core": "1.59.3",
"lit": "^3.2.0",
"lucide": "^0.525.0",
"marked": "^12.0.2"
@@ -1938,9 +1938,9 @@
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.9.13",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.13.tgz",
"integrity": "sha512-c3yq7kfGncMxJT9YCua5KGytTQbEyZhhjt6y3wSFHvuhNXGTJV5kBctK6jh1xFrWKwjFcdqRtVBE8pqz1GiSrA==",
"version": "1.9.15",
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.15.tgz",
"integrity": "sha512-QUW9c7ikycmTkpr/eQYkKA4gpriqizG1OJ6H2YLbDOVkEYLSFnJeTO4MG6pBBO8EqHHa8m1zavKi5ug259VSQQ==",
"license": "MIT",
"dependencies": {
"@langchain/protocol": "^0.0.16",
@@ -3662,6 +3662,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
@@ -15183,9 +15249,9 @@
}
},
"node_modules/openai": {
"version": "6.41.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.41.0.tgz",
"integrity": "sha512-IGWPopZq6Rjoynjfb3NSLf/z2MTw7UiOsm9TAjPGAjUESH7Uq41Trg4QWehBEn58p74i+m7uoRPV2vXcpPXhyA==",
"version": "6.42.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.42.0.tgz",
"integrity": "sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==",
"license": "Apache-2.0",
"peerDependencies": {
"ws": "^8.18.0",
@@ -16838,9 +16904,9 @@
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -14,8 +14,8 @@
},
"dependencies": {
"@ag-ui/crewai": "^0.0.2",
"@copilotkit/react-core": "1.59.1",
"@copilotkit/runtime": "1.59.1",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4",
@@ -14,9 +14,7 @@ type StaticAgents = Extract<RuntimeOptions["agents"], Record<string, unknown>>;
// integration to setup the connection.
const runtime = new CopilotRuntime({
agents: {
// @ag-ui/crewai's agent is runtime-compatible with CopilotRuntime, but its
// current types do not structurally extend the runtime's AbstractAgent.
starterAgent: new CrewAIAgent({
default: new CrewAIAgent({
url: process.env.AGENT_URL || "http://localhost:8000/",
}),
} as unknown as StaticAgents,
@@ -5,8 +5,8 @@ import "./globals.css";
import "@copilotkit/react-core/v2/styles.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "CrewAI Crews + CopilotKit Starter",
description: "A starter demo connecting a CrewAI crew to CopilotKit.",
};
export default function RootLayout({
@@ -17,15 +17,8 @@ export default function RootLayout({
return (
<html lang="en">
<body className={"antialiased"}>
{/* Force REST (path-based) transport so runtime-info and the threads
REST API both hit the multi-route endpoint. Auto-detect probes
GET /info first, which races the lazily-compiled API route in
`next dev` and can fall back to single-route (no threads support). */}
<CopilotKit
runtimeUrl="/api/copilotkit"
agent="starterAgent"
useSingleEndpoint={false}
>
{/* Force REST transport so runtime-info + threads both hit the multi-route endpoint (auto-detect races the lazily-compiled API route in next dev). */}
<CopilotKit runtimeUrl="/api/copilotkit" useSingleEndpoint={false}>
{children}
</CopilotKit>
</body>
@@ -2,44 +2,64 @@
import {
useAgent,
useFrontendTool,
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import type { CSSProperties } from "react";
import { useState } from "react";
import { CSSProperties, useEffect, useState } from "react";
import { z } from "zod";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
const agentId = "starterAgent";
// The agent key registered in the runtime route (`agents: { default: ... }`).
const AGENT_ID = "default";
export default function CopilotKitPage() {
const [themeColor] = useState("#6366f1");
const [themeColor, setThemeColor] = useState("#6366f1");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
// 🪁 Frontend Tools: https://docs.copilotkit.ai/guides/frontend-actions
useFrontendTool({
name: "setThemeColor",
parameters: z.object({
themeColor: z
.string()
.describe("The theme color to set. Make sure to pick nice colors."),
}),
handler: async ({ themeColor }) => {
setThemeColor(themeColor);
return `Changing background to ${themeColor}`;
},
});
return (
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId={agentId}
agentId={AGENT_ID}
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider agentId={agentId} threadId={threadId}>
<CopilotChatConfigurationProvider
agentId={AGENT_ID}
threadId={threadId}
>
<main
style={
{
"--copilot-kit-primary-color": themeColor,
} as CSSProperties
{ "--copilot-kit-primary-color": themeColor } as CSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
clickOutsideToClose={false}
defaultOpen={true}
labels={{
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
'👋 Hi, there! You\'re chatting with an agent. This agent comes with a few tools to get you started.\n\nFor example you can try:\n- **Frontend Tools**: "Set the theme to orange"\n- **Shared State**: "Write a proverb about AI"\n- **Generative UI**: "Get the weather in SF"\n\nAs you interact with the agent, you\'ll see the UI update in real-time to reflect the agent\'s **state**, **tool calls**, and **progress**.',
}}
@@ -51,9 +71,63 @@ export default function CopilotKitPage() {
);
}
// State of the agent, make sure this aligns with your agent's state.
type AgentState = {
proverbs: string[];
};
function YourMainContent({ themeColor }: { themeColor: string }) {
// 🪁 Shared State: https://docs.copilotkit.ai/coagents/shared-state
// V2: useAgent returns the agent; read agent.state and write via agent.setState.
const { agent } = useAgent({
agentId,
agentId: AGENT_ID,
});
const state = (agent.state as AgentState | undefined) ?? { proverbs: [] };
// Seed an initial proverb once (the V2 agent starts with empty state).
useEffect(() => {
if ((agent.state as AgentState | undefined)?.proverbs === undefined) {
agent.setState({
proverbs: [
"CopilotKit may be new, but it's the best thing since sliced bread.",
],
});
}
}, [agent]);
// 🪁 Frontend Tools: https://docs.copilotkit.ai/coagents/frontend-actions
useFrontendTool(
{
name: "updateProverb",
parameters: z.object({
proverbs: z
.array(z.string())
.describe(
"The proverbs to be committed into state. Make them witty, short and concise.",
),
}),
handler: async ({ proverbs }) => {
agent.setState({
...state,
proverbs: [...proverbs],
});
return `Updated proverbs`;
},
},
[state],
);
//🪁 Generative UI: https://docs.copilotkit.ai/coagents/generative-ui
useRenderTool({
name: "get_weather",
parameters: z.object({
location: z.string(),
}),
render: ({ parameters }) => {
return (
<WeatherCard location={parameters.location} themeColor={themeColor} />
);
},
});
return (
@@ -70,15 +144,104 @@ function YourMainContent({ themeColor }: { themeColor: string }) {
</p>
<hr className="border-white/20 my-6" />
<div className="flex flex-col gap-3">
{agent.state?.proverbs?.map((proverb: string, index: number) => (
{state.proverbs?.map((proverb, index) => (
<div
key={index}
className="bg-white/15 p-4 rounded-xl text-white relative group hover:bg-white/20 transition-all"
>
<p className="pr-8">{proverb}</p>
<button
onClick={() =>
agent.setState({
...state,
proverbs: state.proverbs?.filter(
(_: string, i: number) => i !== index,
),
})
}
className="absolute right-3 top-3 opacity-0 group-hover:opacity-100 transition-opacity
bg-red-500 hover:bg-red-600 text-white rounded-full h-6 w-6 flex items-center justify-center"
>
✕
</button>
</div>
))}
</div>
{state.proverbs?.length === 0 && (
<p className="text-center text-white/80 italic my-8">
No proverbs yet. Ask the assistant to add some!
</p>
)}
</div>
</div>
);
}
// Simple sun icon for the weather card
function SunIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-14 h-14 text-yellow-200"
>
<circle cx="12" cy="12" r="5" />
<path
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
strokeWidth="2"
stroke="currentColor"
/>
</svg>
);
}
// Weather card component where the location and themeColor are based on what the agent
// sets via tool calls.
function WeatherCard({
location,
themeColor,
}: {
location?: string;
themeColor: string;
}) {
return (
<div
style={{ backgroundColor: themeColor }}
className="rounded-xl shadow-xl mt-6 mb-4 max-w-md w-full"
>
<div className="bg-white/20 p-4 w-full">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-bold text-white capitalize">
{location}
</h3>
<p className="text-white">Current Weather</p>
</div>
<SunIcon />
</div>
<div className="mt-4 flex items-end justify-between">
<div className="text-3xl font-bold text-white">70°</div>
<div className="text-sm text-white">Clear skies</div>
</div>
<div className="mt-4 pt-4 border-t border-white">
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<p className="text-white text-xs">Humidity</p>
<p className="text-white font-medium">45%</p>
</div>
<div>
<p className="text-white text-xs">Wind</p>
<p className="text-white font-medium">5 mph</p>
</div>
<div>
<p className="text-white text-xs">Feels Like</p>
<p className="text-white font-medium">72°</p>
</div>
</div>
</div>
</div>
</div>
);
@@ -0,0 +1,7 @@
OPENAI_API_KEY=
# --- copilotkit:intelligence (optional local threads stack) ---
# COPILOTKIT_LICENSE_TOKEN=
# INTELLIGENCE_API_KEY=
# INTELLIGENCE_API_URL=http://localhost:4201
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
@@ -39,4 +40,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
@@ -1,7 +1,25 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
serverExternalPackages: ["@copilotkit/runtime"],
env: {
// The public Threads UI flag is DERIVED from the server-side license token.
// Set COPILOTKIT_LICENSE_TOKEN (only) to enable Threads — do not set this flag
// directly. NOTE: NEXT_PUBLIC_* resolves at BUILD time while the runtime reads
// the token per-request, so the UI gate and runtime agree only when the token is
// present at build time (the standard `next dev` / host-build flow). For a
// standalone/Docker image built without the token and injected at runtime, set
// COPILOTKIT_LICENSE_TOKEN at build time too (or gate the UI at runtime) so the
// baked flag reflects it.
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
? "true"
: "false",
},
typescript: {
// HttpAgent type mismatch with CopilotRuntime — pending upstream fix in @copilotkit/runtime
ignoreBuildErrors: true,
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -12,13 +12,17 @@
"postinstall": "npm run install:agent"
},
"dependencies": {
"@ag-ui/client": "0.0.52",
"@copilotkit/react-core": "1.55.2",
"@copilotkit/runtime": "1.55.2",
"@ag-ui/client": "0.0.53",
"@copilotkit/react-core": "1.59.3",
"@copilotkit/runtime": "1.59.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hono": "^4",
"lucide-react": "^0.525.0",
"next": "16.0.8",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"tailwind-merge": "^3.5.0",
"zod": "^3.24.4"
},
"devDependencies": {
@@ -1,5 +1,6 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
@@ -10,9 +11,29 @@ import { handle } from "hono/vercel";
// integration to setup the connection.
const runtime = new CopilotRuntime({
agents: {
sample_agent: new HttpAgent({ url: "http://localhost:8000/" }),
default: new HttpAgent({
url: (process.env.AGENT_URL || "http://localhost:8000").replace(
/\/$/,
"",
),
}),
},
runner: new InMemoryAgentRunner(),
// --- 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 ---
});
// 2. Build a Hono app that handles the CopilotKit runtime requests.
@@ -23,3 +44,5 @@ const app = createCopilotEndpoint({
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
@@ -3,6 +3,47 @@
:root {
--background: #ffffff;
--foreground: #171717;
/* Threads-panel theme-map — values pulled verbatim from CopilotKit's V2
design system (the same one the CopilotSidebar renders with;
see @copilotkit/react-core/src/v2/styles/globals.css). The drawer is a
left-side companion to that LIGHT sidebar, so it inherits the sidebar's
exact neutral-gray palette, charcoal "primary" (NOT a brand accent — the
sidebar's primary buttons are near-black), and 0.625rem radius scale.
This keeps the threads panel reading as one product with the chat.
See ./../components/threads-drawer/THEME.md for the full token contract. */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
/* Match the V2 sidebar's radius scale exactly (--radius: 0.625rem there). */
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-body: var(--font-geist-sans), Arial, Helvetica, sans-serif;
--font-code: var(--font-geist-mono), "SFMono-Regular", Menlo, monospace;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
@@ -12,6 +53,12 @@
}
}
.threadsLayout,
body > [role="presentation"] {
--foreground: oklch(0.145 0 0);
--background: oklch(1 0 0);
}
body {
background: var(--background);
color: var(--foreground);
@@ -5,8 +5,8 @@ import "./globals.css";
import "@copilotkit/react-core/v2/styles.css";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "CrewAI Flows + CopilotKit Starter",
description: "A starter demo connecting a CrewAI flow to CopilotKit.",
};
export default function RootLayout({
@@ -17,7 +17,11 @@ export default function RootLayout({
return (
<html lang="en">
<body className={"antialiased"}>
<CopilotKit runtimeUrl="/api/copilotkit" agent="sample_agent">
{/* Force REST transport so runtime-info + threads both hit the multi-route endpoint (auto-detect races the lazily-compiled API route in next dev). */}
<CopilotKit
runtimeUrl="/api/copilotkit"
useSingleEndpoint={false}
>
{children}
</CopilotKit>
</body>
@@ -5,12 +5,21 @@ import {
useFrontendTool,
useRenderTool,
CopilotSidebar,
CopilotChatConfigurationProvider,
} from "@copilotkit/react-core/v2";
import { CSSProperties, useState } from "react";
import { CSSProperties, useEffect, useState } from "react";
import { z } from "zod";
import { ThreadsDrawer } from "@/components/threads-drawer";
import { ThreadsPanelGate } from "@/components/threads-drawer/locked-state";
import styles from "@/components/threads-drawer/threads-drawer.module.css";
// The agent key registered in the runtime route (`agents: { default: ... }`).
const AGENT_ID = "default";
export default function CopilotKitPage() {
const [themeColor, setThemeColor] = useState("#6366f1");
const [threadId, setThreadId] = useState<string | undefined>(undefined);
// 🪁 Frontend Tools: https://docs.copilotkit.ai/guides/frontend-actions
useFrontendTool({
@@ -22,60 +31,65 @@ export default function CopilotKitPage() {
}),
handler: async ({ themeColor }) => {
setThemeColor(themeColor);
return `Changing background to ${themeColor}`;
},
});
return (
<main
style={{ "--copilot-kit-primary-color": themeColor } as CSSProperties}
>
<CopilotSidebar
disableSystemMessage={true}
clickOutsideToClose={false}
labels={{
title: "Popup Assistant",
initial: "👋 Hi, there! You're chatting with an agent.",
}}
suggestions={[
{
title: "Generative UI",
message: "Get the weather in San Francisco.",
},
{
title: "Frontend Tools",
message: "Set the theme to green.",
},
{
title: "Write Agent State",
message: "Add a proverb about AI.",
},
{
title: "Update Agent State",
message:
"Please remove 1 random proverb from the list if there are any.",
},
{
title: "Read Agent State",
message: "What are the proverbs?",
},
]}
>
<YourMainContent themeColor={themeColor} />
</CopilotSidebar>
</main>
<div className={`${styles.layout} threadsLayout`}>
<ThreadsPanelGate>
<ThreadsDrawer
agentId={AGENT_ID}
threadId={threadId}
onThreadChange={setThreadId}
/>
</ThreadsPanelGate>
<div className={styles.mainPanel}>
<CopilotChatConfigurationProvider
agentId={AGENT_ID}
threadId={threadId}
>
<main
style={
{
"--copilot-kit-primary-color": themeColor,
} as CSSProperties
}
>
<YourMainContent themeColor={themeColor} />
<CopilotSidebar
defaultOpen={true}
labels={{
modalHeaderTitle: "Popup Assistant",
welcomeMessageText:
"👋 Hi, there! You're chatting with an agent.",
}}
/>
</main>
</CopilotChatConfigurationProvider>
</div>
</div>
);
}
// State of the agent, make sure this aligns with your agent's state.
type AgentState = {
proverbs: string[];
};
function YourMainContent({ themeColor }: { themeColor: string }) {
// 🪁 Shared State: https://docs.copilotkit.ai/coagents/shared-state
// V2: useAgent returns the agent; read agent.state and write via agent.setState.
const { agent } = useAgent({
agentId: "sample_agent",
agentId: "default",
});
const state = (agent.state as AgentState | undefined) ?? { proverbs: [] };
// Seed an initial proverb once (the V2 agent starts with empty state).
useEffect(() => {
if ((agent.state as AgentState | undefined)?.proverbs === undefined) {
agent.setState({
proverbs: [
"CopilotKit may be new, but it's the best thing since sliced bread.",
],
});
}
}, [agent]);
// 🪁 Frontend Tools: https://docs.copilotkit.ai/coagents/frontend-actions
useFrontendTool(
@@ -90,12 +104,13 @@ function YourMainContent({ themeColor }: { themeColor: string }) {
}),
handler: async ({ proverbs }) => {
agent.setState({
...agent.state,
...state,
proverbs: [...proverbs],
});
return `Updated proverbs`;
},
},
[agent],
[agent, state],
);
//🪁 Generative UI: https://docs.copilotkit.ai/coagents/generative-ui
@@ -125,7 +140,7 @@ function YourMainContent({ themeColor }: { themeColor: string }) {
</p>
<hr className="border-white/20 my-6" />
<div className="flex flex-col gap-3">
{agent.state?.proverbs?.map((proverb, index) => (
{state.proverbs?.map((proverb, index) => (
<div
key={index}
className="bg-white/15 p-4 rounded-xl text-white relative group hover:bg-white/20 transition-all"
@@ -134,8 +149,8 @@ function YourMainContent({ themeColor }: { themeColor: string }) {
<button
onClick={() =>
agent.setState({
...agent.state,
proverbs: agent.state?.proverbs?.filter(
...state,
proverbs: state.proverbs?.filter(
(_: string, i: number) => i !== index,
),
})
@@ -148,7 +163,7 @@ function YourMainContent({ themeColor }: { themeColor: string }) {
</div>
))}
</div>
{agent.state?.proverbs?.length === 0 && (
{state.proverbs?.length === 0 && (
<p className="text-center text-white/80 italic my-8">
No proverbs yet. Ask the assistant to add some!
</p>
@@ -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}>
copilotkit add-intelligence
</code>
</div>
<button
type="button"
className={styles.lockedCta}
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</button>
</div>
</div>
</aside>
);
}
@@ -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
add-intelligence command in a muted code well, and a near-black primary CTA
pill matching the sidebar's send button. */
.lockedPanel {
display: flex;
width: 20rem;
flex-shrink: 0;
flex-direction: column;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
}
.lockedBody {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
padding: 1rem;
}
.lockedCard {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.lockedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
}
.lockedHeading {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.lockedTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.lockedDescription {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.lockedCommand {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
}
.lockedCommandCode {
font-family: var(--font-code);
font-size: 0.75rem;
white-space: nowrap;
color: var(--secondary-foreground);
}
.lockedCta {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.25rem;
padding: 0 0.875rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 140ms ease;
}
.lockedCta:hover {
opacity: 0.9;
}
.lockedCta:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
radius-xl, soft elevated shadow. */
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
color: var(--foreground);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.4rem;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1.1rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.dialogButtonSecondary {
background: var(--card);
border: 1px solid var(--border);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--accent);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--accent);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
and a fixed-width panel leaves a dead column). */
.lockedPanel {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -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&rsquo;t load threads
</p>
<p className={styles.emptyMessage}>
The thread list failed to load. Try reloading the page.
</p>
<button
className={styles.loadMoreButton}
type="button"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
</div>
) : isInitialLoading ? (
<div
aria-busy="true"
aria-label="Loading threads"
className={styles.loadingList}
role="status"
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={styles.loadingRow}>
<span className={styles.loadingAccent} />
<span className={styles.loadingBody}>
<span className={styles.loadingTitleBar} />
<span className={styles.loadingMetaBar} />
</span>
</div>
))}
</div>
) : displayThreads.length === 0 ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>No threads yet</p>
<p className={styles.emptyMessage}>
Create a thread to start a fresh conversation.
</p>
</div>
</div>
) : (
<div className={styles.threadList}>
{displayThreads.map((thread) => {
const hasTitle = thread.name !== null;
const title = thread.name ?? UNTITLED_THREAD_LABEL;
return (
<div key={thread.id} className={styles.threadRow}>
<button
aria-current={
threadId === thread.id ? "page" : undefined
}
className={cx(
styles.threadItem,
threadId === thread.id && styles.threadItemSelected,
enteringThreadIds[thread.id] &&
styles.threadItemAnimatingIn,
thread.archived && styles.threadItemArchived,
)}
type="button"
onClick={() => onThreadChange(thread.id)}
>
<span aria-hidden className={styles.threadAccent} />
<span className={styles.threadBody}>
<span
className={cx(
styles.threadTitle,
!hasTitle && styles.threadTitlePlaceholder,
revealedTitleIds[thread.id] &&
styles.threadTitleAnimated,
)}
>
{title}
{thread.archived && (
<span className={styles.archivedBadge}>
Archived
</span>
)}
</span>
<span className={styles.threadMeta}>
{formatThreadTimestamp(
thread.lastRunAt ?? thread.updatedAt,
)}
</span>
</span>
</button>
<div className={styles.threadActions}>
{thread.archived ? (
<button
aria-label={`Restore ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Restore thread"
type="button"
onClick={() => {
restoreThread(thread.id).catch((err: unknown) => {
console.error("Unable to restore thread", err);
});
}}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
aria-label={`Archive ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.tooltip,
)}
data-tooltip="Archive thread"
type="button"
onClick={() => {
if (threadId === thread.id)
onThreadChange(undefined);
archiveThread(thread.id).catch((err: unknown) => {
console.error("Unable to archive thread", err);
});
}}
>
<Archive size={14} />
</button>
)}
<button
aria-label={`Delete ${title}`}
className={cx(
styles.iconButton,
styles.threadActionButton,
styles.deleteButton,
styles.tooltip,
)}
data-tooltip="Delete thread"
type="button"
onClick={(e) => {
deleteTriggerRef.current = e.currentTarget;
setPendingDelete({ id: thread.id, title });
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{hasMoreThreads && (
<button
className={styles.loadMoreButton}
disabled={isFetchingMoreThreads}
type="button"
onClick={fetchMoreThreads}
>
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
</button>
)}
</div>
)}
</div>
</div>
</aside>
{pendingDelete && (
<ConfirmDialog
confirmLabel="Delete"
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
destructive
title="Delete thread"
onCancel={closeDeleteDialog}
onConfirm={() => {
const { id } = pendingDelete;
closeDeleteDialog();
if (threadId === id) onThreadChange(undefined);
deleteThread(id).catch((err: unknown) => {
console.error("Unable to delete thread", err);
});
}}
/>
)}
</>
);
}
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmDialog({
title,
description,
confirmLabel,
cancelLabel = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const titleId = useId();
const descId = useId();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
if (typeof document === "undefined") return null;
return createPortal(
<div
className={styles.dialogOverlay}
role="presentation"
onClick={onCancel}
>
<div
aria-describedby={descId}
aria-labelledby={titleId}
aria-modal="true"
className={styles.dialog}
role="dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className={styles.dialogTitle} id={titleId}>
{title}
</h3>
<p className={styles.dialogDescription} id={descId}>
{description}
</p>
<div className={styles.dialogActions}>
<button
autoFocus
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
type="button"
onClick={onCancel}
>
{cancelLabel}
</button>
<button
className={cx(
styles.dialogButton,
destructive
? styles.dialogButtonDestructive
: styles.dialogButtonPrimary,
)}
type="button"
onClick={onConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,52 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[var(--radius)] text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
{
variants: {
variant: {
default:
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90",
secondary:
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
outline:
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--secondary)]",
ghost:
"hover:bg-[var(--secondary)] hover:text-[var(--secondary-foreground)]",
destructive:
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };
@@ -0,0 +1,85 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-[var(--radius)] border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};

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