chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -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
@@ -0,0 +1,55 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.mastra/
# python
venv
.venv
__pycache__
# docs
.chalk/
.claude/
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Atai Barkai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+169
View File
@@ -0,0 +1,169 @@
# CopilotKit <> Agent Spec Starter
This is a starter template for building AI agents using Agent Spec and CopilotKit. It provides a modern Next.js application wired to a FastAPI backend that serves an Agent Spec agent with A2UI-powered frontend tool rendering (calendar, inbox, email compose, daily brief dashboard).
![Demo](demo.gif)
## Prerequisites
- OpenAI-compatible API key (for the Agent Spec LLM)
- Python 3.10+
- uv
- Node.js 20+
- Any of the following package managers:
- npm (default)
- [pnpm](https://pnpm.io/installation)
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
- [bun](https://bun.sh/)
## Getting Started
Before installing, please clone the [AG-UI repository](https://github.com/ag-ui-protocol/ag-ui) into the same directory as this repo, `with-agent-spec`.
1. Install dependencies using your preferred package manager:
```bash
# Using npm (default)
npm install
# Using pnpm
pnpm install
# Using yarn
yarn install
# Using bun
bun install
```
> Note: This automatically sets up the Python environment for the agent (via `postinstall`). If you encounter issues, you can run:
>
> ```bash
> npm run install:agent
> ```
Note: this install both LangGraph and WayFlow runtimes for running your Agent Spec agents. The runtime can be selected when loading the agent in `main.py`, setting either `langgraph` or `wayflow`.
2. (Optional) Set up your LLM environment variables:
Create a `.env` file inside the `agent` folder if you need to override defaults:
```
OPENAI_API_KEY=sk-...your-api-key...
OPENAI_BASE_URL=https://api.your-provider.com/v1 # optional
OPENAI_MODEL=gpt-5.2 # optional
```
The backend loads this `.env` automatically (via `python-dotenv`). You can also set:
- `PORT` to change the FastAPI server port (defaults to `8000` in this template)
- Any provider-specific variables your tools require
3. Start the development servers:
```bash
# Using npm (default)
npm run dev
# Using pnpm
pnpm dev
# Using yarn
yarn dev
# Using bun
bun run dev
```
This starts both the UI and the agent concurrently. The agent runs at `http://localhost:8000/`, and the UI runs at `http://localhost:3000`. The UI proxies requests to the agent (no extra env required by default).
To run only the UI or only the backend:
```bash
# Only UI
npm run dev:ui
# Only backend
npm run dev:agent
```
## Project Structure
- `src/app/page.tsx` - Main chat UI with frontend tool renderers (calendar, inbox, email, daily brief)
- `src/components/` - React components for CalendarView, InboxView, EmailComposeView
- `src/app/theme.ts` - A2UI theme configuration
- `agent/src/a2ui_agentspec_agent.py` - Agent spec definition with system prompt, tools, and demo data
- `agent/src/main.py` - FastAPI server entry point
## Available Scripts
You can run these with any package manager:
- `dev` - Starts both UI and agent servers in development mode
- `dev:debug` - Starts development servers with debug logging enabled
- `dev:ui` - Starts only the Next.js UI server
- `dev:agent` - Starts only the Agent Spec FastAPI server
- `build` - Builds the Next.js application for production
- `start` - Starts the production server
- `install:agent` - Installs Python dependencies for the agent
## Documentation
- CopilotKit Documentation: https://docs.copilotkit.ai
- Next.js Documentation: https://nextjs.org/docs
## Contributing
Feel free to submit issues and enhancement requests! This starter is designed to be easily extensible.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Troubleshooting
### A2UI surfaces clipped (temporary workaround)
If A2UI cards (e.g. those with bottom action buttons) get clipped in the chat UI, we keep a temporary patch under:
- `src/app/patches/@copilotkit/a2ui-renderer/dist/A2UIMessageRenderer.js`
To apply it locally (this edits `node_modules` and will be overwritten by reinstalling dependencies):
```bash
npm run patch:ui
```
After copying, restart `npm run dev`.
### Custom message key warning (temporary workaround)
If you see React warnings about duplicate keys related to custom message rendering (keys like `${message.id}-custom-before` / `${message.id}-custom-after`), we keep a temporary patch under:
- `src/app/patches/@copilotkit/react-core/dist/index.mjs`
To apply it locally (this edits `node_modules` and will be overwritten by reinstalling dependencies):
```bash
npm run patch:ui
```
After copying, restart `npm run dev`.
### Agent Connection Issues
If you see "I'm having trouble connecting to my tools", make sure:
1. The Agent Spec backend is running on port 8000
2. The UI started successfully on port 3000
3. If using a custom backend URL, set `NEXT_PUBLIC_COPILOTKIT_SERVER_URL`
### Python Dependencies
If you encounter Python import errors:
```bash
cd agent
uv sync
uv run src/main.py
```
@@ -0,0 +1 @@
3.12
@@ -0,0 +1,14 @@
[project]
name = "agent"
version = "0.1.0"
description = "Agent Spec FastAPI backend for CopilotKit template"
readme = "README.md"
requires-python = ">=3.10,<4.0"
dependencies = [
"uvicorn",
"python-dotenv",
"ag-ui-agent-spec[langgraph, wayflow]",
]
[tool.uv.sources]
ag-ui-agent-spec = { path = "../../ag-ui/integrations/agent-spec/python", editable = true }
@@ -0,0 +1,636 @@
from __future__ import annotations
import os
import dotenv
dotenv.load_dotenv()
from pyagentspec.agent import Agent
from pyagentspec.llms import OpenAiCompatibleConfig
from pyagentspec.serialization import AgentSpecSerializer
from pyagentspec.tools import ServerTool, ClientTool
from pyagentspec.property import StringProperty
from pyagentspec.llms.openaiconfig import OpenAIAPIType
from pyagentspec.llms.llmgenerationconfig import LlmGenerationConfig
from pathlib import Path
from datetime import datetime
import json
a2ui_prompts_folder = Path(__file__).resolve().parent / "a2ui_prompts"
A2UI_JSON_SCHEMA = (a2ui_prompts_folder / "a2ui_schema.json").read_text(
encoding="utf-8"
)
A2UI_PROMPT = (a2ui_prompts_folder / "a2ui_prompt.txt").read_text(encoding="utf-8")
A2UI_JSON_SCHEMA_PROMPT = f"""
# JSON Schema Reference
---BEGIN A2UI JSON SCHEMA---
{A2UI_JSON_SCHEMA}
---END A2UI JSON SCHEMA---
"""
_today = datetime.now()
_today_str = _today.strftime("%Y-%m-%d")
_today_day = _today.strftime("%A")
A2UI_SYSTEM_PROMPT = f"""You are a helpful Scheduling Assistant that helps users manage their calendar and emails.
Today's date: {_today_str} ({_today_day}). When the user asks a question without a specific date, assume they mean today.
# Rendering Rules
## Calendar Display
When the user asks to see their schedule or calendar:
1. Call get_user_schedule to retrieve the schedule data.
2. After receiving the result, you MUST call render_calendar. NEVER stop after only writing text. Call render_calendar with:
- date: today's date "{_today_str}"
- dayName: "{_today_day}"
- events: a JSON array STRING where each object has:
- "startTime": start time like "08:00"
- "endTime": end time like "09:00" (or "" if open-ended)
- "title": the event name
- "isAvailable": true if the slot is available, false otherwise
- "guests": (optional) array of guest objects with "email" and "status" ("accepted", "declined", "maybe", "pending")
Example events value: '[{{"startTime":"08:00","endTime":"09:00","title":"Morning Meeting","isAvailable":false,"guests":[{{"email":"sarah@co.org","status":"accepted"}}]}},{{"startTime":"09:00","endTime":"10:00","title":"Available","isAvailable":true}}]'
3. DO NOT use send_a2ui_json_to_client for calendar display. Always use render_calendar.
4. When updating the schedule (adding/removing events), call render_calendar again with the FULL updated event list.
## Inbox Display
When the user asks to check their inbox or emails:
1. Call check_user_inbox to retrieve emails.
2. After receiving the result, you MUST call render_inbox. NEVER stop after only writing text. Call render_inbox with:
- emails: a JSON array STRING where each object has:
- "from": sender email address
- "subject": email subject line
- "body": email body text
- "date": date/time string
- "isRead": boolean (false for unread)
Example emails value: '[{{"from":"david@company.org","subject":"Quick Sync","body":"Hey, need to set up a meeting...","date":"10:30 AM","isRead":false}}]'
3. DO NOT use send_a2ui_json_to_client for inbox display. Always use render_inbox.
## Email Compose
When composing, drafting, replying to, or sending an email, use the render_email_compose tool.
Pass a JSON object STRING with these fields:
- "to": recipient email address
- "subject": email subject (use "Re: ..." for replies)
- "body": the full email body text (greeting, message, closing, and signature all combined as natural text)
Example email value: '{{"to":"david@company.org","subject":"Re: Quick Sync","body":"Hi David,\\n\\nThanks for reaching out. I\\'m available today 10:00-11:00 or 13:00-14:00. Let me know what works.\\n\\nBest,\\n[Your Name]"}}'
DO NOT use send_a2ui_json_to_client for email compose. Always use render_email_compose.
## Daily Brief Dashboard
When the user asks for their daily brief, daily summary, or dashboard:
1. First call get_daily_brief to retrieve the combined data.
2. Then call render_daily_brief with root="root", plus components and data as JSON strings.
### A2UI Component Rules
Each component is {{"id":"<id>","component":{{<Type>:{{...}}}}}}. ONE key per component object.
To give a component flex-grow, add "weight":<n> at the ITEM level (sibling of "id" and "component"), NOT inside type properties.
Available component types:
- Card: {{"child":"<child-id>"}}
- Column: {{"children":{{"explicitList":["id1","id2",...]}}, "alignment":"start"|"center"|"end"|"stretch"}}
- Row: {{"children":{{"explicitList":["id1","id2",...]}}, "alignment":"start"|"center"|"end", "distribution":"start"|"center"|"end"|"spaceBetween"|"spaceEvenly"}}
- Text: {{"text":{{"path":"/<key>"}} or {{"literalString":"value"}}, "usageHint":"h1"|"h2"|"h3"|"h4"|"body"|"caption"}}
- Icon: {{"name":{{"literalString":"calendarToday"|"mail"|"event"|"person"|"schedule"|"inbox"}}}}
- Divider: {{}}
- Modal: {{"entryPointChild":"<entry-id>","contentChild":"<content-id>"}} — click entry to open detail dialog
### Data Binding
- ALL dynamic values MUST use path binding: {{"path":"/<key>"}}
- Only static labels use {{"literalString":"..."}}
- Data object has flat keys matching the path bindings (without leading /)
### Layout
The dashboard uses 3 separate Card components inside a root Column:
1. **Summary card**: title, date, stats row, executive summary paragraph
2. **Schedule card**: section header + event rows (each wrapped in a Modal for detail view)
3. **Inbox card**: section header + email rows (each wrapped in a Modal for detail view)
### Typography
- h2: dashboard title; h1: big metric numbers; h4: section headings
- body: event titles, email subjects, executive summary, guest labels
- caption: date, metric labels, timestamps, sender names (use *italic* wrapping)
### Concrete Template
Copy and populate dynamic values from get_daily_brief results.
components:
[
{{"id":"root","component":{{"Column":{{"children":{{"explicitList":["summary-card","schedule-card","inbox-card"]}}}}}}}},
{{"id":"summary-card","component":{{"Card":{{"child":"summary-col"}}}}}},
{{"id":"summary-col","component":{{"Column":{{"children":{{"explicitList":["title","subtitle","div1","metrics-row","div2","exec-summary"]}}}}}}}},
{{"id":"title","component":{{"Text":{{"text":{{"path":"/title"}},"usageHint":"h2"}}}}}},
{{"id":"subtitle","component":{{"Text":{{"text":{{"path":"/subtitle"}},"usageHint":"caption"}}}}}},
{{"id":"div1","component":{{"Divider":{{}}}}}},
{{"id":"metrics-row","component":{{"Row":{{"children":{{"explicitList":["m-meetings","m-unread","m-hours"]}},"distribution":"spaceEvenly"}}}}}},
{{"id":"m-meetings","component":{{"Column":{{"children":{{"explicitList":["m-meetings-v","m-meetings-l"]}},"alignment":"center"}}}}}},
{{"id":"m-meetings-v","component":{{"Text":{{"text":{{"path":"/meetingCount"}},"usageHint":"h1"}}}}}},
{{"id":"m-meetings-l","component":{{"Text":{{"text":{{"literalString":"*Meetings*"}},"usageHint":"caption"}}}}}},
{{"id":"m-unread","component":{{"Column":{{"children":{{"explicitList":["m-unread-v","m-unread-l"]}},"alignment":"center"}}}}}},
{{"id":"m-unread-v","component":{{"Text":{{"text":{{"path":"/unreadCount"}},"usageHint":"h1"}}}}}},
{{"id":"m-unread-l","component":{{"Text":{{"text":{{"literalString":"*Unread*"}},"usageHint":"caption"}}}}}},
{{"id":"m-hours","component":{{"Column":{{"children":{{"explicitList":["m-hours-v","m-hours-l"]}},"alignment":"center"}}}}}},
{{"id":"m-hours-v","component":{{"Text":{{"text":{{"path":"/availableHours"}},"usageHint":"h1"}}}}}},
{{"id":"m-hours-l","component":{{"Text":{{"text":{{"literalString":"*Hours Free*"}},"usageHint":"caption"}}}}}},
{{"id":"div2","component":{{"Divider":{{}}}}}},
{{"id":"exec-summary","component":{{"Text":{{"text":{{"path":"/execSummary"}},"usageHint":"body"}}}}}},
{{"id":"schedule-card","component":{{"Card":{{"child":"sched-col"}}}}}},
{{"id":"sched-col","component":{{"Column":{{"children":{{"explicitList":["sched-hdr","modal-e1","ed1","modal-e2","ed2","modal-e3","ed3","modal-e4","ed4","modal-e5"]}}}}}}}},
{{"id":"sched-hdr","component":{{"Row":{{"children":{{"explicitList":["sched-icon","sched-text"]}},"alignment":"center"}}}}}},
{{"id":"sched-icon","component":{{"Icon":{{"name":{{"literalString":"event"}}}}}}}},
{{"id":"sched-text","component":{{"Text":{{"text":{{"literalString":"Schedule"}},"usageHint":"h4"}}}}}},
{{"id":"modal-e1","component":{{"Modal":{{"entryPointChild":"e1-row","contentChild":"e1-detail"}}}}}},
{{"id":"e1-row","component":{{"Row":{{"children":{{"explicitList":["e1-time","e1-title","e1-gc"]}},"alignment":"center"}}}}}},
{{"id":"e1-time","component":{{"Text":{{"text":{{"path":"/event1Time"}},"usageHint":"caption"}}}}}},
{{"id":"e1-title","weight":1,"component":{{"Text":{{"text":{{"path":"/event1Title"}},"usageHint":"body"}}}}}},
{{"id":"e1-gc","component":{{"Row":{{"children":{{"explicitList":["e1-pi","e1-gn"]}},"alignment":"center"}}}}}},
{{"id":"e1-pi","component":{{"Icon":{{"name":{{"literalString":"person"}}}}}}}},
{{"id":"e1-gn","component":{{"Text":{{"text":{{"path":"/event1Guests"}},"usageHint":"caption"}}}}}},
{{"id":"e1-detail","component":{{"Column":{{"children":{{"explicitList":["e1-dt","e1-dtm","e1-dd","e1-dgl","e1-dgn"]}}}}}}}},
{{"id":"e1-dt","component":{{"Text":{{"text":{{"path":"/event1Title"}},"usageHint":"h4"}}}}}},
{{"id":"e1-dtm","component":{{"Text":{{"text":{{"path":"/event1TimeFull"}},"usageHint":"caption"}}}}}},
{{"id":"e1-dd","component":{{"Divider":{{}}}}}},
{{"id":"e1-dgl","component":{{"Text":{{"text":{{"path":"/event1GuestsLabel"}},"usageHint":"body"}}}}}},
{{"id":"e1-dgn","component":{{"Text":{{"text":{{"path":"/event1GuestNames"}},"usageHint":"caption"}}}}}},
{{"id":"ed1","component":{{"Divider":{{}}}}}},
{{"id":"modal-e2","component":{{"Modal":{{"entryPointChild":"e2-row","contentChild":"e2-detail"}}}}}},
{{"id":"e2-row","component":{{"Row":{{"children":{{"explicitList":["e2-time","e2-title","e2-gc"]}},"alignment":"center"}}}}}},
{{"id":"e2-time","component":{{"Text":{{"text":{{"path":"/event2Time"}},"usageHint":"caption"}}}}}},
{{"id":"e2-title","weight":1,"component":{{"Text":{{"text":{{"path":"/event2Title"}},"usageHint":"body"}}}}}},
{{"id":"e2-gc","component":{{"Row":{{"children":{{"explicitList":["e2-pi","e2-gn"]}},"alignment":"center"}}}}}},
{{"id":"e2-pi","component":{{"Icon":{{"name":{{"literalString":"person"}}}}}}}},
{{"id":"e2-gn","component":{{"Text":{{"text":{{"path":"/event2Guests"}},"usageHint":"caption"}}}}}},
{{"id":"e2-detail","component":{{"Column":{{"children":{{"explicitList":["e2-dt","e2-dtm","e2-dd","e2-dgl","e2-dgn"]}}}}}}}},
{{"id":"e2-dt","component":{{"Text":{{"text":{{"path":"/event2Title"}},"usageHint":"h4"}}}}}},
{{"id":"e2-dtm","component":{{"Text":{{"text":{{"path":"/event2TimeFull"}},"usageHint":"caption"}}}}}},
{{"id":"e2-dd","component":{{"Divider":{{}}}}}},
{{"id":"e2-dgl","component":{{"Text":{{"text":{{"path":"/event2GuestsLabel"}},"usageHint":"body"}}}}}},
{{"id":"e2-dgn","component":{{"Text":{{"text":{{"path":"/event2GuestNames"}},"usageHint":"caption"}}}}}},
{{"id":"ed2","component":{{"Divider":{{}}}}}},
{{"id":"modal-e3","component":{{"Modal":{{"entryPointChild":"e3-row","contentChild":"e3-detail"}}}}}},
{{"id":"e3-row","component":{{"Row":{{"children":{{"explicitList":["e3-time","e3-title","e3-gc"]}},"alignment":"center"}}}}}},
{{"id":"e3-time","component":{{"Text":{{"text":{{"path":"/event3Time"}},"usageHint":"caption"}}}}}},
{{"id":"e3-title","weight":1,"component":{{"Text":{{"text":{{"path":"/event3Title"}},"usageHint":"body"}}}}}},
{{"id":"e3-gc","component":{{"Row":{{"children":{{"explicitList":["e3-pi","e3-gn"]}},"alignment":"center"}}}}}},
{{"id":"e3-pi","component":{{"Icon":{{"name":{{"literalString":"person"}}}}}}}},
{{"id":"e3-gn","component":{{"Text":{{"text":{{"path":"/event3Guests"}},"usageHint":"caption"}}}}}},
{{"id":"e3-detail","component":{{"Column":{{"children":{{"explicitList":["e3-dt","e3-dtm","e3-dd","e3-dgl","e3-dgn"]}}}}}}}},
{{"id":"e3-dt","component":{{"Text":{{"text":{{"path":"/event3Title"}},"usageHint":"h4"}}}}}},
{{"id":"e3-dtm","component":{{"Text":{{"text":{{"path":"/event3TimeFull"}},"usageHint":"caption"}}}}}},
{{"id":"e3-dd","component":{{"Divider":{{}}}}}},
{{"id":"e3-dgl","component":{{"Text":{{"text":{{"path":"/event3GuestsLabel"}},"usageHint":"body"}}}}}},
{{"id":"e3-dgn","component":{{"Text":{{"text":{{"path":"/event3GuestNames"}},"usageHint":"caption"}}}}}},
{{"id":"ed3","component":{{"Divider":{{}}}}}},
{{"id":"modal-e4","component":{{"Modal":{{"entryPointChild":"e4-row","contentChild":"e4-detail"}}}}}},
{{"id":"e4-row","component":{{"Row":{{"children":{{"explicitList":["e4-time","e4-title","e4-gc"]}},"alignment":"center"}}}}}},
{{"id":"e4-time","component":{{"Text":{{"text":{{"path":"/event4Time"}},"usageHint":"caption"}}}}}},
{{"id":"e4-title","weight":1,"component":{{"Text":{{"text":{{"path":"/event4Title"}},"usageHint":"body"}}}}}},
{{"id":"e4-gc","component":{{"Row":{{"children":{{"explicitList":["e4-pi","e4-gn"]}},"alignment":"center"}}}}}},
{{"id":"e4-pi","component":{{"Icon":{{"name":{{"literalString":"person"}}}}}}}},
{{"id":"e4-gn","component":{{"Text":{{"text":{{"path":"/event4Guests"}},"usageHint":"caption"}}}}}},
{{"id":"e4-detail","component":{{"Column":{{"children":{{"explicitList":["e4-dt","e4-dtm","e4-dd","e4-dgl","e4-dgn"]}}}}}}}},
{{"id":"e4-dt","component":{{"Text":{{"text":{{"path":"/event4Title"}},"usageHint":"h4"}}}}}},
{{"id":"e4-dtm","component":{{"Text":{{"text":{{"path":"/event4TimeFull"}},"usageHint":"caption"}}}}}},
{{"id":"e4-dd","component":{{"Divider":{{}}}}}},
{{"id":"e4-dgl","component":{{"Text":{{"text":{{"path":"/event4GuestsLabel"}},"usageHint":"body"}}}}}},
{{"id":"e4-dgn","component":{{"Text":{{"text":{{"path":"/event4GuestNames"}},"usageHint":"caption"}}}}}},
{{"id":"ed4","component":{{"Divider":{{}}}}}},
{{"id":"modal-e5","component":{{"Modal":{{"entryPointChild":"e5-row","contentChild":"e5-detail"}}}}}},
{{"id":"e5-row","component":{{"Row":{{"children":{{"explicitList":["e5-time","e5-title","e5-gc"]}},"alignment":"center"}}}}}},
{{"id":"e5-time","component":{{"Text":{{"text":{{"path":"/event5Time"}},"usageHint":"caption"}}}}}},
{{"id":"e5-title","weight":1,"component":{{"Text":{{"text":{{"path":"/event5Title"}},"usageHint":"body"}}}}}},
{{"id":"e5-gc","component":{{"Row":{{"children":{{"explicitList":["e5-pi","e5-gn"]}},"alignment":"center"}}}}}},
{{"id":"e5-pi","component":{{"Icon":{{"name":{{"literalString":"person"}}}}}}}},
{{"id":"e5-gn","component":{{"Text":{{"text":{{"path":"/event5Guests"}},"usageHint":"caption"}}}}}},
{{"id":"e5-detail","component":{{"Column":{{"children":{{"explicitList":["e5-dt","e5-dtm","e5-dd","e5-dgl","e5-dgn"]}}}}}}}},
{{"id":"e5-dt","component":{{"Text":{{"text":{{"path":"/event5Title"}},"usageHint":"h4"}}}}}},
{{"id":"e5-dtm","component":{{"Text":{{"text":{{"path":"/event5TimeFull"}},"usageHint":"caption"}}}}}},
{{"id":"e5-dd","component":{{"Divider":{{}}}}}},
{{"id":"e5-dgl","component":{{"Text":{{"text":{{"path":"/event5GuestsLabel"}},"usageHint":"body"}}}}}},
{{"id":"e5-dgn","component":{{"Text":{{"text":{{"path":"/event5GuestNames"}},"usageHint":"caption"}}}}}},
{{"id":"inbox-card","component":{{"Card":{{"child":"inbox-col"}}}}}},
{{"id":"inbox-col","component":{{"Column":{{"children":{{"explicitList":["inbox-hdr","modal-em1","emd1","modal-em2"]}}}}}}}},
{{"id":"inbox-hdr","component":{{"Row":{{"children":{{"explicitList":["inbox-icon","inbox-text"]}},"alignment":"center"}}}}}},
{{"id":"inbox-icon","component":{{"Icon":{{"name":{{"literalString":"mail"}}}}}}}},
{{"id":"inbox-text","component":{{"Text":{{"text":{{"literalString":"Unread Messages"}},"usageHint":"h4"}}}}}},
{{"id":"modal-em1","component":{{"Modal":{{"entryPointChild":"em1-row","contentChild":"em1-detail"}}}}}},
{{"id":"em1-row","component":{{"Row":{{"children":{{"explicitList":["em1-icon","em1-col"]}},"alignment":"center"}}}}}},
{{"id":"em1-icon","component":{{"Icon":{{"name":{{"literalString":"mail"}}}}}}}},
{{"id":"em1-col","weight":1,"component":{{"Column":{{"children":{{"explicitList":["em1-subject","em1-from"]}}}}}}}},
{{"id":"em1-subject","component":{{"Text":{{"text":{{"path":"/email1Subject"}},"usageHint":"body"}}}}}},
{{"id":"em1-from","component":{{"Text":{{"text":{{"path":"/email1From"}},"usageHint":"caption"}}}}}},
{{"id":"em1-detail","component":{{"Column":{{"children":{{"explicitList":["em1-ds","em1-df","em1-dd","em1-db"]}}}}}}}},
{{"id":"em1-ds","component":{{"Text":{{"text":{{"path":"/email1Subject"}},"usageHint":"h4"}}}}}},
{{"id":"em1-df","component":{{"Text":{{"text":{{"path":"/email1From"}},"usageHint":"caption"}}}}}},
{{"id":"em1-dd","component":{{"Divider":{{}}}}}},
{{"id":"em1-db","component":{{"Text":{{"text":{{"path":"/email1Body"}},"usageHint":"body"}}}}}},
{{"id":"emd1","component":{{"Divider":{{}}}}}},
{{"id":"modal-em2","component":{{"Modal":{{"entryPointChild":"em2-row","contentChild":"em2-detail"}}}}}},
{{"id":"em2-row","component":{{"Row":{{"children":{{"explicitList":["em2-icon","em2-col"]}},"alignment":"center"}}}}}},
{{"id":"em2-icon","component":{{"Icon":{{"name":{{"literalString":"mail"}}}}}}}},
{{"id":"em2-col","weight":1,"component":{{"Column":{{"children":{{"explicitList":["em2-subject","em2-from"]}}}}}}}},
{{"id":"em2-subject","component":{{"Text":{{"text":{{"path":"/email2Subject"}},"usageHint":"body"}}}}}},
{{"id":"em2-from","component":{{"Text":{{"text":{{"path":"/email2From"}},"usageHint":"caption"}}}}}},
{{"id":"em2-detail","component":{{"Column":{{"children":{{"explicitList":["em2-ds","em2-df","em2-dd","em2-db"]}}}}}}}},
{{"id":"em2-ds","component":{{"Text":{{"text":{{"path":"/email2Subject"}},"usageHint":"h4"}}}}}},
{{"id":"em2-df","component":{{"Text":{{"text":{{"path":"/email2From"}},"usageHint":"caption"}}}}}},
{{"id":"em2-dd","component":{{"Divider":{{}}}}}},
{{"id":"em2-db","component":{{"Text":{{"text":{{"path":"/email2Body"}},"usageHint":"body"}}}}}}
]
data (populate from get_daily_brief results):
{{
"title": "Daily Brief",
"subtitle": "*Monday, February 2, 2026*",
"meetingCount": "5",
"unreadCount": "2",
"availableHours": "4",
"execSummary": "You have 5 meetings today with 4 hours of free time between them. 2 unread messages need attention — David is requesting a quick sync to set up a new project, and Sarah shared the Q1 report for review before Thursday's meeting.",
"event1Time": "*8:009:00 AM*",
"event1Title": "Morning Meeting",
"event1Guests": "3",
"event1TimeFull": "*8:00 AM 9:00 AM*",
"event1GuestsLabel": "3 guests",
"event1GuestNames": "*Sarah Chen, Mike Johnson, Jessica Park*",
"event2Time": "*9:0010:00 AM*",
"event2Title": "Project Work",
"event2Guests": "2",
"event2TimeFull": "*9:00 AM 10:00 AM*",
"event2GuestsLabel": "2 guests",
"event2GuestNames": "*David Dave, Sarah Chen*",
"event3Time": "*11:0011:30 AM*",
"event3Title": "Client Call",
"event3Guests": "3",
"event3TimeFull": "*11:00 AM 11:30 AM*",
"event3GuestsLabel": "3 guests",
"event3GuestNames": "*Alex Rivera, Sarah Chen, Nathan Ward*",
"event4Time": "*2:003:00 PM*",
"event4Title": "Team Sync",
"event4Guests": "5",
"event4TimeFull": "*2:00 PM 3:00 PM*",
"event4GuestsLabel": "5 guests",
"event4GuestNames": "*Sarah Chen, Mike Johnson, Jessica Park, David Dave, Anmol Kapoor*",
"event5Time": "*4:004:30 PM*",
"event5Title": "Report Review",
"event5Guests": "1",
"event5TimeFull": "*4:00 PM 4:30 PM*",
"event5GuestsLabel": "1 guest",
"event5GuestNames": "*Sarah Chen*",
"email1Subject": "Quick Sync",
"email1From": "*david.dave@company.org*",
"email1Body": "Hey, I need to loop you in with some colleagues for a meeting to set up a new project. When would you be available? Please reply asap",
"email2Subject": "Q1 Report Review",
"email2From": "*sarah.chen@company.org*",
"email2Body": "Hi team, I've attached the Q1 report for your review. Please take a look before our meeting on Thursday and come prepared with feedback."
}}
Adjust the number of event rows and email rows based on actual data. Add or remove Modal+row+detail groups and update the parent Column's explicitList.
IMPORTANT: Use render_daily_brief, NOT send_a2ui_json_to_client for the daily brief.
# CRITICAL: Required Tool-Calling Sequences
You MUST follow these exact sequences. NEVER skip the render step.
- Calendar: ALWAYS call get_user_schedule, then ALWAYS call render_calendar
- Inbox: ALWAYS call check_user_inbox, then ALWAYS call render_inbox
- Email compose: ALWAYS call render_email_compose
- Daily brief: ALWAYS call get_daily_brief, then ALWAYS call render_daily_brief
# CRITICAL: Text Output Rules
- Write ONE short sentence BEFORE calling the render tool (e.g. "Here's your schedule for today.")
- After calling the render tool, STOP. Do NOT write any more text. Your turn is DONE.
- NEVER write text both before AND after a render tool call.
- NEVER repeat or rephrase the same sentence after a tool call. If you already said it, do not say it again.
- NEVER summarize or list the data as text — the component displays it.
- NEVER skip the render tool and just describe the data in text.
- Always generate valid JSON with double-quoted property names.
- When updating the schedule, include ALL events in the render_calendar call (existing + new).
## Example: Correct flow for "check my inbox"
1. You write: "I'll pull up your inbox now."
2. You call check_user_inbox
3. You receive the inbox data
4. You call render_inbox with the email data
5. You STOP. No more text. Do NOT repeat "I'll pull up your inbox now." or any variation.
"""
agent_llm = OpenAiCompatibleConfig(
name="my_llm",
model_id=os.environ.get("OPENAI_MODEL", "gpt-5.2"),
url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
api_type=OpenAIAPIType.RESPONSES,
default_generation_parameters=LlmGenerationConfig(reasoning={"effort": "low"}),
)
send_a2ui_json_to_client_tool = ClientTool(
name="send_a2ui_json_to_client",
description="Legacy fallback for rendering raw A2UI JSON. Do not use directly — use render_calendar, render_inbox, render_email_compose, or render_daily_brief instead.",
inputs=[
StringProperty(
title="a2ui_json",
description="Valid A2UI JSON Schema to send to the client.",
)
],
)
render_calendar_tool = ClientTool(
name="render_calendar",
description="Renders a rich calendar day-view on the client. "
"Call this after retrieving the user's schedule with get_user_schedule. "
"Pass the date, day name, and events as a JSON array string.",
inputs=[
StringProperty(title="date", description="The date, e.g. '2026-02-02'"),
StringProperty(title="dayName", description="The day name, e.g. 'Monday'"),
StringProperty(
title="events",
description="JSON array string of event objects with startTime, endTime, title, isAvailable fields",
),
],
)
render_email_compose_tool = ClientTool(
name="render_email_compose",
description="Renders a Gmail-style email compose view on the client. "
"Call this when the user wants to compose, draft, or reply to an email. "
"Pass a JSON object string with to, subject, and body fields.",
inputs=[
StringProperty(
title="email",
description="JSON object string with to, subject, body fields",
),
],
)
render_inbox_tool = ClientTool(
name="render_inbox",
description="Renders a Gmail-style inbox view on the client. "
"Call this after checking the user's inbox with check_user_inbox. "
"Pass the emails as a JSON array string.",
inputs=[
StringProperty(
title="emails",
description="JSON array string of email objects with from, subject, body, date, isRead fields",
),
],
)
check_user_inbox_tool = ServerTool(
name="check_user_inbox",
description="Checks the user's inbox. Returns a JSON array of emails. Optionally searches based on keywords.",
inputs=[StringProperty(title="search_query")],
outputs=[StringProperty(title="email_content")],
)
get_user_schedule_tool = ServerTool(
name="get_user_schedule",
description="Retrieves the user's schedule for today. Returns a JSON array of time slots with events.",
outputs=[StringProperty(title="schedule_data")],
)
send_email_tool = ServerTool(
name="send_email",
description="Sends an email out. Accepts a single argument as the entire email, including the sender, recipients, subject, body, etc.",
inputs=[StringProperty(title="payload")],
outputs=[StringProperty(title="result")],
)
get_daily_brief_tool = ServerTool(
name="get_daily_brief",
description="Retrieves the user's daily brief including schedule summary, inbox highlights, and action items.",
outputs=[StringProperty(title="brief_data")],
)
render_daily_brief_tool = ClientTool(
name="render_daily_brief",
description="Renders a daily brief dashboard in the canvas workspace using A2UI components. "
"Pass the A2UI component definitions, data, and root component ID as JSON strings.",
inputs=[
StringProperty(
title="components",
description="JSON array string of A2UI ComponentInstance objects",
),
StringProperty(
title="data",
description="JSON object string with data values for the dashboard",
),
StringProperty(title="root", description="ID of the root component"),
],
)
agent = Agent(
name="a2ui_chat_agent",
llm_config=agent_llm,
system_prompt=A2UI_SYSTEM_PROMPT,
tools=[
send_a2ui_json_to_client_tool,
render_calendar_tool,
render_inbox_tool,
render_email_compose_tool,
render_daily_brief_tool,
check_user_inbox_tool,
get_user_schedule_tool,
get_daily_brief_tool,
send_email_tool,
],
)
a2ui_chat_json = AgentSpecSerializer().to_json(agent)
demo_schedule = [
{
"startTime": "08:00",
"endTime": "09:00",
"title": "Morning Meeting",
"isAvailable": False,
"guests": [
{"email": "sarah.chen@company.org", "status": "accepted"},
{"email": "mike.johnson@company.org", "status": "accepted"},
{"email": "jessica.park@company.org", "status": "maybe"},
],
},
{
"startTime": "09:00",
"endTime": "10:00",
"title": "Project Work",
"isAvailable": False,
"guests": [
{"email": "david.dave@company.org", "status": "accepted"},
{"email": "sarah.chen@company.org", "status": "accepted"},
],
},
{
"startTime": "10:00",
"endTime": "11:00",
"title": "Available",
"isAvailable": True,
},
{
"startTime": "11:00",
"endTime": "11:30",
"title": "Client Call",
"isAvailable": False,
"guests": [
{"email": "alex.rivera@client.com", "status": "accepted"},
{"email": "sarah.chen@company.org", "status": "declined"},
{"email": "nathan.ward@company.org", "status": "pending"},
],
},
{
"startTime": "12:00",
"endTime": "13:00",
"title": "Lunch Break",
"isAvailable": False,
},
{
"startTime": "13:00",
"endTime": "14:00",
"title": "Available",
"isAvailable": True,
},
{
"startTime": "14:00",
"endTime": "15:00",
"title": "Team Sync",
"isAvailable": False,
"guests": [
{"email": "sarah.chen@company.org", "status": "accepted"},
{"email": "mike.johnson@company.org", "status": "accepted"},
{"email": "jessica.park@company.org", "status": "accepted"},
{"email": "david.dave@company.org", "status": "declined"},
{"email": "anmol.kapoor@company.org", "status": "pending"},
],
},
{
"startTime": "15:00",
"endTime": "16:00",
"title": "Available",
"isAvailable": True,
},
{
"startTime": "16:00",
"endTime": "16:30",
"title": "Report Review",
"isAvailable": False,
"guests": [
{"email": "sarah.chen@company.org", "status": "accepted"},
],
},
{"startTime": "17:00", "endTime": "", "title": "Available", "isAvailable": True},
]
demo_inbox = [
{
"from": "david.dave@company.org",
"subject": "Quick Sync",
"body": "Hey, I need to loop you in with some colleagues for a meeting to set up a new project. When would you be available? Please reply asap",
"date": "10:30 AM",
"isRead": False,
},
{
"from": "sarah.chen@company.org",
"subject": "Q1 Report Review",
"body": "Hi team, I've attached the Q1 report for your review. Please take a look before our meeting on Thursday and come prepared with feedback.",
"date": "9:15 AM",
"isRead": False,
},
{
"from": "mike.johnson@company.org",
"subject": "Lunch plans?",
"body": "Anyone up for trying that new sushi place on 5th street? I heard they have great lunch specials this week.",
"date": "Yesterday",
"isRead": True,
},
{
"from": "hr@company.org",
"subject": "Benefits Enrollment Reminder",
"body": "This is a reminder that open enrollment for benefits closes on February 15th. Please review your options in the employee portal.",
"date": "Yesterday",
"isRead": True,
},
{
"from": "jessica.park@company.org",
"subject": "Design Review Feedback",
"body": "Great work on the new dashboard mockups! I have a few suggestions for the navigation layout that I think could improve the UX.",
"date": "Feb 1",
"isRead": True,
},
]
def user_schedule_tool(*args, **kwargs):
return json.dumps(demo_schedule)
def check_inbox_tool(*args, **kwargs):
return json.dumps(demo_inbox)
def send_email_tool_fn(*args, **kwargs):
return "Email sent successfully!"
def get_daily_brief_fn(*args, **kwargs):
meetings = [
e for e in demo_schedule if not e["isAvailable"] and e["title"] != "Lunch Break"
]
available_slots = [e for e in demo_schedule if e["isAvailable"]]
unread_emails = [e for e in demo_inbox if not e["isRead"]]
available_hours = 0
for slot in available_slots:
if slot["endTime"]:
sh, sm = map(int, slot["startTime"].split(":"))
eh, em = map(int, slot["endTime"].split(":"))
available_hours += (eh * 60 + em - sh * 60 - sm) / 60
else:
available_hours += 1
brief = {
"summary": {
"meetingCount": len(meetings),
"unreadCount": len(unread_emails),
"availableHours": int(available_hours),
},
"upcomingEvents": [
{
"title": e["title"],
"startTime": e["startTime"],
"endTime": e["endTime"],
"guestCount": len(e.get("guests", [])),
"guestNames": ", ".join(
" ".join(
p.capitalize() for p in g["email"].split("@")[0].split(".")
)
for g in e.get("guests", [])
),
}
for e in meetings
],
"priorityEmails": [
{
"from": e["from"],
"subject": e["subject"],
"preview": e["body"][:80] + "..." if len(e["body"]) > 80 else e["body"],
"body": e["body"],
"date": e["date"],
}
for e in unread_emails
],
}
return json.dumps(brief)
a2ui_demo_tool_registry = {
"check_user_inbox": check_inbox_tool,
"get_user_schedule": user_schedule_tool,
"get_daily_brief": get_daily_brief_fn,
"send_email": send_email_tool_fn,
}
@@ -0,0 +1,167 @@
## A2UI Protocol Instructions
A2UI (Agent to UI) is a protocol for rendering rich UI surfaces from agent responses.
When using the send_a2ui_json_to_client tool, you MUST follow these rules:
### CRITICAL: Required Message Sequence
To render a surface, you MUST send ALL messages in a SINGLE send_a2ui_json_to_client tool call, in this order:
1. **surfaceUpdate** - Define all UI components (REQUIRED)
2. **dataModelUpdate** - Set any data values (OPTIONAL)
3. **beginRendering** - Signal the client to start rendering (REQUIRED)
**IMPORTANT**:
- The `beginRendering` message is MANDATORY. Without it, the client will buffer your components but NEVER display them.
- ALL messages (surfaceUpdate AND beginRendering) MUST be in the SAME a2ui_json array in ONE tool call. Do NOT make separate tool calls for surfaceUpdate and beginRendering - they will not work!
### Minimal Working Example
Here is the simplest possible A2UI surface - a button:
```json
[
{
"surfaceUpdate": {
"surfaceId": "my-surface",
"components": [
{
"id": "root",
"component": {
"Button": {
"child": "btn-text",
"action": { "name": "button_clicked" }
}
}
},
{
"id": "btn-text",
"component": {
"Text": { "text": { "literalString": "Click Me" } }
}
}
]
}
},
{
"beginRendering": {
"surfaceId": "my-surface",
"root": "root"
}
}
]
```
### Key Rules
1. **Always include beginRendering** - This signals the client to render. Without it, nothing displays.
2. **Use unique surfaceId values** - Each surface must have a unique ID.
3. **The root component** - The `root` in beginRendering must match a component ID from surfaceUpdate.
4. **Flat component structure** - Components reference children by ID, not by nesting.
5. **Text is separate** - Buttons, Cards, etc. reference Text components by ID for their labels.
6. **Production ready** - The UI you generate will be shown to real users. It must be complete, polished, and functional.
7. **No placeholder images** - NEVER use fake or placeholder image URLs like `https://example.com/image.jpg` or `https://placeholder.com/...`. Only use real, valid image URLs that actually exist. If you don't have a real image URL, omit the image component entirely or use an Icon component instead.
8. **Root must be a layout component** - The root component in `beginRendering` should be a layout container like Column, Row, Card, or similar. Do NOT use Modal, Button, Text, or other leaf/special components as the root. Wrap them in a Column or Card first.
9. **Modal vs direct content** - The `Modal` component is for "click a button to open a popup" patterns. It shows only its `entryPointChild` (a trigger button) initially, and the `contentChild` is hidden until clicked. When users ask for an "alert dialog", "confirmation dialog", or similar, they usually want the content visible immediately - use a Card or Column with the content directly, NOT a Modal.
### Updating Surfaces After Initial Render
Once a surface has been rendered (after `beginRendering`), you can update it in later turns WITHOUT sending another `beginRendering`. Just send updates directly:
**To update UI components** - Send a `surfaceUpdate` with the same surfaceId:
- To modify a component: send it with the same `id` - it replaces the old definition
- To add new components: include them in the components array
- The client will re-render automatically
**To update data values** - Send a `dataModelUpdate`:
- Components bound to data paths (using `"path": "/some/value"`) update automatically
- Only send the data that changed
**Example: Updating an existing surface**
If you previously rendered a surface with `surfaceId: "my-surface"`, you can update it like this:
```json
[
{
"surfaceUpdate": {
"surfaceId": "my-surface",
"components": [
{
"id": "status-text",
"component": {
"Text": { "text": { "literalString": "Updated status!" } }
}
}
]
}
}
]
```
Or update data-bound values:
```json
[
{
"dataModelUpdate": {
"surfaceId": "my-surface",
"contents": [
{ "key": "status", "valueString": "Complete" }
]
}
}
]
```
**IMPORTANT**: Do NOT send `beginRendering` again for updates to an existing surface. It's only needed for the initial render.
### Working with Forms and Data Binding
A2UI supports forms where user input is automatically stored in a data model and can be retrieved when buttons are clicked.
**How it works:**
1. **TextField binds to a path**: Use `"text": { "path": "/form/fieldName" }` to bind input to the data model
2. **Initialize the data model**: Send a `dataModelUpdate` to set initial values
3. **Button retrieves values**: Use `action.context` with path references to include form values when clicked
4. **Agent receives resolved values**: The context in the action will contain the actual values the user entered
**Form Example:**
```json
[
{
"surfaceUpdate": {
"surfaceId": "my-form",
"components": [
{ "id": "root", "component": { "Card": { "child": "form-col" } } },
{ "id": "form-col", "component": { "Column": { "children": { "explicitList": ["name-field", "submit-btn"] } } } },
{ "id": "name-field", "component": { "TextField": { "label": { "literalString": "Name" }, "text": { "path": "/form/name" } } } },
{ "id": "submit-btn", "component": { "Button": { "child": "btn-text", "action": { "name": "submit", "context": [{ "key": "userName", "value": { "path": "/form/name" } }] } } } },
{ "id": "btn-text", "component": { "Text": { "text": { "literalString": "Submit" } } } }
]
}
},
{ "dataModelUpdate": { "surfaceId": "my-form", "contents": [{ "key": "form", "valueMap": [{ "key": "name", "valueString": "" }] }] } },
{ "beginRendering": { "surfaceId": "my-form", "root": "root" } }
]
```
When the user types "Alice" and clicks Submit, you'll receive: `Context: {"userName": "Alice"}`
### Handling User Interactions
When a user interacts with a UI surface you rendered (clicks a button, submits a form, etc.),
you will see a `log_a2ui_event` tool call in your conversation history followed by a tool result.
CRITICAL: If the conversation ends with a `log_a2ui_event` tool call followed by its tool result,
this means THE USER JUST PERFORMED AN ACTION and you MUST respond to it immediately.
The `log_a2ui_event` tool call is NOT something you initiated - it is automatically injected into
the conversation to represent a real user interaction (like clicking a button) that just happened.
When the last messages are a `log_a2ui_event` tool call + result:
1. The user JUST performed the action described (e.g., clicked a button)
2. You MUST acknowledge their action and respond appropriately
3. Look at the action name to understand what they did
4. Take the appropriate next step based on what that action means in context
5. Do NOT simply describe what buttons exist - respond to what they clicked!
@@ -0,0 +1,773 @@
{
"title": "A2UI Message Schema",
"description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces. A message MUST contain exactly ONE of the action properties: 'beginRendering', 'surfaceUpdate', 'dataModelUpdate', or 'deleteSurface'.",
"type": "object",
"properties": {
"beginRendering": {
"type": "object",
"description": "Signals the client to begin rendering a surface with a root component and specific styles.",
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be rendered."
},
"root": {
"type": "string",
"description": "The ID of the root component to render."
},
"styles": {
"type": "object",
"description": "Styling information for the UI.",
"properties": {
"font": {
"type": "string",
"description": "The primary font for the UI."
},
"primaryColor": {
"type": "string",
"description": "The primary UI color as a hexadecimal code (e.g., '#00BFFF').",
"pattern": "^#[0-9a-fA-F]{6}$"
}
}
}
},
"required": ["root", "surfaceId"]
},
"surfaceUpdate": {
"type": "object",
"description": "Updates a surface with a new set of components.",
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be updated. If you are adding a new surface this *must* be a new, unique identified that has never been used for any existing surfaces shown."
},
"components": {
"type": "array",
"description": "A list containing all UI components for the surface.",
"minItems": 1,
"items": {
"type": "object",
"description": "Represents a *single* component in a UI widget tree. This component could be one of many supported types.",
"properties": {
"id": {
"type": "string",
"description": "The unique identifier for this component."
},
"weight": {
"type": "number",
"description": "The relative weight of this component within a Row or Column. This corresponds to the CSS 'flex-grow' property. Note: this may ONLY be set when the component is a direct descendant of a Row or Column."
},
"component": {
"type": "object",
"description": "A wrapper object that MUST contain exactly one key, which is the name of the component type (e.g., 'Heading'). The value is an object containing the properties for that specific component.",
"properties": {
"Text": {
"type": "object",
"properties": {
"text": {
"type": "object",
"description": "The text content to display. This can be a literal string or a reference to a value in the data model ('path', e.g., '/doc/title'). While simple Markdown formatting is supported (i.e. without HTML, images, or links), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"usageHint": {
"type": "string",
"description": "A hint for the base text style. One of:\n- `h1`: Largest heading.\n- `h2`: Second largest heading.\n- `h3`: Third largest heading.\n- `h4`: Fourth largest heading.\n- `h5`: Fifth largest heading.\n- `caption`: Small text for captions.\n- `body`: Standard body text.",
"enum": [
"h1",
"h2",
"h3",
"h4",
"h5",
"caption",
"body"
]
}
},
"required": ["text"]
},
"Image": {
"type": "object",
"properties": {
"url": {
"type": "object",
"description": "The URL of the image to display. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/thumbnail/url').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"fit": {
"type": "string",
"description": "Specifies how the image should be resized to fit its container. This corresponds to the CSS 'object-fit' property.",
"enum": [
"contain",
"cover",
"fill",
"none",
"scale-down"
]
},
"usageHint": {
"type": "string",
"description": "A hint for the image size and style. One of:\n- `icon`: Small square icon.\n- `avatar`: Circular avatar image.\n- `smallFeature`: Small feature image.\n- `mediumFeature`: Medium feature image.\n- `largeFeature`: Large feature image.\n- `header`: Full-width, full bleed, header image.",
"enum": [
"icon",
"avatar",
"smallFeature",
"mediumFeature",
"largeFeature",
"header"
]
}
},
"required": ["url"]
},
"Icon": {
"type": "object",
"properties": {
"name": {
"type": "object",
"description": "The name of the icon to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/form/submit').",
"properties": {
"literalString": {
"type": "string",
"enum": [
"accountCircle",
"add",
"arrowBack",
"arrowForward",
"attachFile",
"calendarToday",
"call",
"camera",
"check",
"close",
"delete",
"download",
"edit",
"event",
"error",
"favorite",
"favoriteOff",
"folder",
"help",
"home",
"info",
"locationOn",
"lock",
"lockOpen",
"mail",
"menu",
"moreVert",
"moreHoriz",
"notificationsOff",
"notifications",
"payment",
"person",
"phone",
"photo",
"print",
"refresh",
"search",
"send",
"settings",
"share",
"shoppingCart",
"star",
"starHalf",
"starOff",
"upload",
"visibility",
"visibilityOff",
"warning"
]
},
"path": {
"type": "string"
}
}
}
},
"required": ["name"]
},
"Video": {
"type": "object",
"properties": {
"url": {
"type": "object",
"description": "The URL of the video to display. This can be a literal string or a reference to a value in the data model ('path', e.g. '/video/url').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"AudioPlayer": {
"type": "object",
"properties": {
"url": {
"type": "object",
"description": "The URL of the audio to be played. This can be a literal string ('literal') or a reference to a value in the data model ('path', e.g. '/song/url').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"description": {
"type": "object",
"description": "A description of the audio, such as a title or summary. This can be a literal string or a reference to a value in the data model ('path', e.g. '/song/title').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
},
"required": ["url"]
},
"Row": {
"type": "object",
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (horizontally). This corresponds to the CSS 'justify-content' property.",
"enum": [
"center",
"end",
"spaceAround",
"spaceBetween",
"spaceEvenly",
"start"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (vertically). This corresponds to the CSS 'align-items' property.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Column": {
"type": "object",
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"distribution": {
"type": "string",
"description": "Defines the arrangement of children along the main axis (vertically). This corresponds to the CSS 'justify-content' property.",
"enum": [
"start",
"center",
"end",
"spaceBetween",
"spaceAround",
"spaceEvenly"
]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis (horizontally). This corresponds to the CSS 'align-items' property.",
"enum": ["center", "end", "start", "stretch"]
}
},
"required": ["children"]
},
"List": {
"type": "object",
"properties": {
"children": {
"type": "object",
"description": "Defines the children. Use 'explicitList' for a fixed set of children, or 'template' to generate children from a data list.",
"properties": {
"explicitList": {
"type": "array",
"items": {
"type": "string"
}
},
"template": {
"type": "object",
"description": "A template for generating a dynamic list of children from a data model list. `componentId` is the component to use as a template, and `dataBinding` is the path to the map of components in the data model. Values in the map will define the list of children.",
"properties": {
"componentId": {
"type": "string"
},
"dataBinding": {
"type": "string"
}
},
"required": ["componentId", "dataBinding"]
}
}
},
"direction": {
"type": "string",
"description": "The direction in which the list items are laid out.",
"enum": ["vertical", "horizontal"]
},
"alignment": {
"type": "string",
"description": "Defines the alignment of children along the cross axis.",
"enum": ["start", "center", "end", "stretch"]
}
},
"required": ["children"]
},
"Card": {
"type": "object",
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to be rendered inside the card."
}
},
"required": ["child"]
},
"Tabs": {
"type": "object",
"properties": {
"tabItems": {
"type": "array",
"description": "An array of objects, where each object defines a tab with a title and a child component.",
"items": {
"type": "object",
"properties": {
"title": {
"type": "object",
"description": "The tab title. Defines the value as either a literal value or a path to data model value (e.g. '/options/title').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"child": {
"type": "string"
}
},
"required": ["title", "child"]
}
}
},
"required": ["tabItems"]
},
"Divider": {
"type": "object",
"properties": {
"axis": {
"type": "string",
"description": "The orientation of the divider.",
"enum": ["horizontal", "vertical"]
}
}
},
"Modal": {
"type": "object",
"properties": {
"entryPointChild": {
"type": "string",
"description": "The ID of the component that opens the modal when interacted with (e.g., a button)."
},
"contentChild": {
"type": "string",
"description": "The ID of the component to be displayed inside the modal."
}
},
"required": ["entryPointChild", "contentChild"]
},
"Button": {
"type": "object",
"properties": {
"child": {
"type": "string",
"description": "The ID of the component to display in the button, typically a Text component."
},
"primary": {
"type": "boolean",
"description": "Indicates if this button should be styled as the primary action."
},
"action": {
"type": "object",
"description": "The client-side action to be dispatched when the button is clicked. It includes the action's name and an optional context payload.",
"properties": {
"name": {
"type": "string"
},
"context": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"value": {
"type": "object",
"description": "Defines the value to be included in the context as either a literal value or a path to a data model value (e.g. '/user/name').",
"properties": {
"path": {
"type": "string"
},
"literalString": {
"type": "string"
},
"literalNumber": {
"type": "number"
},
"literalBoolean": {
"type": "boolean"
}
}
}
},
"required": ["key", "value"]
}
}
},
"required": ["name"]
}
},
"required": ["child", "action"]
},
"CheckBox": {
"type": "object",
"properties": {
"label": {
"type": "object",
"description": "The text to display next to the checkbox. Defines the value as either a literal value or a path to data model ('path', e.g. '/option/label').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "object",
"description": "The current state of the checkbox (true for checked, false for unchecked). This can be a literal boolean ('literalBoolean') or a reference to a value in the data model ('path', e.g. '/filter/open').",
"properties": {
"literalBoolean": {
"type": "boolean"
},
"path": {
"type": "string"
}
}
}
},
"required": ["label", "value"]
},
"TextField": {
"type": "object",
"properties": {
"label": {
"type": "object",
"description": "The text label for the input field. This can be a literal string or a reference to a value in the data model ('path, e.g. '/user/name').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"text": {
"type": "object",
"description": "The value of the text field. This can be a literal string or a reference to a value in the data model ('path', e.g. '/user/name').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"textFieldType": {
"type": "string",
"description": "The type of input field to display.",
"enum": [
"date",
"longText",
"number",
"shortText",
"obscured"
]
},
"validationRegexp": {
"type": "string",
"description": "A regular expression used for client-side validation of the input."
}
},
"required": ["label"]
},
"DateTimeInput": {
"type": "object",
"properties": {
"value": {
"type": "object",
"description": "The selected date and/or time value. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/user/dob').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"enableDate": {
"type": "boolean",
"description": "If true, allows the user to select a date."
},
"enableTime": {
"type": "boolean",
"description": "If true, allows the user to select a time."
},
"outputFormat": {
"type": "string",
"description": "The desired format for the output string after a date or time is selected."
}
},
"required": ["value"]
},
"MultipleChoice": {
"type": "object",
"properties": {
"selections": {
"type": "object",
"description": "The currently selected values for the component. This can be a literal array of strings or a path to an array in the data model('path', e.g. '/hotel/options').",
"properties": {
"literalArray": {
"type": "array",
"items": {
"type": "string"
}
},
"path": {
"type": "string"
}
}
},
"options": {
"type": "array",
"description": "An array of available options for the user to choose from.",
"items": {
"type": "object",
"properties": {
"label": {
"type": "object",
"description": "The text to display for this option. This can be a literal string or a reference to a value in the data model (e.g. '/option/label').",
"properties": {
"literalString": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"value": {
"type": "string",
"description": "The value to be associated with this option when selected."
}
},
"required": ["label", "value"]
}
},
"maxAllowedSelections": {
"type": "integer",
"description": "The maximum number of options that the user is allowed to select."
}
},
"required": ["selections", "options"]
},
"Slider": {
"type": "object",
"properties": {
"value": {
"type": "object",
"description": "The current value of the slider. This can be a literal number ('literalNumber') or a reference to a value in the data model ('path', e.g. '/restaurant/cost').",
"properties": {
"literalNumber": {
"type": "number"
},
"path": {
"type": "string"
}
}
},
"minValue": {
"type": "number",
"description": "The minimum value of the slider."
},
"maxValue": {
"type": "number",
"description": "The maximum value of the slider."
}
},
"required": ["value"]
}
}
}
},
"required": ["id", "component"]
}
}
},
"required": ["surfaceId", "components"]
},
"dataModelUpdate": {
"type": "object",
"description": "Updates the data model for a surface.",
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface this data model update applies to."
},
"path": {
"type": "string",
"description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', the entire data model will be replaced."
},
"contents": {
"type": "array",
"description": "An array of data entries. Each entry must contain a 'key' and exactly one corresponding typed 'value*' property.",
"items": {
"type": "object",
"description": "A single data entry. Exactly one 'value*' property should be provided alongside the key.",
"properties": {
"key": {
"type": "string",
"description": "The key for this data entry."
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
},
"valueMap": {
"description": "Represents a map as an adjacency list.",
"type": "array",
"items": {
"type": "object",
"description": "One entry in the map. Exactly one 'value*' property should be provided alongside the key.",
"properties": {
"key": {
"type": "string"
},
"valueString": {
"type": "string"
},
"valueNumber": {
"type": "number"
},
"valueBoolean": {
"type": "boolean"
}
},
"required": ["key"]
}
}
},
"required": ["key"]
}
}
},
"required": ["contents", "surfaceId"]
},
"deleteSurface": {
"type": "object",
"description": "Signals the client to delete the surface identified by 'surfaceId'.",
"properties": {
"surfaceId": {
"type": "string",
"description": "The unique identifier for the UI surface to be deleted."
}
},
"required": ["surfaceId"]
}
}
}
@@ -0,0 +1,238 @@
[
{
"id": "root",
"component": {
"Card": {
"child": "main-column"
}
}
},
{
"id": "main-column",
"component": {
"Column": {
"children": {
"explicitList": ["header-row", "events-list", "actions"]
},
"gap": "small"
}
}
},
{
"id": "header-row",
"component": {
"Row": {
"children": {
"explicitList": ["date-col", "events-col"]
},
"gap": "medium"
}
}
},
{
"id": "date-col",
"component": {
"Column": {
"children": {
"explicitList": ["day-name", "day-number", "month-name"]
},
"alignment": "start"
}
}
},
{
"id": "day-name",
"component": {
"Text": {
"text": {
"path": "/dayName"
},
"usageHint": "caption"
}
}
},
{
"id": "day-number",
"component": {
"Text": {
"text": {
"path": "/dayNumber"
},
"usageHint": "h1"
}
}
},
{
"id": "month-name",
"component": {
"Text": {
"text": {
"path": "/monthName"
},
"usageHint": "h2"
}
}
},
{
"id": "events-col",
"component": {
"Column": {
"children": {
"explicitList": ["event1", "event2", "event3"]
},
"gap": "small"
}
}
},
{
"id": "event1",
"component": {
"Column": {
"children": {
"explicitList": ["event1-title", "event1-time"]
}
}
}
},
{
"id": "event1-title",
"component": {
"Text": {
"text": {
"path": "/event1/title"
},
"usageHint": "body"
}
}
},
{
"id": "event1-time",
"component": {
"Text": {
"text": {
"path": "/event1/time"
},
"usageHint": "caption"
}
}
},
{
"id": "event2",
"component": {
"Column": {
"children": {
"explicitList": ["event2-title", "event2-time"]
}
}
}
},
{
"id": "event2-title",
"component": {
"Text": {
"text": {
"path": "/event2/title"
},
"usageHint": "body"
}
}
},
{
"id": "event2-time",
"component": {
"Text": {
"text": {
"path": "/event2/time"
},
"usageHint": "caption"
}
}
},
{
"id": "event3",
"component": {
"Column": {
"children": {
"explicitList": ["event3-title", "event3-time"]
}
}
}
},
{
"id": "event3-title",
"component": {
"Text": {
"text": {
"path": "/event3/title"
},
"usageHint": "body"
}
}
},
{
"id": "event3-time",
"component": {
"Text": {
"text": {
"path": "/event3/time"
},
"usageHint": "caption"
}
}
},
{
"id": "events-list",
"component": {
"Divider": {}
}
},
{
"id": "actions",
"component": {
"Row": {
"children": {
"explicitList": ["add-btn", "discard-btn"]
},
"gap": "small"
}
}
},
{
"id": "add-btn-text",
"component": {
"Text": {
"text": {
"literalString": "Add to calendar"
}
}
}
},
{
"id": "add-btn",
"component": {
"Button": {
"child": "add-btn-text",
"action": "add"
}
}
},
{
"id": "discard-btn-text",
"component": {
"Text": {
"text": {
"literalString": "Discard"
}
}
}
},
{
"id": "discard-btn",
"component": {
"Button": {
"child": "discard-btn-text",
"action": "discard"
}
}
}
]
@@ -0,0 +1,17 @@
{
"dayName": "Friday",
"dayNumber": "28",
"monthName": "March",
"event1": {
"title": "Lunch",
"time": "12:00 - 12:45 PM"
},
"event2": {
"title": "Q1 roadmap review",
"time": "1:00 - 2:00 PM"
},
"event3": {
"title": "Team standup (add more events if needed, e.g. event4, event5, etc.)",
"time": "3:30 - 4:00 PM"
}
}
@@ -0,0 +1,240 @@
[
{
"id": "root",
"component": {
"Card": {
"child": "main-column"
}
}
},
{
"id": "main-column",
"component": {
"Column": {
"children": {
"explicitList": [
"from-row",
"to-row",
"subject-row",
"divider",
"message",
"actions"
]
},
"gap": "small"
}
}
},
{
"id": "from-row",
"component": {
"Row": {
"children": {
"explicitList": ["from-label", "from-value"]
},
"gap": "medium",
"alignment": "center"
}
}
},
{
"id": "from-label",
"component": {
"Text": {
"text": {
"literalString": "FROM"
},
"usageHint": "caption"
}
}
},
{
"id": "from-value",
"component": {
"Text": {
"text": {
"path": "/from"
},
"usageHint": "body"
}
}
},
{
"id": "to-row",
"component": {
"Row": {
"children": {
"explicitList": ["to-label", "to-value"]
},
"gap": "medium",
"alignment": "center"
}
}
},
{
"id": "to-label",
"component": {
"Text": {
"text": {
"literalString": "TO"
},
"usageHint": "caption"
}
}
},
{
"id": "to-value",
"component": {
"Text": {
"text": {
"path": "/to"
},
"usageHint": "body"
}
}
},
{
"id": "subject-row",
"component": {
"Row": {
"children": {
"explicitList": ["subject-label", "subject-value"]
},
"gap": "medium",
"alignment": "center"
}
}
},
{
"id": "subject-label",
"component": {
"Text": {
"text": {
"literalString": "SUBJECT"
},
"usageHint": "caption"
}
}
},
{
"id": "subject-value",
"component": {
"Text": {
"text": {
"path": "/subject"
},
"usageHint": "body"
}
}
},
{
"id": "divider",
"component": {
"Divider": {}
}
},
{
"id": "message",
"component": {
"Column": {
"children": {
"explicitList": ["greeting", "body-text", "closing", "signature"]
},
"gap": "small"
}
}
},
{
"id": "greeting",
"component": {
"Text": {
"text": {
"path": "/greeting"
},
"usageHint": "body"
}
}
},
{
"id": "body-text",
"component": {
"Text": {
"text": {
"path": "/body"
},
"usageHint": "body"
}
}
},
{
"id": "closing",
"component": {
"Text": {
"text": {
"path": "/closing"
},
"usageHint": "body"
}
}
},
{
"id": "signature",
"component": {
"Text": {
"text": {
"path": "/signature"
},
"usageHint": "body"
}
}
},
{
"id": "actions",
"component": {
"Row": {
"children": {
"explicitList": ["send-btn", "discard-btn"]
},
"gap": "small"
}
}
},
{
"id": "send-btn-text",
"component": {
"Text": {
"text": {
"literalString": "Send email"
}
}
}
},
{
"id": "send-btn",
"component": {
"Button": {
"child": "send-btn-text",
"action": "send"
}
}
},
{
"id": "discard-btn-text",
"component": {
"Text": {
"text": {
"literalString": "Discard"
}
}
}
},
{
"id": "discard-btn",
"component": {
"Button": {
"child": "discard-btn-text",
"action": "discard"
}
}
}
]
@@ -0,0 +1,9 @@
{
"from": "alex@acme.com",
"to": "jordan@acme.com",
"subject": "Q4 Revenue Forecast",
"greeting": "Hi Jordan,",
"body": "Following up on our call. Please review the attached Q4 forecast and let me know if you have questions before the board meeting.",
"closing": "Best,",
"signature": "Alex"
}
@@ -0,0 +1,35 @@
"""
Agent Spec helpers and server tools.
This module provides utilities to:
- Load the Agent Spec JSON used by the backend
- Define server-side tool implementations
- Build an AgentSpecAgent configured with the JSON spec and tools
It is used by the FastAPI app in main.py.
"""
from __future__ import annotations
from ag_ui_agentspec.agent import AgentSpecAgent
def build_a2ui_chat_agent(runtime: str = "langgraph") -> AgentSpecAgent:
from a2ui_agentspec_agent import a2ui_demo_tool_registry, a2ui_chat_json
return AgentSpecAgent(
agent_spec_config=a2ui_chat_json,
runtime=runtime,
tool_registry=a2ui_demo_tool_registry,
)
def build_agentspec_agent(runtime: str = "langgraph") -> AgentSpecAgent:
"""Create an AgentSpecAgent configured from the JSON spec and server tools."""
from agentspec_agent import get_weather, with_agentspec_agent_json
return AgentSpecAgent(
agent_spec_config=with_agentspec_agent_json,
runtime=runtime,
tool_registry={"get_weather": get_weather},
)
@@ -0,0 +1,87 @@
from __future__ import annotations
import os
from typing import Dict, Any
import dotenv
dotenv.load_dotenv()
from pyagentspec.agent import Agent
from pyagentspec.llms import OpenAiCompatibleConfig
from pyagentspec.tools import ServerTool, ClientTool
from pyagentspec.property import Property
from pyagentspec.serialization import AgentSpecSerializer
def get_weather(location: str) -> Dict[str, Any]:
"""
Get the weather for a given location.
"""
import time
time.sleep(1) # simulates real tool execution
return {
"temperature": 20,
"conditions": "sunny",
"humidity": 50,
"wind_speed": 10,
"feelsLike": 25,
}
weather_tool = ServerTool(
name="get_weather",
description="Get the weather for a given location.",
inputs=[
Property(
title="location",
json_schema={
"title": "location",
"type": "string",
"description": "The location to get the weather forecast. Must be a city/town name.",
},
)
],
outputs=[
Property(
title="weather_result",
json_schema={"title": "weather_result", "type": "string"},
)
],
)
go_to_moon_tool = ClientTool(
name="go_to_moon",
description="Go to the moon on request.",
)
setThemeColor_tool = ClientTool(
name="setThemeColor",
description="Change the theme color of the chat. Can be anything that the CSS background attribute accepts. Regular colors, linear of radial gradients etc.",
inputs=[
Property(
title="themeColor",
json_schema={
"title": "themeColor",
"type": "string",
"description": "The theme color to set. Make sure to pick nice colors.",
},
)
],
)
with_agentspec_agent = Agent(
name="AgentSpecAgent",
description="A starter Agent that can call tools.",
system_prompt="You are a helpful assistant, named Specky, that has access to some tools.",
llm_config=OpenAiCompatibleConfig(
name="with-agentspec-agent",
model_id=os.getenv("OPENAI_MODEL", "gpt-4o"),
url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
),
tools=[weather_tool, go_to_moon_tool, setThemeColor_tool],
)
with_agentspec_agent_json = AgentSpecSerializer().to_json(with_agentspec_agent)
@@ -0,0 +1,40 @@
import logging
def configure_logging() -> None:
"""Enable INFO logs for ag_ui_agentspec and pyagentspec and attach a console handler.
Uvicorn's default logging config doesn't automatically show 3rdparty logger output.
We install a root handler and set levels explicitly so logs appear in the terminal.
"""
root = logging.getLogger()
if not root.handlers:
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
)
root.addHandler(handler)
root.setLevel(logging.INFO)
for h in root.handlers:
try:
h.setLevel(logging.INFO)
except Exception:
pass
# Turn on INFO for relevant namespaces and propagate to root
for name in (
"ag_ui_agentspec",
"ag_ui_agentspec.endpoint",
"ag_ui_agentspec.tracing",
"pyagentspec",
"wayflowcore",
):
lg = logging.getLogger(name)
lg.setLevel(logging.INFO)
lg.propagate = True
# Also make uvicorn loggers propagate to our root handler
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
lg = logging.getLogger(name)
lg.setLevel(logging.INFO)
lg.propagate = True
@@ -0,0 +1,35 @@
import os
import uvicorn
from fastapi import FastAPI
from agent import build_a2ui_chat_agent
from logging_utils import configure_logging
from ag_ui_agentspec.endpoint import add_agentspec_fastapi_endpoint
def build_server() -> FastAPI:
configure_logging()
app = FastAPI(title="Agent Spec Agent")
agent = build_a2ui_chat_agent(runtime="wayflow")
add_agentspec_fastapi_endpoint(app, agent, path="/")
return app
app = build_server()
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=int(os.getenv("PORT", "8000")),
reload=True,
log_level="info",
log_config=None, # use our logging config below
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf0069bffdeef2915c6e0f1a79e9e233948f4746d349322ea3b089861c13013f
size 42222657
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,44 @@
{
"name": "agent-spec-starter",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agentspecagent --prefix-colors blue,green --kill-others",
"dev:debug": "LOG_LEVEL=debug npm run dev",
"dev:agent": "./scripts/run-agent.sh",
"dev:ui": "next dev --turbopack",
"patch:ui": "./scripts/apply-ui-patches.sh",
"build": "next build",
"start": "next start",
"install:agent": "./scripts/setup-agent.sh",
"postinstall": "npm run install:agent"
},
"dependencies": {
"@a2ui/lit": "^0.8.1",
"@ag-ui/a2ui-middleware": "0.0.5",
"@ag-ui/client": "0.0.57",
"@ag-ui/core": "0.0.57",
"@ag-ui/encoder": "0.0.57",
"@ag-ui/proto": "0.0.57",
"@copilotkit/a2ui-renderer": "1.61.0",
"@copilotkit/react-core": "1.61.0",
"@copilotkit/runtime": "1.61.0",
"hono": "^4.11.4",
"next": "16.0.10",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"zod": "^3.25.75"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@types/node": "^22.15.3",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"concurrently": "^9.2.1",
"postcss": "^8.4.49",
"tailwindcss": "^4.1.13",
"typescript": "5.9.2"
},
"resolutions": {}
}
@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/../agent" || exit 1
uv run src/main.py
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/../agent" || exit 1
uv sync
@@ -0,0 +1,46 @@
import {
CopilotRuntime,
CopilotKitIntelligence,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { handle } from "hono/vercel";
import { HttpAgent } from "@ag-ui/client";
import { A2UIMiddleware } from "@ag-ui/a2ui-middleware";
const agent = new HttpAgent({
url:
process.env.AGENT_URL ||
process.env.NEXT_PUBLIC_AGENT_URL ||
"http://localhost:8000/",
});
agent.use(new A2UIMiddleware({ injectA2UITool: true }));
const runtime = new CopilotRuntime({
agents: { my_a2ui_agent: agent },
a2ui: {},
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your own auth-derived user identity (e.g. OIDC)
// before any multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new InMemoryAgentRunner() }),
// --- /copilotkit:intelligence ---
});
const app = createCopilotEndpoint({ runtime, basePath: "/api/copilotkit" });
export const GET = handle(app);
export const POST = handle(app);
export const PATCH = handle(app);
export const DELETE = handle(app);
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,24 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
body,
html {
height: 100%;
}
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import "./globals.css";
import "./style.css";
export const metadata: Metadata = {
title: "Scheduling Assistant",
description:
"AI scheduling assistant demo — manage your calendar, inbox, and emails",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){var d=document.documentElement;var m=window.matchMedia('(prefers-color-scheme:dark)');function u(e){d.classList.toggle('dark',e.matches)}u(m);m.addEventListener('change',u)})()`,
}}
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
/>
</head>
{/*
suppressHydrationWarning: browser extensions (e.g. Grammarly) inject
attributes like data-gr-ext-installed onto <body> before React hydrates,
which would otherwise surface as a hydration mismatch on first load.
This only relaxes the check for <body>'s own attributes (one level deep);
everything rendered inside <body> is still fully hydration-checked.
*/}
<body className={"antialiased"} suppressHydrationWarning>
<div className="flex h-dvh w-screen flex-col min-h-0 overflow-hidden bg-background">
{children}
</div>
</body>
</html>
);
}
@@ -0,0 +1,908 @@
"use client";
import "@copilotkit/react-core/v2/styles.css";
import {
CopilotChat,
CopilotChatConfigurationProvider,
CopilotKitProvider,
createA2UIMessageRenderer,
ToolCallStatus,
useFrontendTool,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import {
A2UIProvider,
A2UIRenderer,
useA2UIActions,
viewerTheme,
} from "@copilotkit/a2ui-renderer";
import { z } from "zod";
import {
useState,
useCallback,
useEffect,
useId,
useMemo,
useRef,
} from "react";
import { withA2UIActivityMessage } from "@/components/a2ui-activity-wrapper";
import { theme } from "./theme";
import { CalendarView, CalendarLoadingState } from "@/components/calendar-view";
import type { CalendarEvent } from "@/components/calendar-view";
import { InboxView, InboxLoadingState } from "@/components/inbox-view";
import type { Email } from "@/components/inbox-view";
import {
EmailComposeView,
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";
const BaseA2UIMessageRenderer = createA2UIMessageRenderer({ theme });
const A2UIMessageRenderer = withA2UIActivityMessage(BaseA2UIMessageRenderer);
const activityRenderers = [A2UIMessageRenderer];
// ---------------------------------------------------------------------------
// Canvas state — supports A2UI dashboard + native React components
// ---------------------------------------------------------------------------
type CanvasContent =
| {
type: "dashboard";
root: string;
components: any[];
data: Record<string, unknown>;
}
| { type: "calendar"; date: string; dayName: string; events: CalendarEvent[] }
| { type: "inbox"; emails: Email[] }
| { type: "email"; email: EmailComposeData };
interface CanvasState {
mode: "chat" | "canvas";
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",
inbox: "Inbox",
email: "Email",
};
// ---------------------------------------------------------------------------
// Shared parsing helpers — used by both handler and render callbacks
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Dedup identical assistant text messages — works around LLMs that repeat
// text before and after tool calls in the same turn. Tool/activity messages
// may sit between the duplicates so we scan the full window, not just
// adjacent pairs.
// ---------------------------------------------------------------------------
function deduplicateMessages(
messages: Array<{ role: string; content?: unknown; toolCalls?: unknown[] }>,
elements: React.ReactElement[],
): React.ReactElement[] {
if (messages.length !== elements.length) return elements;
const dominated = new Set<number>();
const seen = new Map<string, number>(); // content → first index
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (msg.role !== "assistant" || typeof msg.content !== "string") continue;
const text = msg.content.trim();
if (!text) continue;
const prev = seen.get(text);
if (prev !== undefined && !msg.toolCalls?.length) {
// This is a text-only repeat of an earlier assistant message — hide it
dominated.add(i);
} else if (prev === undefined) {
seen.set(text, i);
}
}
if (dominated.size === 0) return elements;
return elements.filter((_, i) => !dominated.has(i));
}
function parseEmailList(raw: string): Email[] {
const parsed = JSON.parse(raw);
return parsed.map((e: any, i: number) => ({
id: String(i),
from: e.from,
subject: e.subject,
preview: e.body?.slice(0, 80) || "",
body: e.body || "",
date: e.date || "Today",
isRead: e.isRead ?? false,
}));
}
function parseCalendarEvents(raw: string): CalendarEvent[] {
return JSON.parse(raw);
}
function parseEmailCompose(raw: string): EmailComposeData {
return JSON.parse(raw);
}
// ---------------------------------------------------------------------------
// Shared loading spinner
// ---------------------------------------------------------------------------
function LoadingSpinner({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-md text-gray-500 text-sm mb-3">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>{label}</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact tool card — shown in chat sidebar when content is in canvas
// ---------------------------------------------------------------------------
const CARD_ICONS: Record<string, React.ReactNode> = {
calendar: (
<svg
className="compact-tool-card-icon compact-tool-card-icon--indigo"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
),
inbox: (
<svg
className="compact-tool-card-icon compact-tool-card-icon--blue"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
),
email: (
<svg
className="compact-tool-card-icon compact-tool-card-icon--emerald"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
),
dashboard: (
<svg
className="compact-tool-card-icon compact-tool-card-icon--indigo"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/>
</svg>
),
};
function CompactToolCard({
icon,
title,
summary,
buttonLabel = "Show inline",
onAction,
}: {
icon: string;
title: string;
summary: string;
buttonLabel?: string;
onAction: () => void;
}) {
const isCanvasAction = buttonLabel.toLowerCase().includes("canvas");
return (
<div className="compact-tool-card">
<div className="compact-tool-card-header">
{CARD_ICONS[icon] ?? CARD_ICONS.dashboard}
<div className="compact-tool-card-text">
<span className="compact-tool-card-title">{title}</span>
<span className="compact-tool-card-summary">{summary}</span>
</div>
</div>
<button
type="button"
className="compact-tool-card-btn"
onClick={onAction}
>
{buttonLabel}
{isCanvasAction ? (
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
) : (
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"
/>
</svg>
)}
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Canvas panel — renders content by type
// ---------------------------------------------------------------------------
function CanvasContentRenderer({ content }: { content: CanvasContent }) {
switch (content.type) {
case "dashboard":
return (
<A2UIStaticViewer
root={content.root}
components={content.components}
data={content.data}
styles={{
primaryColor: "#4f46e5",
font: "Inter, system-ui, sans-serif",
}}
className="canvas-a2ui"
/>
);
case "calendar":
return (
<div className="canvas-content-centered">
<CalendarView
date={content.date}
dayName={content.dayName}
events={content.events}
/>
</div>
);
case "inbox":
return (
<div className="canvas-content-centered">
<InboxView emails={content.emails} />
</div>
);
case "email":
return (
<div className="canvas-content-centered">
<EmailComposeView email={content.email} />
</div>
);
}
}
function Canvas({
content,
onClose,
}: {
content: CanvasContent;
onClose: () => void;
}) {
return (
<div className="canvas-panel">
<div className="canvas-header">
<div className="canvas-header-left">
{CARD_ICONS[content.type]}
<span className="canvas-header-title">
{CANVAS_TITLES[content.type]}
</span>
</div>
<button
type="button"
className="canvas-header-close"
onClick={onClose}
title="Close canvas"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6L6 18" />
<path d="M6 6l12 12" />
</svg>
</button>
</div>
<div className="canvas-body">
<CanvasContentRenderer content={content} />
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Chat component — registers all frontend tools
// ---------------------------------------------------------------------------
function Chat({
isCanvasMode,
hasCanvasContent,
onCanvasUpdate,
onShowChat,
onShowCanvas,
}: {
isCanvasMode: boolean;
hasCanvasContent: boolean;
onCanvasUpdate: (content: CanvasContent) => void;
onShowChat: () => void;
onShowCanvas: () => void;
}) {
const isCanvasModeRef = useRef(isCanvasMode);
isCanvasModeRef.current = isCanvasMode;
// A2UI fallback tool
useFrontendTool(
{
name: "send_a2ui_json_to_client",
description: "Sends A2UI JSON to the client to render rich UI",
parameters: z.object({ a2ui_json: z.string() }) as any,
render: ({ status }) =>
status !== ToolCallStatus.Complete ? (
<LoadingSpinner label="Building interface..." />
) : null,
},
[],
);
// Daily brief — always routes to canvas
useFrontendTool(
{
name: "render_daily_brief",
description: "Renders a daily brief dashboard in the canvas workspace",
parameters: z.object({
components: z.string(),
data: z.string(),
root: z.string(),
}) as any,
handler: async ({
components,
data,
root,
}: {
components: string;
data: string;
root: string;
}) => {
try {
onCanvasUpdate({
type: "dashboard",
root,
components: JSON.parse(components),
data: JSON.parse(data),
});
} catch (e) {
console.error("Failed to parse daily brief data", e);
return "Failed to render dashboard - invalid JSON";
}
return "Dashboard rendered in canvas workspace";
},
render: ({ status }) => {
if (status !== ToolCallStatus.Complete)
return <LoadingSpinner label="Building dashboard..." />;
if (isCanvasMode) {
return (
<CompactToolCard
icon="dashboard"
title="Daily Brief"
summary="Opened in canvas"
buttonLabel="Show inline"
onAction={onShowChat}
/>
);
}
// In chat mode: show a card with "Show in canvas" to restore
return (
<CompactToolCard
icon="dashboard"
title="Daily Brief"
summary={hasCanvasContent ? "Dashboard ready" : "Opened in canvas"}
buttonLabel="Show in canvas"
onAction={onShowCanvas}
/>
);
},
},
[onCanvasUpdate, onShowChat, onShowCanvas, isCanvasMode, hasCanvasContent],
);
// Calendar — routes to canvas when open, inline otherwise
useFrontendTool(
{
name: "render_calendar",
description: "Renders a rich calendar day-view with the user's schedule",
parameters: z.object({
date: z.string(),
dayName: z.string(),
events: z.string(),
}) as any,
handler: async ({
date,
dayName,
events,
}: {
date: string;
dayName: string;
events: string;
}) => {
if (isCanvasModeRef.current) {
try {
onCanvasUpdate({
type: "calendar",
date,
dayName,
events: parseCalendarEvents(events),
});
} catch (e) {
console.error("Failed to parse calendar for canvas", e);
}
}
return "Calendar rendered";
},
render: ({ status, args }) => {
if (status !== ToolCallStatus.Complete || !args)
return <CalendarLoadingState />;
let events: CalendarEvent[] = [];
try {
events = parseCalendarEvents(args.events);
} catch {
/* empty */
}
if (isCanvasMode) {
const bookedCount = events.filter((e) => !e.isAvailable).length;
return (
<CompactToolCard
icon="calendar"
title={`Schedule — ${args.dayName}`}
summary={`${bookedCount} meeting${bookedCount !== 1 ? "s" : ""} today`}
onAction={onShowChat}
/>
);
}
return (
<CalendarView
date={args.date}
dayName={args.dayName}
events={events}
/>
);
},
},
[isCanvasMode, onCanvasUpdate, onShowChat],
);
// Inbox — routes to canvas when open, inline otherwise
useFrontendTool(
{
name: "render_inbox",
description: "Renders a Gmail-style inbox view with email messages",
parameters: z.object({
emails: z.string(),
}) as any,
handler: async ({ emails }: { emails: string }) => {
if (isCanvasModeRef.current) {
try {
onCanvasUpdate({ type: "inbox", emails: parseEmailList(emails) });
} catch (e) {
console.error("Failed to parse inbox for canvas", e);
}
}
return "Inbox rendered";
},
render: ({ status, args }) => {
if (status !== ToolCallStatus.Complete || !args)
return <InboxLoadingState />;
let emails: Email[] = [];
try {
emails = parseEmailList(args.emails);
} catch {
/* empty */
}
if (isCanvasMode) {
const unread = emails.filter((e) => !e.isRead).length;
return (
<CompactToolCard
icon="inbox"
title="Inbox"
summary={`${emails.length} message${emails.length !== 1 ? "s" : ""} · ${unread} unread`}
onAction={onShowChat}
/>
);
}
return <InboxView emails={emails} />;
},
},
[isCanvasMode, onCanvasUpdate, onShowChat],
);
// Email compose — routes to canvas when open, inline otherwise
useFrontendTool(
{
name: "render_email_compose",
description: "Renders a Gmail-style email compose view",
parameters: z.object({
email: z.string(),
}) as any,
handler: async ({ email }: { email: string }) => {
if (isCanvasModeRef.current) {
try {
onCanvasUpdate({ type: "email", email: parseEmailCompose(email) });
} catch (e) {
console.error("Failed to parse email for canvas", e);
}
}
return "Email compose rendered";
},
render: ({ status, args }) => {
if (status !== ToolCallStatus.Complete || !args)
return <EmailComposeLoadingState />;
let email: EmailComposeData = { to: "", subject: "", body: "" };
try {
email = parseEmailCompose(args.email);
} catch {
/* empty */
}
if (isCanvasMode) {
const isReply = email.subject.startsWith("Re:");
return (
<CompactToolCard
icon="email"
title={isReply ? "Reply" : "New Message"}
summary={`To: ${email.to} · ${email.subject}`}
onAction={onShowChat}
/>
);
}
return <EmailComposeView email={email} />;
},
},
[isCanvasMode, onCanvasUpdate, onShowChat],
);
useConfigureSuggestions(
{
suggestions: [
{ title: "Show inbox", message: "Check my inbox" },
{ title: "Show calendar", message: "Show me my schedule for today" },
{ title: "Write brief", message: "Create my daily brief" },
],
available: "always",
},
[],
);
const [isAtBottom, setIsAtBottom] = useState(true);
const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
const sentinelRef = useCallback((node: HTMLDivElement | null) => {
setSentinelEl(node);
}, []);
useEffect(() => {
if (!sentinelEl) return;
// Use viewport root — the sentinel is inside a nested scroll container that
// clips it via CSS overflow, so the IO correctly reports it as not-intersecting
// when the user scrolls up (the sentinel is visually clipped out of view).
const io = new IntersectionObserver(
([entry]) => setIsAtBottom(entry.isIntersecting),
{ threshold: 0.1 },
);
io.observe(sentinelEl);
return () => io.disconnect();
}, [sentinelEl]);
return (
<CopilotChat
className="flex-1 min-h-0 overflow-hidden"
agentId="my_a2ui_agent"
labels={{
welcomeMessageText: "How can I help you today?",
chatInputPlaceholder: isCanvasMode
? "Type a message..."
: "Ask about your schedule, inbox, or compose an email...",
}}
messageView={{
children: ({
messages,
messageElements,
interruptElement,
isRunning,
}) => (
<>
{deduplicateMessages(messages, messageElements)}
{interruptElement}
{isRunning && (
<div className="cpk:mt-2">
<div
data-testid="copilot-loading-cursor"
className="cpk:w-[11px] cpk:h-[11px] cpk:rounded-full cpk:bg-foreground cpk:animate-pulse-cursor cpk:ml-1"
/>
</div>
)}
<div ref={sentinelRef} style={{ height: 1, width: "100%" }} />
</>
),
}}
>
{({ scrollView, input, suggestionView }) => (
<div className="copilot-custom-chat">
{scrollView}
<div
className="chips-above-input"
style={{
opacity: isAtBottom ? 1 : 0,
pointerEvents: isAtBottom ? undefined : "none",
transition: "opacity 0.2s ease",
}}
>
{suggestionView}
</div>
{input}
</div>
)}
</CopilotChat>
);
}
// ---------------------------------------------------------------------------
// Page — orchestrates layout modes
// ---------------------------------------------------------------------------
export default function Page() {
const [canvas, setCanvas] = useState<CanvasState>({
mode: "chat",
content: null,
});
const [threadId, setThreadId] = useState<string | undefined>(undefined);
const handleCanvasUpdate = useCallback((content: CanvasContent) => {
setCanvas({ mode: "canvas", content });
}, []);
const handleShowChat = useCallback(() => {
setCanvas((prev) => ({ ...prev, mode: "chat" }));
}, []);
const handleShowCanvas = useCallback(() => {
setCanvas((prev) => {
if (prev.content) return { ...prev, mode: "canvas" };
return prev;
});
}, []);
const isCanvasMode = canvas.mode === "canvas" && canvas.content !== null;
return (
<CopilotKitProvider
runtimeUrl="/api/copilotkit"
useSingleEndpoint={false}
showDevConsole="auto"
renderActivityMessages={activityRenderers}
>
<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,669 @@
/* === Theme variables === */
:root {
--surface-primary: #fff;
--surface-secondary: #fafafa;
--surface-tertiary: #f9fafb;
--surface-quaternary: #f3f4f6;
--surface-btn: #fff;
--surface-btn-hover: #f9fafb;
--surface-canvas-header: #fff;
--surface-dialog: #fff;
--text-primary: #374151;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--border-default: #e5e7eb;
--border-hover: #d1d5db;
--border-subtle: #f0f0f0;
--border-light: #f3f4f6;
--border-card: transparent;
--accent-indigo: #6366f1;
--accent-blue: #3b82f6;
--accent-emerald: #10b981;
--backdrop: rgba(0, 0, 0, 0.4);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04);
--shadow-card-hover:
0 2px 6px rgba(0, 0, 0, 0.08), 0 4px 16px rgba(0, 0, 0, 0.04);
--shadow-compact:
0 1px 3px rgba(0, 0, 0, 0.06), 0 2px 8px rgba(0, 0, 0, 0.03);
--shadow-compact-hover:
0 2px 6px rgba(0, 0, 0, 0.08), 0 4px 16px rgba(0, 0, 0, 0.04);
--shadow-canvas-card:
0 1px 3px rgba(0, 0, 0, 0.08), 0 8px 20px rgba(0, 0, 0, 0.06);
--shadow-dialog: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
@media (prefers-color-scheme: dark) {
:root {
--surface-primary: rgba(0, 0, 0, 0.35);
--surface-secondary: #111;
--surface-tertiary: rgba(255, 255, 255, 0.1);
--surface-quaternary: rgba(255, 255, 255, 0.08);
--surface-btn: rgba(255, 255, 255, 0.06);
--surface-btn-hover: rgba(255, 255, 255, 0.1);
--surface-canvas-header: rgba(0, 0, 0, 0.3);
--surface-dialog: #1f2937;
--text-primary: #e5e7eb;
--text-secondary: #9ca3af;
--text-tertiary: #6b7280;
--border-default: rgba(255, 255, 255, 0.1);
--border-hover: rgba(255, 255, 255, 0.16);
--border-subtle: rgba(255, 255, 255, 0.1);
--border-light: rgba(255, 255, 255, 0.08);
--border-card: rgba(255, 255, 255, 0.1);
--backdrop: rgba(0, 0, 0, 0.6);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-card-hover: 0 2px 6px rgba(0, 0, 0, 0.4);
--shadow-compact: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-compact-hover: 0 2px 6px rgba(0, 0, 0, 0.4);
--shadow-canvas-card: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-dialog: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
}
/* CopilotKit v2 dark mode — media-query override so we don't need .dark class
(avoids Next.js hydration mismatch from toggling className on <html>). */
@media (prefers-color-scheme: dark) {
[data-copilotkit][data-copilotkit] {
--background: oklch(14.5% 0 0);
--foreground: oklch(98.5% 0 0);
--card: oklch(14.5% 0 0);
--card-foreground: oklch(98.5% 0 0);
--popover: oklch(14.5% 0 0);
--popover-foreground: oklch(98.5% 0 0);
--primary: oklch(98.5% 0 0);
--primary-foreground: oklch(20.5% 0 0);
--secondary: oklch(26.9% 0 0);
--secondary-foreground: oklch(98.5% 0 0);
--muted: oklch(26.9% 0 0);
--muted-foreground: oklch(70.8% 0 0);
--accent: oklch(26.9% 0 0);
--accent-foreground: oklch(98.5% 0 0);
--destructive: oklch(39.6% 0.141 25.723);
--destructive-foreground: oklch(63.7% 0.237 25.331);
--border: oklch(26.9% 0 0);
--input: oklch(26.9% 0 0);
--ring: oklch(55.6% 0 0);
--sidebar: oklch(20.5% 0 0);
--sidebar-foreground: oklch(98.5% 0 0);
--sidebar-primary: oklch(48.8% 0.243 264.376);
--sidebar-primary-foreground: oklch(98.5% 0 0);
--sidebar-accent: oklch(26.9% 0 0);
--sidebar-accent-foreground: oklch(98.5% 0 0);
--sidebar-border: oklch(26.9% 0 0);
--sidebar-ring: oklch(43.9% 0 0);
}
/* CopilotKit v1 fallback variables */
:root {
--copilot-kit-primary-color: rgb(255, 255, 255);
--copilot-kit-contrast-color: rgb(28, 28, 28);
--copilot-kit-background-color: rgb(17, 17, 17);
--copilot-kit-input-background-color: #2c2c2c;
--copilot-kit-secondary-color: rgb(28, 28, 28);
--copilot-kit-secondary-contrast-color: rgb(255, 255, 255);
--copilot-kit-separator-color: rgb(45, 45, 45);
--copilot-kit-muted-color: rgb(45, 45, 45);
--copilot-kit-error-background: #7f1d1d;
--copilot-kit-error-border: #dc2626;
--copilot-kit-error-text: #fca5a5;
--copilot-kit-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--copilot-kit-shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--copilot-kit-shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--copilot-kit-dev-console-bg: #1a1a1a;
--copilot-kit-dev-console-text: white;
}
}
/* Fix for messages being hidden behind the absolutely-positioned input */
.a2ui-chat-container [class*="overflow-y-scroll"] {
padding-bottom: 120px !important;
}
/* === Custom chat layout: chips above input pill === */
/* The children render prop layout container */
.copilot-custom-chat {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
}
/* Hide the duplicate suggestions inside the scroll area (they're also rendered
separately in .chips-above-input via the suggestionView render prop) */
.copilot-custom-chat > :first-child [data-testid="copilot-suggestions"] {
display: none !important;
}
/* Floating suggestion chips positioned just above the input pill */
.chips-above-input {
position: absolute;
bottom: 110px;
left: 0;
right: 0;
z-index: 15;
display: flex;
justify-content: center;
padding: 0 16px;
pointer-events: none;
}
[data-sidebar-chat] .chips-above-input {
bottom: 96px;
padding: 0 8px;
}
/* Hide the scroll-to-bottom button (overlaps with chips) */
[data-testid="copilot-scroll-to-bottom"] {
display: none !important;
}
/* === Suggestion chips: welcome screen reordering === */
/* On the welcome screen (no messages), CopilotChatView renders its own layout.
Reorder children so chips appear above the input pill. */
[data-testid="copilot-welcome-screen"] > div > div:nth-child(2) {
order: 3;
}
[data-testid="copilot-welcome-screen"] > div > div:nth-child(3) {
order: 2;
width: 100%;
justify-content: center;
margin-top: 0;
margin-bottom: 12px;
padding-inline: 0;
}
/* === Suggestion chips: shared styles === */
/* Single non-wrapping row with hidden scrollbar */
[data-testid="copilot-suggestions"] {
flex-wrap: nowrap !important;
overflow-x: auto !important;
overflow-y: hidden !important;
max-width: 100% !important;
padding-bottom: 2px; /* prevent clipping box-shadow on pills */
justify-content: center;
/* Hide scrollbar (still scrollable) */
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-testid="copilot-suggestions"]::-webkit-scrollbar {
display: none;
}
/* Use the system font for A2UI surfaces to match the rest of the UI. */
.a2ui-chat-container .a2ui-surface {
--font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
--font-family-flex:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
/* A2UI activity messages — clean card matching inbox/calendar components. */
.a2ui-chat-container .a2ui-activity-message {
position: relative;
margin: 12px 0;
border-radius: 12px;
border: 1px solid var(--border-card);
background: var(--surface-primary);
overflow: hidden;
box-shadow: var(--shadow-card);
}
.a2ui-chat-container .a2ui-activity-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-light);
background: transparent;
}
.a2ui-chat-container .a2ui-activity-message--flash {
animation: a2ui-activity-flash 700ms ease-out;
}
@keyframes a2ui-activity-flash {
0% {
outline: 2px solid rgba(99, 102, 241, 0.5);
outline-offset: 2px;
}
100% {
outline: 2px solid transparent;
outline-offset: 8px;
}
}
.a2ui-chat-container .a2ui-activity-header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
letter-spacing: normal;
text-transform: none;
}
.a2ui-chat-container .a2ui-activity-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--accent-indigo);
box-shadow: none;
flex: none;
}
.a2ui-chat-container .a2ui-activity-meta {
font-weight: 400;
letter-spacing: normal;
text-transform: none;
opacity: 0.6;
white-space: nowrap;
}
.a2ui-chat-container .a2ui-activity-toggle {
appearance: none;
border: 1px solid var(--border-default);
background: var(--surface-btn);
color: var(--text-primary);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.a2ui-chat-container .a2ui-activity-toggle:hover {
background: var(--surface-btn-hover);
border-color: var(--border-hover);
}
.a2ui-chat-container .a2ui-activity-toggle:active {
transform: translateY(1px);
}
.a2ui-chat-container .a2ui-activity-toggle:focus-visible {
outline: 2px solid rgba(99, 102, 241, 0.5);
outline-offset: 2px;
}
.a2ui-chat-container .a2ui-activity-body {
padding: 12px 16px 16px;
}
/* The base A2UI renderer adds generous vertical padding; remove it inside the wrapper. */
.a2ui-chat-container .a2ui-activity-body > div {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.a2ui-chat-container .a2ui-activity-message--collapsed .a2ui-activity-body {
display: none;
}
/* === Split Layout: Canvas + Chat Sidebar === */
.layout-chat {
flex-direction: column;
}
.layout-split {
flex-direction: row;
}
/* Canvas panel (left side) */
.canvas-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid var(--border-default);
background: var(--surface-secondary);
overflow: hidden;
}
.canvas-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border-default);
background: var(--surface-canvas-header);
}
.canvas-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.canvas-header-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.canvas-header-close {
appearance: none;
border: none;
background: transparent;
color: var(--text-tertiary);
border-radius: 6px;
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
color 120ms ease,
background 120ms ease;
}
.canvas-header-close:hover {
background: var(--surface-quaternary);
color: var(--text-primary);
}
.canvas-header-close:active {
transform: translateY(1px);
}
.canvas-body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.canvas-a2ui {
max-width: 640px;
margin: 0 auto;
}
.canvas-content-centered {
max-width: 560px;
margin: 0 auto;
}
/* Chat sidebar (right side in split mode) */
.chat-sidebar {
width: 400px;
min-width: 320px;
max-width: 480px;
border-left: none;
}
/* Force compact single-row input layout in sidebar.
CopilotKit's layout evaluator switches to "expanded" (2-row) mode in narrow
containers. Override to keep the + | textarea | send layout on one row. */
[data-sidebar-chat] [data-testid="copilot-chat-input"] > div {
grid-template-rows: auto !important;
align-items: center !important;
}
[data-sidebar-chat] [data-testid="copilot-chat-input"] > div > div:first-child {
grid-row: 1 !important;
}
[data-sidebar-chat]
[data-testid="copilot-chat-input"]
> div
> div:nth-child(2) {
grid-column: 2 / 3 !important;
grid-row: 1 !important;
}
[data-sidebar-chat] [data-testid="copilot-chat-input"] > div > div:last-child {
grid-row: 1 !important;
}
/* === Compact Tool Cards (chat sidebar when content is in canvas) === */
.compact-tool-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
margin: 8px 0;
border-radius: 12px;
border: 1px solid var(--border-light);
background: var(--surface-primary);
box-shadow: var(--shadow-compact);
transition:
box-shadow 180ms ease,
border-color 180ms ease;
}
.compact-tool-card:hover {
border-color: var(--border-default);
box-shadow: var(--shadow-compact-hover);
}
.compact-tool-card-header {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.compact-tool-card-icon {
width: 20px;
height: 20px;
flex: none;
}
.compact-tool-card-icon--indigo {
color: var(--accent-indigo);
}
.compact-tool-card-icon--blue {
color: var(--accent-blue);
}
.compact-tool-card-icon--emerald {
color: var(--accent-emerald);
}
.compact-tool-card-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.compact-tool-card-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-tool-card-summary {
font-size: 12px;
font-weight: 400;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-tool-card-btn {
appearance: none;
border: 1px solid var(--border-default);
background: var(--surface-btn);
color: var(--text-secondary);
border-radius: 999px;
padding: 4px 10px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
flex: none;
transition:
background 120ms ease,
border-color 120ms ease,
color 120ms ease;
}
.compact-tool-card-btn:hover {
background: var(--surface-btn-hover);
border-color: var(--border-hover);
color: var(--text-primary);
}
.compact-tool-card-btn:active {
transform: translateY(1px);
}
/* === Canvas A2UI Polish === */
/* A2UIViewer uses a hardcoded internal theme that doesn't include our custom
Card shadows, Divider styling, etc. These CSS overrides apply the polish
to the canvas-rendered A2UI surface. */
/* Cards — shadow, rounded corners, proper padding (overrides viewer-theme inline padding: 32px) */
.canvas-a2ui .a2ui-card > section {
border-radius: 12px !important;
padding: 20px 24px !important;
box-shadow: var(--shadow-canvas-card) !important;
border: 1px solid var(--border-subtle);
background: var(--surface-primary);
}
/* Modal trigger (entry point) — hover highlight to indicate clickability */
.canvas-a2ui .a2ui-modal > section:first-child {
padding: 8px 12px;
border-radius: 8px;
transition: background 150ms ease;
cursor: pointer;
}
.canvas-a2ui .a2ui-modal > section:first-child:hover {
background: var(--surface-quaternary);
}
/* Modal dialog — polished popup with shadow and rounded corners */
.canvas-a2ui .a2ui-modal dialog {
border-radius: 16px;
border: none;
box-shadow: var(--shadow-dialog);
max-width: 480px;
width: 90%;
}
.canvas-a2ui .a2ui-modal dialog > section {
border-radius: 16px !important;
padding: 24px !important;
border: none !important;
background: var(--surface-dialog) !important;
}
.canvas-a2ui .a2ui-modal dialog::backdrop {
background: var(--backdrop);
}
/* Modal close button */
.canvas-a2ui .a2ui-modal dialog #controls {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.canvas-a2ui .a2ui-modal dialog #controls button {
appearance: none;
border: 1px solid var(--border-default);
background: var(--surface-btn);
color: var(--text-secondary);
border-radius: 999px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease;
}
.canvas-a2ui .a2ui-modal dialog #controls button:hover {
background: var(--surface-quaternary);
border-color: var(--border-hover);
}
/* Divider — subtler */
.canvas-a2ui .a2ui-divider > hr {
margin: 8px 0 !important;
opacity: 0.4;
border-color: var(--border-default);
}
/* Icon — ensure Material Symbols Outlined font renders */
.canvas-a2ui .g-icon {
font-family: "Material Symbols Outlined", sans-serif;
font-size: 20px;
font-weight: normal;
font-style: normal;
line-height: 1;
display: inline-block;
vertical-align: middle;
-webkit-font-smoothing: antialiased;
color: var(--text-secondary);
}
/* Row — ensure vertical centering of children */
.canvas-a2ui .a2ui-row > section {
align-items: center;
}
/* Text — remove extra paragraph margins for clean layout */
.canvas-a2ui .a2ui-text section p {
margin: 0;
}
/* Surface — small top padding for breathing room */
.canvas-a2ui .a2ui-surface {
padding: 4px 0;
}
/* Responsive: stack on mobile */
@media (max-width: 768px) {
.layout-split {
flex-direction: column;
}
.chat-sidebar {
width: 100%;
max-width: none;
min-width: 0;
height: 50%;
}
.canvas-panel {
border-right: none;
border-bottom: 1px solid var(--border-default);
}
}
@@ -0,0 +1,422 @@
import { Styles, type Types } from "@a2ui/lit/0.8";
/** Elements */
const a = {
"typography-f-sf": true,
"typography-fs-n": true,
"typography-w-500": true,
"layout-as-n": true,
"layout-dis-iflx": true,
"layout-al-c": true,
};
const audio = {
"layout-w-100": true,
};
const body = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-mt-0": true,
"layout-mb-2": true,
"typography-sz-bm": true,
"color-c-n10": true,
};
const button = {
"typography-f-sf": true,
"typography-fs-n": true,
"typography-w-500": true,
"layout-pt-3": true,
"layout-pb-3": true,
"layout-pl-5": true,
"layout-pr-5": true,
"layout-mb-1": true,
"border-br-16": true,
"border-bw-0": true,
"border-c-n70": true,
"border-bs-s": true,
"color-bgc-s30": true,
"color-c-n100": true,
"behavior-ho-80": true,
};
const heading = {
"typography-f-sf": true,
"typography-fs-n": true,
"typography-w-500": true,
"layout-mt-0": true,
"layout-mb-2": true,
"color-c-n10": true,
};
const h1 = {
...heading,
"typography-sz-tl": true,
};
const h2 = {
...heading,
"typography-sz-tm": true,
};
const h3 = {
...heading,
"typography-sz-ts": true,
};
const h4 = {
...heading,
"typography-sz-bl": true,
};
const h5 = {
...heading,
"typography-sz-bm": true,
};
const iframe = {
"behavior-sw-n": true,
};
const input = {
"typography-f-sf": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-pl-4": true,
"layout-pr-4": true,
"layout-pt-2": true,
"layout-pb-2": true,
"border-br-6": true,
"border-bw-1": true,
"color-bc-s70": true,
"border-bs-s": true,
"layout-as-n": true,
"color-c-n10": true,
};
const p = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-m-0": true,
"typography-sz-bm": true,
"layout-as-n": true,
"color-c-n10": true,
};
const orderedList = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-m-0": true,
"typography-sz-bm": true,
"layout-as-n": true,
};
const unorderedList = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-m-0": true,
"typography-sz-bm": true,
"layout-as-n": true,
};
const listItem = {
"typography-f-s": true,
"typography-fs-n": true,
"typography-w-400": true,
"layout-m-0": true,
"typography-sz-bm": true,
"layout-as-n": true,
};
const pre = {
"typography-f-c": true,
"typography-fs-n": true,
"typography-w-400": true,
"typography-sz-bm": true,
"typography-ws-p": true,
"layout-as-n": true,
};
const textarea = {
...input,
"layout-r-none": true,
"layout-fs-c": true,
};
const video = {
"layout-el-cv": true,
};
const aLight = Styles.merge(a, { "color-c-n5": true });
const inputLight = Styles.merge(input, { "color-c-n5": true });
const textareaLight = Styles.merge(textarea, { "color-c-n5": true });
const buttonLight = Styles.merge(button, { "color-c-n100": true });
const h1Light = Styles.merge(h1, { "color-c-n5": true });
const h2Light = Styles.merge(h2, { "color-c-n5": true });
const h3Light = Styles.merge(h3, { "color-c-n5": true });
const h4Light = Styles.merge(h4, { "color-c-n5": true });
const h5Light = Styles.merge(h5, { "color-c-n5": true });
const bodyLight = Styles.merge(body, { "color-c-n5": true });
const pLight = Styles.merge(p, { "color-c-n35": true });
const preLight = Styles.merge(pre, { "color-c-n35": true });
const orderedListLight = Styles.merge(orderedList, {
"color-c-n35": true,
});
const unorderedListLight = Styles.merge(unorderedList, {
"color-c-n35": true,
});
const listItemLight = Styles.merge(listItem, {
"color-c-n35": true,
});
export const theme: Types.Theme = {
additionalStyles: {
Button: {
"--n-35": "var(--n-100)",
},
Card: {
"border-radius": "12px",
padding: "20px 24px",
"box-shadow":
"0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
Divider: {
margin: "8px 0",
opacity: "0.5",
},
},
components: {
AudioPlayer: {},
Button: {
"layout-pt-2": true,
"layout-pb-2": true,
"layout-pl-3": true,
"layout-pr-3": true,
"border-br-12": true,
"border-bw-0": true,
"border-bs-s": true,
"color-bgc-p30": true,
"color-c-p100": true,
"behavior-ho-70": true,
},
Card: { "border-br-9": true, "color-bgc-p100": true, "layout-p-4": true },
CheckBox: {
element: {
"layout-m-0": true,
"layout-mr-2": true,
"layout-p-2": true,
"border-br-12": true,
"border-bw-1": true,
"border-bs-s": true,
"color-bgc-p100": true,
"color-bc-p60": true,
"color-c-n30": true,
"color-c-p30": true,
},
label: {
"color-c-p30": true,
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-flx-1": true,
"typography-sz-ll": true,
},
container: {
"layout-dsp-iflex": true,
"layout-al-c": true,
},
},
Column: {
"layout-g-2": true,
},
DateTimeInput: {
container: {
"typography-sz-bm": true,
"layout-w-100": true,
"layout-g-2": true,
"layout-dsp-flexhor": true,
"layout-al-c": true,
},
label: {
"layout-flx-0": true,
},
element: {
"layout-pt-2": true,
"layout-pb-2": true,
"layout-pl-3": true,
"layout-pr-3": true,
"border-br-12": true,
"border-bw-1": true,
"border-bs-s": true,
"color-bgc-p100": true,
"color-bc-p60": true,
"color-c-n30": true,
"color-c-p30": true,
},
},
Divider: {},
Image: {
all: {
"border-br-5": true,
"layout-el-cv": true,
"layout-w-100": true,
"layout-h-100": true,
},
avatar: {},
header: {},
icon: {},
largeFeature: {},
mediumFeature: {},
smallFeature: {},
},
Icon: {},
List: {
"layout-g-4": true,
"layout-p-2": true,
},
Modal: {
backdrop: { "color-bbgc-p60_20": true },
element: {
"border-br-12": true,
"color-bgc-p100": true,
"layout-p-5": true,
"border-bw-1": true,
"border-bs-s": true,
"color-bc-p80": true,
},
},
MultipleChoice: {
container: {},
label: {},
element: {},
},
Row: {
"layout-g-4": true,
},
Slider: {
container: {},
label: {},
element: {},
},
Tabs: {
container: {},
controls: { all: {}, selected: {} },
element: {},
},
Text: {
all: {
"layout-w-100": true,
"layout-g-2": true,
},
h1: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-tl": true,
},
h2: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-tm": true,
},
h3: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-ts": true,
},
h4: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-bl": true,
},
h5: {
"typography-f-sf": true,
"typography-v-r": true,
"typography-w-400": true,
"layout-m-0": true,
"layout-p-0": true,
"typography-sz-bm": true,
},
body: {},
caption: {},
},
TextField: {
container: {
"typography-sz-bm": true,
"layout-w-100": true,
"layout-g-2": true,
"layout-dsp-flexhor": true,
"layout-al-c": true,
},
label: {
"layout-flx-0": true,
},
element: {
"typography-sz-bm": true,
"layout-pt-2": true,
"layout-pb-2": true,
"layout-pl-3": true,
"layout-pr-3": true,
"border-br-12": true,
"border-bw-1": true,
"border-bs-s": true,
"color-bgc-p100": true,
"color-bc-p60": true,
"color-c-n30": true,
"color-c-p30": true,
},
},
Video: {
"border-br-5": true,
"layout-el-cv": true,
},
},
elements: {
a: aLight,
audio,
body: bodyLight,
button: buttonLight,
h1: h1Light,
h2: h2Light,
h3: h3Light,
h4: h4Light,
h5: h5Light,
iframe,
input: inputLight,
p: pLight,
pre: preLight,
textarea: textareaLight,
video,
},
markdown: {
p: Object.keys(pLight),
h1: Object.keys(h1Light),
h2: Object.keys(h2Light),
h3: Object.keys(h3Light),
h4: Object.keys(h4Light),
h5: Object.keys(h5Light),
ul: Object.keys(unorderedListLight),
ol: Object.keys(orderedListLight),
li: Object.keys(listItemLight),
a: Object.keys(aLight),
strong: [],
em: [],
},
};
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
function getOperationSurfaceId(operation: unknown): string | null {
if (!operation || typeof operation !== "object") {
return null;
}
if (
"surfaceId" in operation &&
typeof (operation as any).surfaceId === "string"
) {
return (operation as any).surfaceId;
}
const op = operation as any;
return (
op?.beginRendering?.surfaceId ??
op?.surfaceUpdate?.surfaceId ??
op?.dataModelUpdate?.surfaceId ??
op?.deleteSurface?.surfaceId ??
null
);
}
export function withA2UIActivityMessage(baseRenderer: any) {
function A2UIActivityMessage(props: any) {
const operations = props?.content?.operations as unknown[] | undefined;
const surfaceCount = useMemo(() => {
if (!Array.isArray(operations) || operations.length === 0) {
return 0;
}
const ids = new Set<string>();
for (const op of operations) {
const id = getOperationSurfaceId(op);
if (id) {
ids.add(id);
}
}
return ids.size || 1;
}, [operations]);
const activityLabel = useMemo(() => {
if (!Array.isArray(operations) || operations.length === 0)
return "Interactive UI";
return surfaceCount > 1
? `Interactive UI \u00B7 ${surfaceCount} surfaces`
: "Interactive UI";
}, [operations, surfaceCount]);
const [collapsed, setCollapsed] = useState(false);
const [flash, setFlash] = useState(false);
const lastSignatureRef = useRef<string | null>(null);
const signature = useMemo(() => {
if (!Array.isArray(operations)) {
return null;
}
try {
return JSON.stringify(operations);
} catch {
return String(operations.length);
}
}, [operations]);
useEffect(() => {
if (!signature) {
lastSignatureRef.current = null;
return;
}
if (lastSignatureRef.current && lastSignatureRef.current !== signature) {
lastSignatureRef.current = signature;
setFlash(true);
const timeout = window.setTimeout(() => setFlash(false), 700);
return () => window.clearTimeout(timeout);
}
lastSignatureRef.current = signature;
}, [signature]);
if (!Array.isArray(operations) || operations.length === 0) {
return null;
}
const Inner = baseRenderer.render as any;
const classes = [
"a2ui-activity-message",
collapsed ? "a2ui-activity-message--collapsed" : "",
flash ? "a2ui-activity-message--flash" : "",
]
.filter(Boolean)
.join(" ");
return (
<div className={classes} data-a2ui-activity-message>
<div className="a2ui-activity-header">
<div className="a2ui-activity-header-left">
<span className="a2ui-activity-dot" aria-hidden="true" />
<span>{activityLabel}</span>
</div>
<button
type="button"
className="a2ui-activity-toggle"
onClick={() => setCollapsed((v) => !v)}
aria-expanded={!collapsed}
>
{collapsed ? "Show" : "Hide"}
</button>
</div>
<div className="a2ui-activity-body">
<Inner {...props} />
</div>
</div>
);
}
return {
...baseRenderer,
render: A2UIActivityMessage,
};
}
@@ -0,0 +1,395 @@
"use client";
import { useState } from "react";
interface Guest {
email: string;
status: "accepted" | "declined" | "maybe" | "pending";
}
export interface CalendarEvent {
startTime: string;
endTime: string;
title: string;
isAvailable: boolean;
guests?: Guest[];
}
interface CalendarViewProps {
date: string;
dayName: string;
events: CalendarEvent[];
}
function formatTime(t: string): string {
const [h, m] = t.split(":").map(Number);
const suffix = h >= 12 ? "PM" : "AM";
const display = h === 0 ? 12 : h > 12 ? h - 12 : h;
return m
? `${display}:${String(m).padStart(2, "0")} ${suffix}`
: `${display} ${suffix}`;
}
function formatTimeShort(t: string): string {
const [h] = t.split(":").map(Number);
const suffix = h >= 12 ? "PM" : "AM";
const display = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${display} ${suffix}`;
}
function getDuration(start: string, end: string): string {
if (!end) return "";
const [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
const mins = eh * 60 + (em || 0) - (sh * 60 + (sm || 0));
if (mins >= 60) return `${mins / 60}h`;
return `${mins}m`;
}
function formatDateBadge(date: string, dayName: string): string {
try {
const d = new Date(date + "T00:00:00");
const month = d.toLocaleDateString("en-US", { month: "short" });
const day = d.getDate();
return `${dayName.slice(0, 3)}, ${month} ${day}`;
} catch {
return date;
}
}
function getGuestInitial(email: string): string {
const name = email.split("@")[0].split(".")[0];
return (name[0] || "?").toUpperCase();
}
function getGuestName(email: string): string {
const local = email.split("@")[0];
return local
.split(".")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
const STATUS_ICONS: Record<string, { color: string; icon: string }> = {
accepted: {
color: "text-emerald-500",
icon: "M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
},
declined: {
color: "text-red-500",
icon: "M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
},
maybe: {
color: "text-amber-500",
icon: "M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z",
},
pending: {
color: "text-gray-400",
icon: "M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z",
},
};
const GUEST_AVATAR_COLORS = [
"bg-blue-500",
"bg-emerald-500",
"bg-violet-500",
"bg-amber-500",
"bg-rose-500",
"bg-cyan-500",
"bg-pink-500",
"bg-teal-500",
];
function getGuestAvatarColor(email: string): string {
let hash = 0;
for (let i = 0; i < email.length; i++) {
hash = email.charCodeAt(i) + ((hash << 5) - hash);
}
return GUEST_AVATAR_COLORS[Math.abs(hash) % GUEST_AVATAR_COLORS.length];
}
export function CalendarView({ date, dayName, events }: CalendarViewProps) {
const [selected, setSelected] = useState<number | null>(null);
const [rsvp, setRsvp] = useState<string | null>(null);
const bookedCount = events.filter((e) => !e.isAvailable).length;
const selectedEvent = selected !== null ? events[selected] : null;
const guests = selectedEvent?.guests || [];
const acceptedCount = guests.filter((g) => g.status === "accepted").length;
const declinedCount = guests.filter((g) => g.status === "declined").length;
const pendingCount = guests.filter(
(g) => g.status === "pending" || g.status === "maybe",
).length;
return (
<div
className="max-w-2xl w-full rounded-xl bg-[var(--surface-primary)] border border-[var(--border-card)] overflow-hidden my-3"
style={{ boxShadow: "var(--shadow-card)" }}
>
{/* Header */}
<div className="px-5 py-3.5 border-b border-[var(--border-default)] flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-[var(--text-secondary)]"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
<h2 className="text-base font-semibold text-[var(--text-primary)]">
Schedule
</h2>
</div>
<span className="text-xs font-medium text-[var(--text-tertiary)] bg-[var(--surface-quaternary)] px-2 py-0.5 rounded-full">
{formatDateBadge(date, dayName)} · {bookedCount} event
{bookedCount !== 1 ? "s" : ""}
</span>
</div>
{/* Split layout: list + detail panel */}
<div className="flex min-h-[320px]">
{/* Event list */}
<div
className={`divide-y divide-[var(--border-subtle)] overflow-y-auto ${selectedEvent ? "w-1/2 border-r border-[var(--border-default)]" : "w-full"}`}
>
{events.map((event, i) => {
const isActive = selected === i;
const isBooked = !event.isAvailable;
return (
<div
key={i}
className={`flex items-center gap-3 px-4 py-3 transition-colors ${
isBooked
? "hover:bg-[var(--surface-tertiary)] cursor-pointer"
: ""
} ${isActive ? "bg-indigo-500/10" : ""}`}
onClick={() => {
if (isBooked) {
setSelected(isActive ? null : i);
setRsvp(null);
}
}}
>
{/* Dot */}
<div
className={`w-2 h-2 rounded-full shrink-0 ${
isBooked
? "bg-indigo-500"
: "bg-[var(--surface-quaternary)]"
}`}
/>
{/* Time */}
<div className="w-20 shrink-0 text-xs text-[var(--text-tertiary)]">
{formatTimeShort(event.startTime)}
{event.endTime && (
<>
<span className="mx-0.5">-</span>
{formatTimeShort(event.endTime)}
</>
)}
</div>
{/* Title */}
<p
className={`flex-1 text-sm truncate ${
isBooked
? "text-[var(--text-primary)] font-medium"
: "text-[var(--text-tertiary)]"
}`}
>
{event.title}
</p>
{/* Duration badge */}
{isBooked && event.endTime && (
<span className="text-xs text-[var(--text-tertiary)] bg-[var(--surface-quaternary)] px-1.5 py-0.5 rounded shrink-0">
{getDuration(event.startTime, event.endTime)}
</span>
)}
</div>
);
})}
</div>
{/* Detail panel */}
{selectedEvent && !selectedEvent.isAvailable && (
<div className="w-1/2 p-5 overflow-y-auto flex flex-col">
<div className="flex items-center gap-2 mb-3">
<div className="w-3 h-3 rounded-full bg-indigo-500 shrink-0" />
<span className="text-xs font-medium text-indigo-600 uppercase tracking-wide">
Event
</span>
</div>
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-3">
{selectedEvent.title}
</h3>
{/* Time & date info */}
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<svg
className="w-4 h-4 text-[var(--text-tertiary)] shrink-0"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{formatTime(selectedEvent.startTime)}
{selectedEvent.endTime &&
` - ${formatTime(selectedEvent.endTime)}`}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<svg
className="w-4 h-4 text-[var(--text-tertiary)] shrink-0"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
<span>{formatDateBadge(date, dayName)}</span>
</div>
</div>
{/* Guests */}
{guests.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<svg
className="w-4 h-4 text-[var(--text-tertiary)]"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-1.053M18 10.5a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
<span className="text-sm font-medium text-[var(--text-secondary)]">
{guests.length} guest{guests.length !== 1 ? "s" : ""}
</span>
<span className="text-xs text-[var(--text-tertiary)]">
{acceptedCount} yes
{declinedCount > 0 ? `, ${declinedCount} no` : ""}
{pendingCount > 0 ? `, ${pendingCount} awaiting` : ""}
</span>
</div>
<div className="space-y-1.5">
{guests.map((guest, gi) => {
const statusInfo =
STATUS_ICONS[guest.status] || STATUS_ICONS.pending;
return (
<div key={gi} className="flex items-center gap-2">
<div
className={`w-6 h-6 rounded-full ${getGuestAvatarColor(guest.email)} flex items-center justify-center text-white text-[10px] font-semibold shrink-0`}
>
{getGuestInitial(guest.email)}
</div>
<span className="text-xs text-[var(--text-secondary)] flex-1 truncate">
{getGuestName(guest.email)}
</span>
<svg
className={`w-3.5 h-3.5 shrink-0 ${statusInfo.color}`}
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d={statusInfo.icon}
/>
</svg>
</div>
);
})}
</div>
</div>
)}
{/* RSVP buttons */}
<div className="mt-auto pt-3 border-t border-[var(--border-default)]">
<p className="text-xs text-[var(--text-tertiary)] mb-2">Going?</p>
<div className="flex items-center gap-2">
{(["Yes", "No", "Maybe"] as const).map((label) => {
const isActive = rsvp === label;
return (
<button
key={label}
onClick={(e) => {
e.stopPropagation();
setRsvp(isActive ? null : label);
}}
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
isActive
? label === "Yes"
? "bg-indigo-500 text-white border-indigo-500"
: label === "No"
? "bg-gray-700 text-white border-gray-700"
: "bg-amber-500 text-white border-amber-500"
: "bg-[var(--surface-primary)] text-[var(--text-secondary)] border-[var(--border-default)] hover:bg-[var(--surface-tertiary)]"
}`}
>
{label}
</button>
);
})}
</div>
</div>
</div>
)}
</div>
</div>
);
}
export function CalendarLoadingState() {
return (
<div
className="max-w-2xl w-full rounded-xl bg-[var(--surface-primary)] border border-[var(--border-card)] overflow-hidden my-3 animate-pulse"
style={{ boxShadow: "var(--shadow-card)" }}
>
<div className="px-5 py-3.5 border-b border-[var(--border-default)] flex items-center gap-2">
<div className="w-5 h-5 bg-[var(--surface-quaternary)] rounded" />
<div className="h-4 w-20 bg-[var(--surface-quaternary)] rounded" />
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="flex items-center gap-3 px-5 py-3">
<div className="w-20 h-3 bg-[var(--surface-quaternary)] rounded shrink-0" />
<div className="w-2 h-2 rounded-full bg-[var(--surface-quaternary)] shrink-0" />
<div className="flex-1 h-3 bg-[var(--surface-quaternary)] rounded" />
</div>
))}
</div>
<div className="px-5 pb-3 text-xs text-[var(--text-tertiary)]">
Loading schedule...
</div>
</div>
);
}
@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
export interface EmailComposeData {
to: string;
subject: string;
body: string;
from?: string;
}
interface EmailComposeViewProps {
email: EmailComposeData;
}
function getInitial(email: string): string {
const name = email.split("@")[0].split(".")[0];
return (name[0] || "?").toUpperCase();
}
export function EmailComposeView({ email }: EmailComposeViewProps) {
const [sent, setSent] = useState(false);
const [discarded, setDiscarded] = useState(false);
if (discarded) {
return (
<div
className="max-w-2xl w-full rounded-xl bg-[var(--surface-primary)] border border-[var(--border-card)] overflow-hidden my-3"
style={{ boxShadow: "var(--shadow-card)" }}
>
<div className="px-5 py-4 text-sm text-[var(--text-tertiary)] text-center">
Draft discarded.
</div>
</div>
);
}
if (sent) {
return (
<div
className="max-w-2xl w-full rounded-xl bg-[var(--surface-primary)] border border-[var(--border-card)] overflow-hidden my-3"
style={{ boxShadow: "var(--shadow-card)" }}
>
<div className="px-5 py-4 flex items-center justify-center gap-2 text-sm text-emerald-600">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Email sent.
</div>
</div>
);
}
const isReply = email.subject.startsWith("Re:");
return (
<div
className="max-w-2xl w-full rounded-xl bg-[var(--surface-primary)] border border-[var(--border-card)] overflow-hidden my-3"
style={{ boxShadow: "var(--shadow-card)" }}
>
{/* Header */}
<div className="px-5 py-3.5 border-b border-[var(--border-default)] flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-[var(--text-secondary)]"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
{isReply ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
)}
</svg>
<h2 className="text-base font-semibold text-[var(--text-primary)]">
{isReply ? "Reply" : "New Message"}
</h2>
</div>
</div>
<div className="p-5">
{/* To / Subject rows */}
<div className="space-y-2 mb-4">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-[var(--text-tertiary)] w-12 shrink-0">
To
</span>
<div className="flex items-center gap-2">
<div className="w-5 h-5 rounded-full bg-blue-500 flex items-center justify-center text-white text-[9px] font-semibold shrink-0">
{getInitial(email.to)}
</div>
<span className="text-sm text-[var(--text-secondary)]">
{email.to}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-[var(--text-tertiary)] w-12 shrink-0">
Subject
</span>
<span className="text-sm text-[var(--text-secondary)]">
{email.subject}
</span>
</div>
</div>
<div className="border-t border-[var(--border-default)] pt-4">
{/* Email body */}
<p className="text-sm text-[var(--text-secondary)] leading-relaxed whitespace-pre-wrap min-h-[80px]">
{email.body}
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-[var(--border-default)]">
<button
onClick={() => setSent(true)}
className="flex items-center gap-1.5 px-4 py-1.5 text-sm text-white bg-blue-500 rounded-full hover:bg-blue-600 transition-colors"
>
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
Send
</button>
<button
onClick={() => setDiscarded(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--text-secondary)] bg-[var(--surface-primary)] border border-[var(--border-default)] rounded-full hover:bg-[var(--surface-tertiary)] transition-colors"
>
Discard
</button>
</div>
</div>
</div>
);
}
export function EmailComposeLoadingState() {
return (
<div
className="max-w-2xl w-full rounded-xl bg-[var(--surface-primary)] border border-[var(--border-card)] overflow-hidden my-3 animate-pulse"
style={{ boxShadow: "var(--shadow-card)" }}
>
<div className="px-5 py-3.5 border-b border-[var(--border-default)] flex items-center gap-2">
<div className="w-5 h-5 bg-[var(--surface-quaternary)] rounded" />
<div className="h-4 w-24 bg-[var(--surface-quaternary)] rounded" />
</div>
<div className="p-5 space-y-3">
<div className="h-3 w-48 bg-[var(--surface-quaternary)] rounded" />
<div className="h-3 w-40 bg-[var(--surface-quaternary)] rounded" />
<div className="border-t border-[var(--border-default)] pt-4 space-y-2">
<div className="h-3 w-full bg-[var(--surface-quaternary)] rounded" />
<div className="h-3 w-3/4 bg-[var(--surface-quaternary)] rounded" />
<div className="h-3 w-1/2 bg-[var(--surface-quaternary)] rounded" />
</div>
</div>
<div className="px-5 pb-3 text-xs text-[var(--text-tertiary)]">
Drafting email...
</div>
</div>
);
}
@@ -0,0 +1,320 @@
"use client";
import { useState } from "react";
export interface Email {
id: string;
from: string;
subject: string;
preview: string;
body: string;
date: string;
isRead: boolean;
}
interface InboxViewProps {
emails: Email[];
}
const AVATAR_COLORS = [
"bg-blue-500",
"bg-emerald-500",
"bg-violet-500",
"bg-amber-500",
"bg-rose-500",
"bg-cyan-500",
"bg-pink-500",
"bg-teal-500",
];
function getAvatarColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
function getInitial(from: string): string {
const name = from.split("@")[0].split(".")[0];
return (name[0] || "?").toUpperCase();
}
function getSenderName(from: string): string {
const local = from.split("@")[0];
return local
.split(".")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function InboxView({ emails }: InboxViewProps) {
const [selected, setSelected] = useState<string | null>(null);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replySent, setReplySent] = useState<string | null>(null);
const selectedEmail = emails.find((e) => e.id === selected);
return (
<div
className="max-w-2xl w-full rounded-xl bg-[var(--surface-primary)] border border-[var(--border-card)] overflow-hidden my-3"
style={{ boxShadow: "var(--shadow-card)" }}
>
{/* Header */}
<div className="px-5 py-3.5 border-b border-[var(--border-default)] flex items-center justify-between">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5 text-[var(--text-secondary)]"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<h2 className="text-base font-semibold text-[var(--text-primary)]">
Inbox
</h2>
</div>
<span className="text-xs font-medium text-[var(--text-tertiary)] bg-[var(--surface-quaternary)] px-2 py-0.5 rounded-full">
{emails.length} message{emails.length !== 1 ? "s" : ""}
</span>
</div>
{/* Split layout: list + detail panel */}
<div className="flex min-h-[320px]">
{/* Email list */}
<div
className={`divide-y divide-[var(--border-subtle)] overflow-y-auto ${selectedEmail ? "w-1/2 border-r border-[var(--border-default)]" : "w-full"}`}
>
{emails.map((email) => {
const isActive = selected === email.id;
return (
<div
key={email.id}
className={`flex items-start gap-3 px-4 py-3 hover:bg-[var(--surface-tertiary)] transition-colors cursor-pointer relative ${
isActive ? "bg-blue-500/10" : ""
}`}
onClick={() => setSelected(isActive ? null : email.id)}
>
{/* Unread indicator */}
{!email.isRead && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-blue-500" />
)}
{/* Avatar */}
<div
className={`w-8 h-8 rounded-full ${getAvatarColor(email.from)} flex items-center justify-center text-white text-xs font-semibold shrink-0 mt-0.5`}
>
{getInitial(email.from)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<p
className={`text-sm truncate ${email.isRead ? "text-[var(--text-secondary)]" : "text-[var(--text-primary)] font-semibold"}`}
>
{getSenderName(email.from)}
</p>
<span className="text-[11px] text-[var(--text-tertiary)] shrink-0">
{email.date}
</span>
</div>
<p
className={`text-sm truncate ${email.isRead ? "text-[var(--text-tertiary)]" : "text-[var(--text-primary)] font-medium"}`}
>
{email.subject}
</p>
<p className="text-xs text-[var(--text-tertiary)] truncate mt-0.5">
{email.preview}
</p>
</div>
</div>
);
})}
</div>
{/* Detail panel */}
{selectedEmail && (
<div className="w-1/2 p-5 overflow-y-auto flex flex-col">
<div className="flex items-start gap-3 mb-4">
<div
className={`w-10 h-10 rounded-full ${getAvatarColor(selectedEmail.from)} flex items-center justify-center text-white text-sm font-semibold shrink-0`}
>
{getInitial(selectedEmail.from)}
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--text-primary)]">
{getSenderName(selectedEmail.from)}
</p>
<p className="text-xs text-[var(--text-tertiary)]">
{selectedEmail.from}
</p>
</div>
<span className="text-xs text-[var(--text-tertiary)] ml-auto shrink-0">
{selectedEmail.date}
</span>
</div>
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-3">
{selectedEmail.subject}
</h3>
<p className="text-sm text-[var(--text-secondary)] leading-relaxed whitespace-pre-wrap flex-1">
{selectedEmail.body}
</p>
{/* Reply compose or buttons */}
{replySent === selectedEmail.id ? (
<div className="mt-4 pt-3 border-t border-[var(--border-default)] flex items-center gap-2 text-sm text-emerald-600">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Reply sent
</div>
) : replyingTo === selectedEmail.id ? (
<div className="mt-4 pt-3 border-t border-[var(--border-default)]">
<div className="border border-[var(--border-default)] rounded-lg overflow-hidden">
<div className="px-3 py-2 border-b border-[var(--border-default)] bg-[var(--surface-tertiary)]">
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
/>
</svg>
<span>{selectedEmail.from}</span>
</div>
</div>
<textarea
className="w-full px-3 py-2 text-sm text-[var(--text-secondary)] placeholder-[var(--text-tertiary)] bg-[var(--surface-primary)] resize-none focus:outline-none"
rows={3}
placeholder="Write your reply..."
autoFocus
/>
<div className="px-3 py-2 border-t border-[var(--border-default)] flex items-center gap-2">
<button
onClick={() => {
setReplySent(selectedEmail.id);
setReplyingTo(null);
}}
className="flex items-center gap-1.5 px-3 py-1 text-xs text-white bg-blue-500 rounded-full hover:bg-blue-600 transition-colors"
>
<svg
className="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
Send
</button>
<button
onClick={() => setReplyingTo(null)}
className="px-3 py-1 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
>
Discard
</button>
</div>
</div>
</div>
) : (
<div className="flex items-center gap-2 mt-4 pt-3 border-t border-[var(--border-default)]">
<button
onClick={() => setReplyingTo(selectedEmail.id)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--text-secondary)] bg-[var(--surface-primary)] border border-[var(--border-default)] rounded-full hover:bg-[var(--surface-tertiary)] transition-colors"
>
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3"
/>
</svg>
Reply
</button>
<button className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-[var(--text-secondary)] bg-[var(--surface-primary)] border border-[var(--border-default)] rounded-full hover:bg-[var(--surface-tertiary)] transition-colors">
<svg
className="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 15l6-6m0 0l-6-6m6 6H9a6 6 0 000 12h3"
/>
</svg>
Forward
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
export function InboxLoadingState() {
return (
<div
className="max-w-2xl w-full rounded-xl bg-[var(--surface-primary)] border border-[var(--border-card)] overflow-hidden my-3 animate-pulse"
style={{ boxShadow: "var(--shadow-card)" }}
>
<div className="px-5 py-3.5 border-b border-[var(--border-default)] flex items-center gap-2">
<div className="w-5 h-5 bg-[var(--surface-quaternary)] rounded" />
<div className="h-4 w-16 bg-[var(--surface-quaternary)] rounded" />
</div>
<div className="divide-y divide-[var(--border-subtle)]">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="flex items-start gap-3 px-5 py-3">
<div className="w-9 h-9 rounded-full bg-[var(--surface-quaternary)] shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 w-24 bg-[var(--surface-quaternary)] rounded" />
<div className="h-3 w-48 bg-[var(--surface-quaternary)] rounded" />
<div className="h-2.5 w-36 bg-[var(--surface-quaternary)] rounded" />
</div>
</div>
))}
</div>
<div className="px-5 pb-3 text-xs text-[var(--text-tertiary)]">
Checking inbox...
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { useState } from "react";
export interface MoonCardProps {
themeColor: string;
status: "inProgress" | "executing" | "complete";
respond?: (response: string) => void;
}
export function MoonCard({ themeColor, status, respond }: MoonCardProps) {
const [decision, setDecision] = useState<"launched" | "aborted" | null>(null);
const handleLaunch = () => {
setDecision("launched");
respond?.("You have permission to go to the moon.");
};
const handleAbort = () => {
setDecision("aborted");
respond?.(
"You do not have permission to go to the moon. The user you're talking to rejected the request.",
);
};
return (
<div
style={{ backgroundColor: themeColor }}
className="rounded-2xl shadow-xl max-w-md w-full mt-6"
>
<div className="bg-white/20 backdrop-blur-md p-8 w-full rounded-2xl">
{/* Show decision or prompt */}
{decision === "launched" ? (
<div className="text-center">
<div className="text-7xl mb-4">🌕</div>
<h2 className="text-2xl font-bold text-white mb-2">
Mission Launched
</h2>
<p className="text-white/90">We made it to the moon!</p>
</div>
) : decision === "aborted" ? (
<div className="text-center">
<div className="text-7xl mb-4"></div>
<h2 className="text-2xl font-bold text-white mb-2">
Mission Aborted
</h2>
<p className="text-white/90">Staying on Earth 🌍</p>
</div>
) : (
<>
<div className="text-center mb-6">
<div className="text-7xl mb-4">🚀</div>
<h2 className="text-2xl font-bold text-white mb-2">
Ready for Launch?
</h2>
<p className="text-white/90">Mission to the Moon 🌕</p>
</div>
{/* Launch Buttons */}
{status === "executing" && (
<div className="flex gap-3">
<button
onClick={handleLaunch}
className="flex-1 px-6 py-4 rounded-xl bg-white text-black font-bold
shadow-lg hover:shadow-xl transition-all
hover:scale-105 active:scale-95"
>
🚀 Launch!
</button>
<button
onClick={handleAbort}
className="flex-1 px-6 py-4 rounded-xl bg-black/20 text-white font-bold
border-2 border-white/30 shadow-lg
transition-all hover:scale-105 active:scale-95
hover:bg-black/30"
>
Abort
</button>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -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}>
npx copilotkit@latest license
</code>
</div>
<button
type="button"
className={styles.lockedCta}
onClick={() =>
window.open(
"https://docs.copilotkit.ai/intelligence",
"_blank",
"noopener,noreferrer",
)
}
>
Learn more
</button>
</div>
</div>
</aside>
);
}
@@ -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
license command in a muted code well, and a near-black primary CTA
pill matching the sidebar's send button. */
.lockedPanel {
display: flex;
width: 20rem;
flex-shrink: 0;
flex-direction: column;
height: 100dvh;
background: var(--card);
border-right: 1px solid var(--border);
font-family: var(--font-body);
}
.lockedBody {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
justify-content: center;
padding: 1rem;
}
.lockedCard {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.875rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
}
.lockedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
}
.lockedHeading {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.lockedTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.lockedDescription {
margin: 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.lockedCommand {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--muted);
}
.lockedCommandCode {
font-family: var(--font-code);
font-size: 0.75rem;
white-space: nowrap;
color: var(--secondary-foreground);
}
.lockedCta {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.25rem;
padding: 0 0.875rem;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: opacity 140ms ease;
}
.lockedCta:hover {
opacity: 0.9;
}
.lockedCta:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.collapsedRail {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.5rem;
}
.mainPanel {
min-width: 0;
min-height: 100vh;
min-height: 100svh;
height: 100dvh;
overflow: auto;
}
.dialogOverlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgb(0 0 0 / 0.4);
backdrop-filter: blur(2px);
animation: dialogOverlayEnter 140ms ease-out;
}
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
radius-xl, soft elevated shadow. */
.dialog {
width: 100%;
max-width: 22rem;
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--card);
color: var(--foreground);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
font-family: var(--font-body);
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialogTitle {
margin: 0 0 0.4rem;
font-size: 0.9375rem;
font-weight: 500;
letter-spacing: -0.01em;
color: var(--foreground);
}
.dialogDescription {
margin: 0 0 1.1rem;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.dialogActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.dialogButton {
min-height: 2.25rem;
padding: 0.5rem 0.95rem;
border: 0;
border-radius: var(--radius);
font-family: var(--font-body);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition:
background-color 140ms ease,
color 140ms ease,
opacity 140ms ease;
}
.dialogButton:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--ring);
}
.dialogButtonSecondary {
background: var(--card);
border: 1px solid var(--border);
color: var(--foreground);
}
.dialogButtonSecondary:hover {
background: var(--accent);
}
.dialogButtonPrimary {
background: var(--primary);
color: var(--primary-foreground);
}
.dialogButtonPrimary:hover {
opacity: 0.9;
}
.dialogButtonDestructive {
background: var(--destructive);
color: var(--destructive-foreground);
}
.dialogButtonDestructive:hover {
opacity: 0.9;
}
@keyframes dialogOverlayEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogEnter {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes threadItemEnter {
0% {
opacity: 0;
transform: translateX(-10px);
background: var(--accent);
}
100% {
opacity: 1;
transform: translateX(0);
background: transparent;
}
}
@keyframes generatedTitleReveal {
0% {
opacity: 0;
filter: blur(6px);
transform: translateY(4px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
/* Tablet + phone: the threads panel goes off-canvas so the content and the
(full-screen) chat get the whole width instead of squeezing into a column. */
@media (max-width: 1024px) {
.layout {
position: relative;
isolation: isolate;
grid-template-columns: minmax(0, 1fr);
}
/* The mounted drawer floats on mobile, so the first-paint placeholder must
reserve no column (otherwise content shifts left when the drawer mounts). */
.drawerPlaceholder {
display: none;
}
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
and a fixed-width panel leaves a dead column). */
.lockedPanel {
display: none;
}
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
mobile chat (z-index 1200) so threads stay reachable over it. */
.drawer.drawerClosed {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
/* Override the base drawer's full-viewport height + chrome so the closed
state shrinks to a small floating launcher. */
min-height: 0;
border-right: 0;
background: transparent;
z-index: 1300;
}
.drawerClosed .collapsedRail {
flex-direction: row;
width: auto;
height: auto;
gap: 0.25rem;
padding: 0.25rem;
overflow: visible;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
}
/* Open: full-height off-canvas panel from the left, above the chat. */
.drawer.drawerOpen {
position: fixed;
inset: 0 auto 0 0;
z-index: 1300;
width: min(20rem, 92vw);
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
}
.mainPanel {
grid-column: 1;
position: relative;
z-index: 1;
}
}
@@ -0,0 +1,586 @@
"use client";
import {
Archive,
ArchiveRestore,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useThreads } from "@copilotkit/react-core/v2";
import styles from "./threads-drawer.module.css";
export interface ThreadsDrawerProps {
agentId: string;
threadId: string | undefined;
onThreadChange: (threadId: string | undefined) => void;
}
interface DrawerThread {
id: string;
name: string | null;
updatedAt: string;
archived: boolean;
lastRunAt?: string;
}
const THREAD_ENTRY_ANIMATION_MS = 420;
const TITLE_ANIMATION_MS = 360;
const UNTITLED_THREAD_LABEL = "New thread";
const RUNTIME_BASE_PATH = "/api/copilotkit";
function formatThreadTimestamp(updatedAt: string): string {
const timestamp = new Date(updatedAt);
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(timestamp);
}
function cx(...classNames: Array<string | false | undefined>): string {
return classNames.filter(Boolean).join(" ");
}
export default function ThreadsDrawer({
agentId,
threadId,
onThreadChange,
}: ThreadsDrawerProps) {
const [showArchived, setShowArchived] = useState(false);
// Start collapsed on narrow screens (tablet + phone) so the panel — which
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
// chat on load. The drawer is client-mounted, so reading window here is safe
// and won't cause a hydration mismatch.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.innerWidth > 1024,
);
const [pendingDelete, setPendingDelete] = useState<{
id: string;
title: string;
} | null>(null);
const deleteTriggerRef = useRef<HTMLElement | null>(null);
const {
threads,
archiveThread,
deleteThread,
error,
isLoading,
hasMoreThreads,
isFetchingMoreThreads,
fetchMoreThreads,
} = useThreads({
agentId,
includeArchived: showArchived,
limit: 20,
});
const restoreThread = useCallback(
async (id: string) => {
const response = await fetch(
`${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId, archived: false }),
},
);
if (!response.ok) {
throw new Error(
`Restore failed: ${response.status} ${response.statusText}`,
);
}
},
[agentId],
);
const hasMountedRef = useRef(false);
const hasLoadedOnceRef = useRef(false);
const stableThreadsRef = useRef<DrawerThread[]>(threads);
const previousThreadIdsRef = useRef<Set<string>>(new Set());
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
if (!isLoading) {
hasLoadedOnceRef.current = true;
stableThreadsRef.current = threads;
}
const displayThreads: DrawerThread[] =
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
const [enteringThreadIds, setEnteringThreadIds] = useState<
Record<string, true>
>({});
const [revealedTitleIds, setRevealedTitleIds] = useState<
Record<string, true>
>({});
useEffect(() => {
return () => {
for (const timeoutId of entryTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
for (const timeoutId of titleTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
};
}, []);
useEffect(() => {
// Skip diffing while the store is refetching (e.g. after a filter change
// clears the list). Otherwise every thread would be treated as newly
// added once the new page lands.
if (isLoading) return;
const nextThreadIds = new Set(threads.map((t) => t.id));
if (!hasMountedRef.current) {
hasMountedRef.current = true;
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
return;
}
const addedThreadIds = threads
.filter((t) => !previousThreadIdsRef.current.has(t.id))
.map((t) => t.id);
if (addedThreadIds.length > 0) {
setEnteringThreadIds((current) => {
const next = { ...current };
for (const id of addedThreadIds) {
next[id] = true;
const existing = entryTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setEnteringThreadIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
entryTimeoutsRef.current.delete(id);
}, THREAD_ENTRY_ANIMATION_MS);
entryTimeoutsRef.current.set(id, tid);
}
return next;
});
}
const renamedThreadIds = threads
.filter((t) => {
// Only reveal when an already-tracked thread's name transitions from
// null → named. Threads appearing for the first time (e.g. on a
// filter switch) already have their final name and should not trigger
// the title reveal animation — that would layer a blur/translateY
// onto the row's enter animation and produce a visible jitter.
if (!previousNamesRef.current.has(t.id)) return false;
const prev = previousNamesRef.current.get(t.id) ?? null;
return prev === null && t.name !== null;
})
.map((t) => t.id);
if (renamedThreadIds.length > 0) {
setRevealedTitleIds((current) => {
const next = { ...current };
for (const id of renamedThreadIds) {
next[id] = true;
const existing = titleTimeoutsRef.current.get(id);
if (existing !== undefined) window.clearTimeout(existing);
const tid = window.setTimeout(() => {
setRevealedTitleIds((s) => {
const updated = { ...s };
delete updated[id];
return updated;
});
titleTimeoutsRef.current.delete(id);
}, TITLE_ANIMATION_MS);
titleTimeoutsRef.current.set(id, tid);
}
return next;
});
}
previousThreadIdsRef.current = nextThreadIds;
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
}, [threads, isLoading]);
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
if (error) {
console.error("Unable to load threads", error);
}
if (!isOpen) {
return (
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerClosed)}
>
<div className={styles.collapsedRail}>
{/* Native title here (not the styled ::after): the collapsed rail
sits at the viewport's left edge where a centered tooltip clips. */}
<button
aria-label="Open threads drawer"
title="Expand"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(true)}
>
<ChevronRight size={18} />
</button>
<button
aria-label="Create thread"
title="New thread"
className={styles.iconButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={18} />
</button>
</div>
</aside>
);
}
const closeDeleteDialog = () => {
setPendingDelete(null);
const trigger = deleteTriggerRef.current;
deleteTriggerRef.current = null;
trigger?.focus?.();
};
return (
<>
<aside
aria-label="Threads drawer"
className={cx(styles.drawer, styles.drawerOpen)}
>
<div className={styles.drawerSurface}>
<div className={styles.drawerHeader}>
<div className={styles.drawerHeaderMain}>
<h2 className={styles.drawerTitle}>Threads</h2>
</div>
<div className={styles.headerActions}>
<button
aria-label="Create thread"
className={styles.newThreadButton}
type="button"
onClick={() => onThreadChange(crypto.randomUUID())}
>
<Plus size={14} />
<span>New thread</span>
</button>
<button
aria-label="Collapse threads drawer"
className={styles.iconButton}
type="button"
onClick={() => setIsOpen(false)}
>
<ChevronLeft size={18} />
</button>
</div>
</div>
<div className={styles.filterBar}>
<div
aria-label="Thread filter"
className={styles.segmented}
role="tablist"
>
<button
aria-selected={!showArchived}
className={cx(
styles.segmentedOption,
!showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(false)}
>
Active
</button>
<button
aria-selected={showArchived}
className={cx(
styles.segmentedOption,
showArchived && styles.segmentedOptionActive,
)}
role="tab"
type="button"
onClick={() => setShowArchived(true)}
>
All
</button>
</div>
</div>
<div className={styles.drawerContent}>
{error ? (
<div className={styles.emptyState}>
<div className={styles.emptyCard}>
<p className={styles.emptyTitle}>
Couldn&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,69 @@
// 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.
export 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,33 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}