chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
## 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:00–9: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:00–10:00 AM*",
|
||||
"event2Title": "Project Work",
|
||||
"event2Guests": "2",
|
||||
"event2TimeFull": "*9:00 AM – 10:00 AM*",
|
||||
"event2GuestsLabel": "2 guests",
|
||||
"event2GuestNames": "*David Dave, Sarah Chen*",
|
||||
"event3Time": "*11:00–11: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:00–3: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:00–4: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 3rd‑party 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
|
||||
)
|
||||
+2548
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;
|
||||
+18705
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 |
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../agent" || exit 1
|
||||
uv run src/main.py
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+891
@@ -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’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"]
|
||||
}
|
||||
Reference in New Issue
Block a user