openai/chatkit-python
Publicmirrored fromhttps://github.com/openai/chatkit-pythonAvailable
tests/helpers/mock_widget.py
1910lines · modecode
| 1 | import os |
| 2 | import re |
| 3 | import uuid |
| 4 | from datetime import datetime, timedelta |
| 5 | from typing import Annotated, Any, AsyncIterator, Callable, Literal |
| 6 | |
| 7 | from agents import Agent, Runner |
| 8 | from anyio import sleep |
| 9 | from pydantic import BaseModel, Field, TypeAdapter |
| 10 | from typing_extensions import assert_never |
| 11 | |
| 12 | from chatkit.actions import Action, ActionConfig |
| 13 | from chatkit.types import ThreadStreamEvent |
| 14 | from chatkit.widgets import ( |
| 15 | Box, |
| 16 | Button, |
| 17 | Card, |
| 18 | Col, |
| 19 | DatePicker, |
| 20 | Divider, |
| 21 | Icon, |
| 22 | Image, |
| 23 | ListView, |
| 24 | ListViewItem, |
| 25 | Markdown, |
| 26 | Row, |
| 27 | Select, |
| 28 | Spacer, |
| 29 | Text, |
| 30 | Title, |
| 31 | Transition, |
| 32 | WidgetComponent, |
| 33 | WidgetRoot, |
| 34 | WidgetStatus, |
| 35 | ) |
| 36 | |
| 37 | from .email_data import ( |
| 38 | BRAINSTORM_SESSION, |
| 39 | CHATKIT_ROADMAP, |
| 40 | FLIGHT_REMINDER, |
| 41 | ) |
| 42 | |
| 43 | # HELPERS |
| 44 | |
| 45 | |
| 46 | def _gen_id(prefix: str) -> str: |
| 47 | return f"{prefix}_{uuid.uuid4().hex}" |
| 48 | |
| 49 | |
| 50 | chatkit_address = os.getenv("CHATKIT_ADDRESS") or "http://localhost:3000" |
| 51 | |
| 52 | |
| 53 | def asset(path: str) -> str: |
| 54 | """ |
| 55 | Helper function to create an asset URL. |
| 56 | """ |
| 57 | return f"{chatkit_address}/{path}" |
| 58 | |
| 59 | |
| 60 | ## STATE MODELS |
| 61 | |
| 62 | |
| 63 | class DraftTask(BaseModel): |
| 64 | title: str |
| 65 | description: str |
| 66 | priority: Literal["low", "high"] = "low" |
| 67 | timeframe: Literal["today", "tomorrow", "week", "month", "custom"] = "tomorrow" |
| 68 | due_date: datetime | None = None |
| 69 | |
| 70 | def model_post_init(self, __context) -> None: |
| 71 | if self.due_date is None: |
| 72 | self.due_date = self._calculate_due_date() |
| 73 | |
| 74 | def _calculate_due_date(self) -> datetime: |
| 75 | now = datetime.now() |
| 76 | if self.timeframe == "today": |
| 77 | return now.replace(hour=23, minute=59, second=59, microsecond=0) |
| 78 | elif self.timeframe == "tomorrow": |
| 79 | return (now + timedelta(days=1)).replace( |
| 80 | hour=23, minute=59, second=59, microsecond=0 |
| 81 | ) |
| 82 | elif self.timeframe == "week": |
| 83 | # End of this week (Sunday) |
| 84 | days_until_sunday = 6 - now.weekday() |
| 85 | return (now + timedelta(days=days_until_sunday)).replace( |
| 86 | hour=23, minute=59, second=59, microsecond=0 |
| 87 | ) |
| 88 | elif self.timeframe == "month": |
| 89 | # End of this month |
| 90 | if now.month == 12: |
| 91 | next_month = now.replace(year=now.year + 1, month=1, day=1) |
| 92 | else: |
| 93 | next_month = now.replace(month=now.month + 1, day=1) |
| 94 | return (next_month - timedelta(days=1)).replace( |
| 95 | hour=23, minute=59, second=59, microsecond=0 |
| 96 | ) |
| 97 | else: # custom - treat as today for now |
| 98 | return now.replace(hour=23, minute=59, second=59, microsecond=0) |
| 99 | |
| 100 | def priority_color(self) -> str: |
| 101 | if self.priority == "high": |
| 102 | return "red-400" |
| 103 | return "secondary" |
| 104 | |
| 105 | def urgency_color(self) -> str: |
| 106 | if self.due_date is None: |
| 107 | self.due_date = self._calculate_due_date() |
| 108 | |
| 109 | now = datetime.now(self.due_date.tzinfo) |
| 110 | due = self.due_date |
| 111 | days_until_due = (due - now).days |
| 112 | |
| 113 | if days_until_due < 1: |
| 114 | return "red-400" |
| 115 | |
| 116 | if days_until_due < 3: |
| 117 | return "yellow-600" |
| 118 | |
| 119 | return "secondary" |
| 120 | |
| 121 | def humanized_due_date(self) -> str: |
| 122 | if self.due_date is None: |
| 123 | self.due_date = self._calculate_due_date() |
| 124 | |
| 125 | now = datetime.now(self.due_date.tzinfo) |
| 126 | due = self.due_date |
| 127 | |
| 128 | # Check if it's overdue |
| 129 | if due < now: |
| 130 | days_overdue = (now - due).days |
| 131 | if days_overdue == 0: |
| 132 | return "Due today" |
| 133 | elif days_overdue == 1: |
| 134 | return "Due yesterday" |
| 135 | else: |
| 136 | return f"Due {days_overdue} days ago" |
| 137 | |
| 138 | # Check future dates |
| 139 | days_until_due = (due - now).days |
| 140 | |
| 141 | if days_until_due == 0: |
| 142 | return "Due today" |
| 143 | elif days_until_due == 1: |
| 144 | return "Due tomorrow" |
| 145 | elif days_until_due <= 7: |
| 146 | return f"Due in {days_until_due} days" |
| 147 | elif days_until_due <= 14: |
| 148 | return "Due next week" |
| 149 | else: |
| 150 | return f"Due on {due.strftime('%b %d')}" |
| 151 | |
| 152 | |
| 153 | class DraftCalendarEvent(BaseModel): |
| 154 | title: str |
| 155 | description: str |
| 156 | date: str |
| 157 | start_time: str |
| 158 | end_time: str |
| 159 | calendar: Literal["Work", "Personal"] |
| 160 | |
| 161 | def calendar_color(self) -> str: |
| 162 | return "blue-400" if self.calendar == "Work" else "red-400" |
| 163 | |
| 164 | def time(self) -> str: |
| 165 | return f"{self.start_time} - {self.end_time}" |
| 166 | |
| 167 | |
| 168 | class DraftEmail(BaseModel): |
| 169 | subject: str |
| 170 | body: str |
| 171 | to: str |
| 172 | |
| 173 | |
| 174 | class Email(DraftEmail): |
| 175 | id: str = Field(default_factory=lambda: _gen_id("email")) |
| 176 | sender_image: str |
| 177 | sender: str |
| 178 | sender_type: Literal["org", "person"] |
| 179 | sent_at: str |
| 180 | |
| 181 | |
| 182 | class Task(DraftTask): |
| 183 | id: str = Field(default_factory=lambda: _gen_id("task")) |
| 184 | completed: bool |
| 185 | |
| 186 | |
| 187 | class CalendarEvent(DraftCalendarEvent): |
| 188 | id: str = Field(default_factory=lambda: _gen_id("event")) |
| 189 | |
| 190 | |
| 191 | class BaseWidgetView(BaseModel): |
| 192 | status_text: str | None = None |
| 193 | status_icon: str = "favicon.svg" |
| 194 | collapsed: bool = False |
| 195 | |
| 196 | def status(self) -> WidgetStatus | None: |
| 197 | if not self.status_text: |
| 198 | return None |
| 199 | return {"text": self.status_text, "favicon": asset(self.status_icon)} |
| 200 | |
| 201 | |
| 202 | class EmailDraft(BaseWidgetView): |
| 203 | type: Literal["email_draft"] = Field(default="email_draft", frozen=True) |
| 204 | email: DraftEmail |
| 205 | streaming: bool |
| 206 | status_icon: str = "gmail-send-status-icon.png" |
| 207 | |
| 208 | |
| 209 | class EmailView(BaseWidgetView): |
| 210 | type: Literal["email_view"] = Field(default="email_view", frozen=True) |
| 211 | email: Email |
| 212 | show_back_button: bool |
| 213 | status_icon: str = "gmail-status-icon.png" |
| 214 | |
| 215 | |
| 216 | class EmailsList(BaseWidgetView): |
| 217 | type: Literal["emails_list"] = Field(default="emails_list", frozen=True) |
| 218 | emails: list[Email] |
| 219 | status_icon: str = "gmail-status-icon.png" |
| 220 | |
| 221 | |
| 222 | class TaskDraft(BaseWidgetView): |
| 223 | type: Literal["task_draft"] = Field(default="task_draft", frozen=True) |
| 224 | todo: DraftTask |
| 225 | status_icon: str = "linear-status-icon.png" |
| 226 | |
| 227 | |
| 228 | class TaskView(BaseWidgetView): |
| 229 | type: Literal["task_view"] = Field(default="task_view", frozen=True) |
| 230 | task: Task |
| 231 | show_back_button: bool |
| 232 | status_icon: str = "linear-status-icon.png" |
| 233 | |
| 234 | |
| 235 | class TasksList(BaseWidgetView): |
| 236 | type: Literal["tasks_list"] = Field(default="tasks_list", frozen=True) |
| 237 | tasks: list[Task] |
| 238 | status_icon: str = "linear-status-icon.png" |
| 239 | |
| 240 | |
| 241 | class CalendarEventDraft(BaseWidgetView): |
| 242 | type: Literal["calendar_event_draft"] = Field( |
| 243 | default="calendar_event_draft", frozen=True |
| 244 | ) |
| 245 | event: DraftCalendarEvent |
| 246 | status_icon: str = "calendar-status-icon.png" |
| 247 | |
| 248 | |
| 249 | class CalendarEventView(BaseWidgetView): |
| 250 | type: Literal["calendar_event_view"] = Field( |
| 251 | default="calendar_event_view", frozen=True |
| 252 | ) |
| 253 | event: CalendarEvent |
| 254 | show_back_button: bool |
| 255 | status_icon: str = "calendar-status-icon.png" |
| 256 | |
| 257 | |
| 258 | class CalendarEventsList(BaseWidgetView): |
| 259 | type: Literal["calendar_events_list"] = Field( |
| 260 | default="calendar_events_list", frozen=True |
| 261 | ) |
| 262 | events: list[CalendarEvent] |
| 263 | status_icon: str = "calendar-status-icon.png" |
| 264 | |
| 265 | |
| 266 | class Index(BaseWidgetView): |
| 267 | type: Literal["index"] = Field(default="index", frozen=True) |
| 268 | selected: Literal["email", "calendar", "tasks", "index"] |
| 269 | |
| 270 | def get_section_icon(self, section: str) -> str: |
| 271 | """Get the appropriate icon for each section""" |
| 272 | icon_map = { |
| 273 | "email": "gmail-status-icon.png", |
| 274 | "calendar": "calendar-status-icon.png", |
| 275 | "tasks": "linear-status-icon.png", |
| 276 | "index": "favicon.svg", |
| 277 | } |
| 278 | return icon_map.get(section, "favicon.svg") |
| 279 | |
| 280 | def __init__(self, **data): |
| 281 | super().__init__(**data) |
| 282 | # Set status_icon based on current selection |
| 283 | if self.selected: |
| 284 | self.status_icon = self.get_section_icon(self.selected) |
| 285 | else: |
| 286 | self.status_icon = "favicon.svg" |
| 287 | |
| 288 | |
| 289 | State = Annotated[ |
| 290 | EmailDraft |
| 291 | | EmailView |
| 292 | | EmailsList |
| 293 | | TaskDraft |
| 294 | | TaskView |
| 295 | | TasksList |
| 296 | | CalendarEventDraft |
| 297 | | CalendarEventView |
| 298 | | CalendarEventsList |
| 299 | | Index, |
| 300 | Field(discriminator="type"), |
| 301 | ] |
| 302 | |
| 303 | |
| 304 | ## ACTIONS |
| 305 | |
| 306 | |
| 307 | class BasePayload(BaseModel): |
| 308 | widget_id: str |
| 309 | |
| 310 | |
| 311 | class ShowWidgetPayload(BasePayload): |
| 312 | widget: Literal["email", "calendar", "tasks", "index"] |
| 313 | |
| 314 | |
| 315 | class SendEmailPayload(BasePayload): |
| 316 | email: DraftEmail |
| 317 | |
| 318 | |
| 319 | class ViewEmailPayload(BasePayload): |
| 320 | email_id: str |
| 321 | |
| 322 | |
| 323 | class UpdateDraftTaskPayload(BasePayload): |
| 324 | todo: DraftTask |
| 325 | |
| 326 | |
| 327 | class CreateTaskPayload(BasePayload): |
| 328 | todo: DraftTask |
| 329 | |
| 330 | |
| 331 | class ViewTaskPayload(BasePayload): |
| 332 | task_id: str |
| 333 | |
| 334 | |
| 335 | class ToggleTaskCompletePayload(BasePayload): |
| 336 | task_id: str |
| 337 | |
| 338 | |
| 339 | class CreateEventPayload(BasePayload): |
| 340 | event: DraftCalendarEvent |
| 341 | |
| 342 | |
| 343 | class ViewEventPayload(BasePayload): |
| 344 | event_id: str |
| 345 | |
| 346 | |
| 347 | ShowWidgetAction = Action[Literal["sample.show_widget"], ShowWidgetPayload] |
| 348 | DraftEmailAction = Action[Literal["sample.draft_email"], BasePayload] |
| 349 | ShowInboxAction = Action[Literal["sample.show_inbox"], BasePayload] |
| 350 | SendEmailAction = Action[Literal["sample.send_email"], SendEmailPayload] |
| 351 | DiscardEmailAction = Action[Literal["sample.discard_email"], BasePayload] |
| 352 | ViewEmailAction = Action[Literal["sample.view_email"], ViewEmailPayload] |
| 353 | DraftTaskAction = Action[Literal["sample.draft_task"], BasePayload] |
| 354 | UpdateDraftTaskAction = Action[ |
| 355 | Literal["sample.update_draft_task"], UpdateDraftTaskPayload |
| 356 | ] |
| 357 | CreateTaskAction = Action[Literal["sample.create_task"], CreateTaskPayload] |
| 358 | CancelTaskAction = Action[Literal["sample.cancel_task"], BasePayload] |
| 359 | ViewTasksAction = Action[Literal["sample.view_tasks"], BasePayload] |
| 360 | ViewTaskAction = Action[Literal["sample.view_task"], ViewTaskPayload] |
| 361 | ToggleTaskCompleteAction = Action[ |
| 362 | Literal["sample.toggle_task_complete"], ToggleTaskCompletePayload |
| 363 | ] |
| 364 | DraftEventAction = Action[Literal["sample.draft_event"], BasePayload] |
| 365 | CreateEventAction = Action[Literal["sample.create_event"], CreateEventPayload] |
| 366 | DiscardEventAction = Action[Literal["sample.discard_event"], BasePayload] |
| 367 | ViewScheduleAction = Action[Literal["sample.view_schedule"], BasePayload] |
| 368 | ViewEventAction = Action[Literal["sample.view_event"], ViewEventPayload] |
| 369 | |
| 370 | |
| 371 | SampleWidgetAction = Annotated[ |
| 372 | ShowWidgetAction |
| 373 | | DraftEmailAction |
| 374 | | ShowInboxAction |
| 375 | | SendEmailAction |
| 376 | | DiscardEmailAction |
| 377 | | ViewEmailAction |
| 378 | | DraftTaskAction |
| 379 | | CreateTaskAction |
| 380 | | CancelTaskAction |
| 381 | | ViewTasksAction |
| 382 | | ViewTaskAction |
| 383 | | ToggleTaskCompleteAction |
| 384 | | UpdateDraftTaskAction |
| 385 | | DraftEventAction |
| 386 | | CreateEventAction |
| 387 | | DiscardEventAction |
| 388 | | ViewScheduleAction |
| 389 | | ViewEventAction, |
| 390 | Field(discriminator="type"), |
| 391 | ] |
| 392 | |
| 393 | ActionAdapter: TypeAdapter[SampleWidgetAction] = TypeAdapter(SampleWidgetAction) |
| 394 | |
| 395 | |
| 396 | email_generator = Agent( |
| 397 | model="gpt-4.1", |
| 398 | name="Description generator", |
| 399 | instructions="""Generate an email about a given subject. Do not include any other text than the email. Do not include the subject itself. The email should end with "\n\nThanks,\nZach" |
| 400 | """, |
| 401 | ) |
| 402 | |
| 403 | |
| 404 | class ActionOutput(BaseModel): |
| 405 | widget: WidgetRoot | None = None |
| 406 | event: ThreadStreamEvent | None = None |
| 407 | |
| 408 | @staticmethod |
| 409 | def from_event(event: ThreadStreamEvent) -> "ActionOutput": |
| 410 | return ActionOutput(widget=None, event=event) |
| 411 | |
| 412 | @staticmethod |
| 413 | def from_widget(widget: WidgetRoot) -> "ActionOutput": |
| 414 | return ActionOutput(widget=widget, event=None) |
| 415 | |
| 416 | |
| 417 | class SampleWidget(BaseModel): |
| 418 | id: str = Field(default_factory=lambda: _gen_id("wig")) |
| 419 | emails: list[Email] = [] |
| 420 | tasks: list[Task] = [] |
| 421 | events: list[CalendarEvent] = [] |
| 422 | state: State = Index(selected="index", status_text="Fetched widgets") |
| 423 | |
| 424 | @staticmethod |
| 425 | def parse_action(action: Action[str, Any]) -> SampleWidgetAction: |
| 426 | return ActionAdapter.validate_python(action.model_dump()) |
| 427 | |
| 428 | def load_events(self): |
| 429 | self.events = [ |
| 430 | CalendarEvent( |
| 431 | title="Dentist appointment", |
| 432 | description="Regular checkup and cleaning", |
| 433 | date="Wed, July 16", |
| 434 | start_time="2:00", |
| 435 | end_time="3:00 PM", |
| 436 | calendar="Personal", |
| 437 | ), |
| 438 | CalendarEvent( |
| 439 | title="Team standup", |
| 440 | description="Daily team sync meeting", |
| 441 | date="Thu, July 17", |
| 442 | start_time="3:30", |
| 443 | end_time="4:00 PM", |
| 444 | calendar="Work", |
| 445 | ), |
| 446 | CalendarEvent( |
| 447 | title="Q3 roadmap review", |
| 448 | description="Quarterly planning session", |
| 449 | date="Fri, July 18", |
| 450 | start_time="9:00", |
| 451 | end_time="10:30 AM", |
| 452 | calendar="Work", |
| 453 | ), |
| 454 | CalendarEvent( |
| 455 | title="Lunch with Sarah", |
| 456 | description="Catch up over lunch", |
| 457 | date="Sat, July 19", |
| 458 | start_time="12:00", |
| 459 | end_time="1:30 PM", |
| 460 | calendar="Personal", |
| 461 | ), |
| 462 | ] |
| 463 | |
| 464 | def load_emails(self): |
| 465 | self.emails = [ |
| 466 | Email( |
| 467 | sender="David Weedon", |
| 468 | sender_image=asset("david.png"), |
| 469 | sender_type="person", |
| 470 | subject="ChatKit Roadmap", |
| 471 | body=CHATKIT_ROADMAP, |
| 472 | to="zach@chatkit.studio", |
| 473 | sent_at="8:40 AM", |
| 474 | ), |
| 475 | Email( |
| 476 | sender="United Airlines", |
| 477 | sender_image=asset("united.png"), |
| 478 | sender_type="org", |
| 479 | subject="Quick reminders about your upcoming trip to San Francisco", |
| 480 | body=FLIGHT_REMINDER, |
| 481 | to="zach@chatkit.studio", |
| 482 | sent_at="8:12 AM", |
| 483 | ), |
| 484 | Email( |
| 485 | sender="Tyler Smith", |
| 486 | sender_image=asset("tyler.png"), |
| 487 | sender_type="person", |
| 488 | subject="re: Morning brainstorm", |
| 489 | body=BRAINSTORM_SESSION, |
| 490 | to="zach@chatkit.studio", |
| 491 | sent_at="Yesterday", |
| 492 | ), |
| 493 | ] |
| 494 | |
| 495 | def load_tasks(self): |
| 496 | self.tasks = [ |
| 497 | Task( |
| 498 | title="Add source annotations to responses", |
| 499 | description="Implement source annotations feature to add context to assistant responses. This is currently marked as 'In progress' in the roadmap.", |
| 500 | priority="high", |
| 501 | timeframe="today", |
| 502 | completed=False, |
| 503 | ), |
| 504 | Task( |
| 505 | title="Fix mobile web scrolling issues", |
| 506 | description="Address the rough mobile web experience, particularly scrolling and input caret tracking issues mentioned in known issues.", |
| 507 | priority="high", |
| 508 | timeframe="tomorrow", |
| 509 | completed=False, |
| 510 | ), |
| 511 | Task( |
| 512 | title="Implement graceful page refresh handling", |
| 513 | description="Fix the issue where refreshing the page while waiting for an assistant message causes the message to be dropped entirely.", |
| 514 | priority="high", |
| 515 | timeframe="week", |
| 516 | completed=False, |
| 517 | ), |
| 518 | Task( |
| 519 | title="Design enhanced interactive widgets", |
| 520 | description="Develop richer interactive elements for the widget system. This is planned for the upcoming roadmap.", |
| 521 | priority="high", |
| 522 | timeframe="month", |
| 523 | completed=True, |
| 524 | ), |
| 525 | Task( |
| 526 | title="Add robust attachment error handling", |
| 527 | description="Implement proper error handling for attachments to prevent items from staying in uploading state indefinitely.", |
| 528 | priority="high", |
| 529 | timeframe="week", |
| 530 | completed=False, |
| 531 | ), |
| 532 | Task( |
| 533 | title="Implement source scope selection", |
| 534 | description="Add the ability for users to choose specific data sources when interacting with the assistant.", |
| 535 | priority="low", |
| 536 | timeframe="month", |
| 537 | completed=False, |
| 538 | ), |
| 539 | Task( |
| 540 | title="Add custom fonts support", |
| 541 | description="Enable users to use different typefaces in their ChatKit interface. This is planned for customization features.", |
| 542 | priority="low", |
| 543 | timeframe="month", |
| 544 | completed=False, |
| 545 | ), |
| 546 | Task( |
| 547 | title="Implement dark mode theme switching", |
| 548 | description="Complete the theme switching functionality between light and dark modes with proper system integration.", |
| 549 | priority="low", |
| 550 | timeframe="tomorrow", |
| 551 | completed=True, |
| 552 | ), |
| 553 | Task( |
| 554 | title="Set up ChatKit Explorer development environment", |
| 555 | description="Configure the complete development setup including dependencies, environment variables, and make commands for local development.", |
| 556 | priority="low", |
| 557 | timeframe="today", |
| 558 | completed=True, |
| 559 | ), |
| 560 | ] |
| 561 | |
| 562 | def render( |
| 563 | self, |
| 564 | state: State | None = None, |
| 565 | ) -> WidgetRoot: |
| 566 | if state is not None: |
| 567 | self.state = state |
| 568 | |
| 569 | if self.state.type == "index": |
| 570 | return render_index(self.id, self.state) |
| 571 | if self.state.type == "email_draft": |
| 572 | return render_email_draft(self.id, self.state) |
| 573 | if self.state.type == "email_view": |
| 574 | return render_email_view(self.id, self.state) |
| 575 | if self.state.type == "emails_list": |
| 576 | return render_emails_list(self.id, self.state) |
| 577 | if self.state.type == "task_draft": |
| 578 | return render_task_draft(self.id, self.state) |
| 579 | if self.state.type == "task_view": |
| 580 | return render_task_view(self.id, self.state) |
| 581 | if self.state.type == "tasks_list": |
| 582 | return render_tasks_list(self.id, self.state) |
| 583 | if self.state.type == "calendar_event_draft": |
| 584 | return render_calendar_event_draft(self.id, self.state) |
| 585 | if self.state.type == "calendar_event_view": |
| 586 | return render_calendar_event_view(self.id, self.state) |
| 587 | if self.state.type == "calendar_events_list": |
| 588 | return render_calendar_events_list(self.id, self.state) |
| 589 | |
| 590 | assert_never(self.state) |
| 591 | |
| 592 | def render_with_status(self, text: str | None) -> WidgetRoot: |
| 593 | self.state.status_text = text |
| 594 | return self.render() |
| 595 | |
| 596 | async def save_and_generate( |
| 597 | self, |
| 598 | next_state: State, |
| 599 | generate: Callable[[], AsyncIterator[ThreadStreamEvent]], |
| 600 | save: Callable[[], AsyncIterator[ThreadStreamEvent]], |
| 601 | ): |
| 602 | async for event in save(): |
| 603 | yield ActionOutput.from_event(event) |
| 604 | |
| 605 | first = True |
| 606 | async for event in generate(): |
| 607 | if first: |
| 608 | first = False |
| 609 | yield ActionOutput.from_widget(self.render(next_state)) |
| 610 | yield ActionOutput.from_event(event) |
| 611 | |
| 612 | async def handle_action( |
| 613 | self, |
| 614 | action: SampleWidgetAction, |
| 615 | generate: Callable[[], AsyncIterator[ThreadStreamEvent]], |
| 616 | save: Callable[[], AsyncIterator[ThreadStreamEvent]], |
| 617 | ) -> AsyncIterator[ActionOutput]: |
| 618 | if action.type == "sample.show_widget": |
| 619 | next_state = Index( |
| 620 | selected=action.payload.widget, |
| 621 | status_text=f"Fetched {action.payload.widget} widget" |
| 622 | if action.payload.widget != "index" |
| 623 | else "Fetched widgets", |
| 624 | ) |
| 625 | yield ActionOutput.from_widget(self.render(next_state)) |
| 626 | |
| 627 | elif action.type == "sample.draft_email": |
| 628 | with open("../README.md", "r") as f: |
| 629 | readme = f.read() |
| 630 | |
| 631 | body_text = Runner.run_streamed( |
| 632 | email_generator, |
| 633 | "Draft an email asking David about the current status of the ChatKit roadmap. Keep it short and high level, ask questions about some of the items in the readme. Current status in the README:\n\n" |
| 634 | + readme, |
| 635 | ) |
| 636 | |
| 637 | next_email_state: EmailDraft = EmailDraft( |
| 638 | status_text="Drafting email", |
| 639 | email=DraftEmail( |
| 640 | subject="ChatKit Roadmap", |
| 641 | body="", |
| 642 | to="david@chatkit.studio", |
| 643 | ), |
| 644 | streaming=True, |
| 645 | ) |
| 646 | yield ActionOutput.from_widget(self.render(next_email_state)) |
| 647 | |
| 648 | async for event in body_text.stream_events(): |
| 649 | if event.type == "raw_response_event": |
| 650 | if event.data.type == "response.output_text.delta": |
| 651 | next_email_state.email.body += event.data.delta |
| 652 | next_email_state.streaming = True |
| 653 | yield ActionOutput.from_widget(self.render(next_email_state)) |
| 654 | |
| 655 | next_email_state.status_text = "Drafted email" |
| 656 | next_email_state.streaming = False |
| 657 | yield ActionOutput.from_widget(self.render(next_email_state)) |
| 658 | |
| 659 | elif action.type == "sample.show_inbox": |
| 660 | if not self.emails: |
| 661 | yield ActionOutput.from_widget( |
| 662 | self.render_with_status("Fetching inbox") |
| 663 | ) |
| 664 | await sleep(2) |
| 665 | self.load_emails() |
| 666 | next_state = EmailsList(status_text="Fetched inbox", emails=self.emails) |
| 667 | yield ActionOutput.from_widget(self.render(next_state)) |
| 668 | |
| 669 | elif action.type == "sample.send_email": |
| 670 | if self.state.type != "email_draft": |
| 671 | raise ValueError("Invalid state for sending email") |
| 672 | self.state.email = action.payload.email |
| 673 | self.state.status_text = "Sending" |
| 674 | yield ActionOutput.from_widget(self.render()) |
| 675 | email = Email( |
| 676 | sender="Zach Johnston", |
| 677 | sender_image=asset("zach.png"), |
| 678 | sender_type="person", |
| 679 | sent_at="Just now", |
| 680 | subject=action.payload.email.subject, |
| 681 | body=action.payload.email.body, |
| 682 | to=action.payload.email.to, |
| 683 | ) |
| 684 | next_state = EmailView( |
| 685 | status_text="Sent email", |
| 686 | status_icon="gmail-send-status-icon.png", |
| 687 | email=email, |
| 688 | show_back_button=False, |
| 689 | ) |
| 690 | async for event in self.save_and_generate(next_state, generate, save): |
| 691 | yield event |
| 692 | |
| 693 | elif action.type == "sample.discard_email": |
| 694 | yield ActionOutput.from_widget(self.render_with_status("Discarding")) |
| 695 | self.state.status_text = "Discarded email draft" |
| 696 | self.state.collapsed = True |
| 697 | async for event in self.save_and_generate(self.state, generate, save): |
| 698 | yield event |
| 699 | |
| 700 | elif action.type == "sample.view_email": |
| 701 | for email in self.emails: |
| 702 | if email.id == action.payload.email_id: |
| 703 | next_state = EmailView( |
| 704 | status_text=self.state.status_text, |
| 705 | email=email, |
| 706 | show_back_button=True, |
| 707 | ) |
| 708 | yield ActionOutput.from_widget(self.render(next_state)) |
| 709 | break |
| 710 | else: |
| 711 | raise ValueError(f"Email with id {action.payload.email_id} not found") |
| 712 | |
| 713 | elif action.type == "sample.draft_task": |
| 714 | next_state = TaskDraft( |
| 715 | status_text="Drafted task", |
| 716 | todo=DraftTask( |
| 717 | title="Design resizable popup mode", |
| 718 | description="Create a design proposal for how ChatKit's popup mode can support dynamic height and user resizing.", |
| 719 | priority="low", |
| 720 | timeframe="tomorrow", |
| 721 | ), |
| 722 | ) |
| 723 | yield ActionOutput.from_widget(self.render(next_state)) |
| 724 | |
| 725 | elif action.type == "sample.create_task": |
| 726 | if self.state.type != "task_draft": |
| 727 | raise ValueError("Invalid state for creating task") |
| 728 | self.state.todo = action.payload.todo |
| 729 | self.state.status_text = "Creating task" |
| 730 | yield ActionOutput.from_widget(self.render()) |
| 731 | task = Task( |
| 732 | title=action.payload.todo.title, |
| 733 | description=action.payload.todo.description, |
| 734 | priority=action.payload.todo.priority, |
| 735 | timeframe=action.payload.todo.timeframe, |
| 736 | due_date=action.payload.todo.due_date, |
| 737 | completed=False, |
| 738 | ) |
| 739 | self.tasks.append(task) |
| 740 | next_state = TaskView( |
| 741 | status_text="Created task", |
| 742 | task=task, |
| 743 | show_back_button=False, |
| 744 | ) |
| 745 | async for event in self.save_and_generate(next_state, generate, save): |
| 746 | yield event |
| 747 | |
| 748 | elif action.type == "sample.cancel_task": |
| 749 | yield ActionOutput.from_widget(self.render_with_status("Discarding")) |
| 750 | self.state.status_text = "Discarded task draft" |
| 751 | self.state.collapsed = True |
| 752 | async for event in self.save_and_generate(self.state, generate, save): |
| 753 | yield event |
| 754 | |
| 755 | elif action.type == "sample.view_tasks": |
| 756 | if not self.tasks: |
| 757 | yield ActionOutput.from_widget( |
| 758 | self.render_with_status("Fetching tasks") |
| 759 | ) |
| 760 | await sleep(2) |
| 761 | self.load_tasks() |
| 762 | next_state = TasksList(status_text="Fetched tasks", tasks=self.tasks) |
| 763 | yield ActionOutput.from_widget(self.render(next_state)) |
| 764 | |
| 765 | elif action.type == "sample.view_task": |
| 766 | for task in self.tasks: |
| 767 | if task.id == action.payload.task_id: |
| 768 | next_state = TaskView( |
| 769 | status_text=self.state.status_text, |
| 770 | task=task, |
| 771 | show_back_button=True, |
| 772 | ) |
| 773 | yield ActionOutput.from_widget(self.render(next_state)) |
| 774 | break |
| 775 | else: |
| 776 | raise ValueError(f"Task with id {action.payload.task_id} not found") |
| 777 | |
| 778 | elif action.type == "sample.toggle_task_complete": |
| 779 | for task in self.tasks: |
| 780 | if task.id == action.payload.task_id: |
| 781 | task.completed = not task.completed |
| 782 | break |
| 783 | else: |
| 784 | raise ValueError(f"Task with id {action.payload.task_id} not found") |
| 785 | |
| 786 | if self.state.type == "tasks_list": |
| 787 | self.state.tasks = self.tasks |
| 788 | elif self.state.type == "task_view": |
| 789 | self.state.task = task |
| 790 | |
| 791 | yield ActionOutput.from_widget(self.render()) |
| 792 | |
| 793 | elif action.type == "sample.update_draft_task": |
| 794 | if self.state.type != "task_draft": |
| 795 | raise ValueError("Invalid draft task update") |
| 796 | self.state.todo = action.payload.todo |
| 797 | yield ActionOutput.from_widget(self.render()) |
| 798 | |
| 799 | elif action.type == "sample.draft_event": |
| 800 | next_state = CalendarEventDraft( |
| 801 | status_text="Drafted calendar event", |
| 802 | event=DraftCalendarEvent( |
| 803 | title="Q3 roadmap review", |
| 804 | description="Quarterly planning session to review and align on the Q3 roadmap.", |
| 805 | date="Wed 16", |
| 806 | start_time="9:00", |
| 807 | end_time="10:30 AM", |
| 808 | calendar="Work", |
| 809 | ), |
| 810 | ) |
| 811 | yield ActionOutput.from_widget(self.render(next_state)) |
| 812 | |
| 813 | elif action.type == "sample.create_event": |
| 814 | yield ActionOutput.from_widget( |
| 815 | self.render_with_status("Creating calendar event") |
| 816 | ) |
| 817 | event = CalendarEvent( |
| 818 | title=action.payload.event.title, |
| 819 | date=action.payload.event.date, |
| 820 | start_time=action.payload.event.start_time, |
| 821 | end_time=action.payload.event.end_time, |
| 822 | description=action.payload.event.description, |
| 823 | calendar=action.payload.event.calendar, |
| 824 | ) |
| 825 | self.events.append(event) |
| 826 | next_state = CalendarEventView( |
| 827 | status_text="Added event to calendar", |
| 828 | event=event, |
| 829 | show_back_button=False, |
| 830 | ) |
| 831 | async for event in self.save_and_generate(next_state, generate, save): |
| 832 | yield event |
| 833 | |
| 834 | elif action.type == "sample.discard_event": |
| 835 | yield ActionOutput.from_widget(self.render_with_status("Discarding")) |
| 836 | self.state.status_text = "Discarded draft event" |
| 837 | self.state.collapsed = True |
| 838 | async for event in self.save_and_generate(self.state, generate, save): |
| 839 | yield event |
| 840 | |
| 841 | elif action.type == "sample.view_schedule": |
| 842 | if not self.events: |
| 843 | yield ActionOutput.from_widget( |
| 844 | self.render_with_status("Fetching schedule") |
| 845 | ) |
| 846 | await sleep(2) |
| 847 | self.load_events() |
| 848 | next_state = CalendarEventsList( |
| 849 | status_text="Fetched schedule", events=self.events |
| 850 | ) |
| 851 | yield ActionOutput.from_widget(self.render(next_state)) |
| 852 | |
| 853 | elif action.type == "sample.view_event": |
| 854 | for event in self.events: |
| 855 | if event.id == action.payload.event_id: |
| 856 | next_state = CalendarEventView( |
| 857 | status_text=self.state.status_text, |
| 858 | event=event, |
| 859 | show_back_button=True, |
| 860 | ) |
| 861 | yield ActionOutput.from_widget(self.render(next_state)) |
| 862 | break |
| 863 | else: |
| 864 | raise ValueError(f"Event with id {action.payload.event_id} not found") |
| 865 | |
| 866 | else: |
| 867 | assert_never(action) |
| 868 | |
| 869 | |
| 870 | # HELPER TEMPLATES |
| 871 | |
| 872 | |
| 873 | def back_button_list_item(action: ActionConfig): |
| 874 | action.loadingBehavior = "container" |
| 875 | return ListViewItem( |
| 876 | onClickAction=action, |
| 877 | gap=3, |
| 878 | children=[ |
| 879 | Button( |
| 880 | size="3xs", |
| 881 | iconStart="chevron-left", |
| 882 | color="primary", |
| 883 | variant="soft", |
| 884 | iconSize="sm", |
| 885 | label="", |
| 886 | pill=True, |
| 887 | uniform=True, |
| 888 | onClickAction=action, |
| 889 | ), |
| 890 | Text(value="Back", color="emphasis"), |
| 891 | ], |
| 892 | ) |
| 893 | |
| 894 | |
| 895 | # INDEX TEMPLATES |
| 896 | |
| 897 | |
| 898 | def render_index(id: str, state: Index): |
| 899 | if state.selected == "index": |
| 900 | return render_widget_list(id, state) |
| 901 | if state.selected == "email": |
| 902 | return render_email_widget_list(id, state) |
| 903 | if state.selected == "calendar": |
| 904 | return render_calendar_widget_list(id, state) |
| 905 | if state.selected == "tasks": |
| 906 | return render_tasks_widget_list(id, state) |
| 907 | |
| 908 | assert_never(state.selected) |
| 909 | |
| 910 | |
| 911 | def render_widget_list(id: str, state: Index): |
| 912 | return ListView( |
| 913 | status=state.status(), |
| 914 | key="index.pick", |
| 915 | children=[ |
| 916 | ListViewItem( |
| 917 | onClickAction=ShowWidgetAction.create( |
| 918 | ShowWidgetPayload(widget="email", widget_id=id), |
| 919 | loading_behavior="container", |
| 920 | ), |
| 921 | gap=3, |
| 922 | children=[ |
| 923 | Image( |
| 924 | src=asset("gmail-list-icon.png"), |
| 925 | size="60px", |
| 926 | frame=True, |
| 927 | ), |
| 928 | Col( |
| 929 | children=[ |
| 930 | Text( |
| 931 | value="Email widget", |
| 932 | weight="medium", |
| 933 | color="emphasis", |
| 934 | ), |
| 935 | Text( |
| 936 | value="Craft and preview an email before sending", |
| 937 | color="secondary", |
| 938 | ), |
| 939 | ] |
| 940 | ), |
| 941 | ], |
| 942 | ), |
| 943 | ListViewItem( |
| 944 | onClickAction=ShowWidgetAction.create( |
| 945 | ShowWidgetPayload(widget="calendar", widget_id=id), |
| 946 | loading_behavior="container", |
| 947 | ), |
| 948 | gap=3, |
| 949 | children=[ |
| 950 | Image( |
| 951 | src=asset("calendar-list-icon.png"), |
| 952 | size="60px", |
| 953 | frame=True, |
| 954 | ), |
| 955 | Col( |
| 956 | children=[ |
| 957 | Text( |
| 958 | value="Calendar widget", |
| 959 | weight="medium", |
| 960 | color="emphasis", |
| 961 | ), |
| 962 | Text( |
| 963 | value="Add events to your calendar", |
| 964 | color="secondary", |
| 965 | ), |
| 966 | ] |
| 967 | ), |
| 968 | ], |
| 969 | ), |
| 970 | ListViewItem( |
| 971 | onClickAction=ShowWidgetAction.create( |
| 972 | ShowWidgetPayload(widget="tasks", widget_id=id), |
| 973 | loading_behavior="container", |
| 974 | ), |
| 975 | gap=3, |
| 976 | children=[ |
| 977 | Image( |
| 978 | src=asset("linear-list-icon.png"), |
| 979 | size="60px", |
| 980 | frame=True, |
| 981 | ), |
| 982 | Col( |
| 983 | children=[ |
| 984 | Text( |
| 985 | value="Tasks widget", |
| 986 | weight="medium", |
| 987 | color="emphasis", |
| 988 | ), |
| 989 | Text( |
| 990 | value="Manage your tasks and to-dos", |
| 991 | color="secondary", |
| 992 | ), |
| 993 | ] |
| 994 | ), |
| 995 | ], |
| 996 | ), |
| 997 | ], |
| 998 | ) |
| 999 | |
| 1000 | |
| 1001 | def render_email_widget_list(id: str, state: Index): |
| 1002 | return ListView( |
| 1003 | status=state.status(), |
| 1004 | key="index.email", |
| 1005 | children=[ |
| 1006 | back_button_list_item( |
| 1007 | ShowWidgetAction.create(ShowWidgetPayload(widget="index", widget_id=id)) |
| 1008 | ), |
| 1009 | ListViewItem( |
| 1010 | onClickAction=ShowInboxAction.create( |
| 1011 | BasePayload(widget_id=id), loading_behavior="container" |
| 1012 | ), |
| 1013 | gap=3, |
| 1014 | children=[ |
| 1015 | Image( |
| 1016 | src=asset("gmail-inbox-icon.png"), |
| 1017 | size="40px", |
| 1018 | frame=True, |
| 1019 | ), |
| 1020 | Text(value="View inbox", color="emphasis"), |
| 1021 | ], |
| 1022 | ), |
| 1023 | ListViewItem( |
| 1024 | onClickAction=DraftEmailAction.create(BasePayload(widget_id=id)), |
| 1025 | gap=3, |
| 1026 | children=[ |
| 1027 | Image( |
| 1028 | src=asset("gmail-send-icon.png"), |
| 1029 | size="40px", |
| 1030 | frame=True, |
| 1031 | ), |
| 1032 | Text(value="Send an email", color="emphasis"), |
| 1033 | ], |
| 1034 | ), |
| 1035 | ], |
| 1036 | ) |
| 1037 | |
| 1038 | |
| 1039 | def render_tasks_widget_list(id: str, state: Index): |
| 1040 | return ListView( |
| 1041 | status=state.status(), |
| 1042 | key="index.tasks", |
| 1043 | children=[ |
| 1044 | back_button_list_item( |
| 1045 | action=ShowWidgetAction.create( |
| 1046 | ShowWidgetPayload(widget="index", widget_id=id) |
| 1047 | ) |
| 1048 | ), |
| 1049 | ListViewItem( |
| 1050 | onClickAction=ViewTasksAction.create( |
| 1051 | BasePayload(widget_id=id), loading_behavior="container" |
| 1052 | ), |
| 1053 | gap=3, |
| 1054 | children=[ |
| 1055 | Image(src=asset("linear-view-icon.png"), size=40, frame=True), |
| 1056 | Text(value="View tasks", color="emphasis"), |
| 1057 | ], |
| 1058 | ), |
| 1059 | ListViewItem( |
| 1060 | onClickAction=DraftTaskAction.create( |
| 1061 | BasePayload(widget_id=id), loading_behavior="container" |
| 1062 | ), |
| 1063 | gap=3, |
| 1064 | children=[ |
| 1065 | Image(src=asset("linear-create-icon.png"), size=40, frame=True), |
| 1066 | Text(value="Create a task", color="emphasis"), |
| 1067 | ], |
| 1068 | ), |
| 1069 | ], |
| 1070 | ) |
| 1071 | |
| 1072 | |
| 1073 | def render_calendar_widget_list(id: str, state: Index): |
| 1074 | return ListView( |
| 1075 | status=state.status(), |
| 1076 | key="index.calendar", |
| 1077 | children=[ |
| 1078 | back_button_list_item( |
| 1079 | action=ShowWidgetAction.create( |
| 1080 | ShowWidgetPayload(widget="index", widget_id=id) |
| 1081 | ) |
| 1082 | ), |
| 1083 | ListViewItem( |
| 1084 | onClickAction=ViewScheduleAction.create( |
| 1085 | BasePayload(widget_id=id), loading_behavior="container" |
| 1086 | ), |
| 1087 | gap=3, |
| 1088 | children=[ |
| 1089 | Image( |
| 1090 | src=asset("calendar-schedule-icon.png"), |
| 1091 | size="40px", |
| 1092 | frame=True, |
| 1093 | ), |
| 1094 | Text(value="View schedule", color="emphasis"), |
| 1095 | ], |
| 1096 | ), |
| 1097 | ListViewItem( |
| 1098 | onClickAction=DraftEventAction.create( |
| 1099 | BasePayload(widget_id=id), loading_behavior="container" |
| 1100 | ), |
| 1101 | gap=3, |
| 1102 | children=[ |
| 1103 | Image( |
| 1104 | src=asset("calendar-create-icon.png"), |
| 1105 | size="40px", |
| 1106 | frame=True, |
| 1107 | ), |
| 1108 | Text(value="Create an event", color="emphasis"), |
| 1109 | ], |
| 1110 | ), |
| 1111 | ], |
| 1112 | ) |
| 1113 | |
| 1114 | |
| 1115 | # EMAIL TEMPLATES |
| 1116 | |
| 1117 | |
| 1118 | def render_email_draft(id: str, state: EmailDraft): |
| 1119 | return Card( |
| 1120 | key="email.draft", |
| 1121 | size="lg", |
| 1122 | status=state.status(), |
| 1123 | collapsed=state.collapsed, |
| 1124 | asForm=True, |
| 1125 | confirm={ |
| 1126 | "label": "Send email", |
| 1127 | "action": SendEmailAction.create( |
| 1128 | SendEmailPayload(email=state.email, widget_id=id), |
| 1129 | loading_behavior="self", |
| 1130 | ), |
| 1131 | } |
| 1132 | if not state.collapsed |
| 1133 | else None, |
| 1134 | cancel={ |
| 1135 | "label": "Discard", |
| 1136 | "action": DiscardEmailAction.create(BasePayload(widget_id=id)), |
| 1137 | } |
| 1138 | if not state.collapsed |
| 1139 | else None, |
| 1140 | children=[ |
| 1141 | Col( |
| 1142 | gap=3, |
| 1143 | children=[ |
| 1144 | Row( |
| 1145 | gap=4, |
| 1146 | align="baseline", |
| 1147 | children=[ |
| 1148 | Text( |
| 1149 | value="TO", |
| 1150 | weight="semibold", |
| 1151 | color="tertiary", |
| 1152 | size="xs", |
| 1153 | width=64, |
| 1154 | ), |
| 1155 | Text(value=state.email.to), |
| 1156 | ], |
| 1157 | ), |
| 1158 | Divider(), |
| 1159 | Row( |
| 1160 | gap=4, |
| 1161 | align="baseline", |
| 1162 | children=[ |
| 1163 | Text( |
| 1164 | value="SUBJECT", |
| 1165 | weight="semibold", |
| 1166 | color="tertiary", |
| 1167 | size="xs", |
| 1168 | width=64, |
| 1169 | ), |
| 1170 | Text( |
| 1171 | value=state.email.subject, |
| 1172 | editable={"name": "subject", "required": True} |
| 1173 | if not state.collapsed |
| 1174 | and state.status_text != "Sending" |
| 1175 | else False, |
| 1176 | ), |
| 1177 | ], |
| 1178 | ), |
| 1179 | Divider(), |
| 1180 | Row( |
| 1181 | flex="auto", |
| 1182 | children=[ |
| 1183 | Text( |
| 1184 | width="100%", |
| 1185 | value=state.email.body, |
| 1186 | streaming=state.streaming, |
| 1187 | id="email_body", |
| 1188 | minLines=10, |
| 1189 | editable={ |
| 1190 | "name": "body", |
| 1191 | "autoFocus": True, |
| 1192 | "required": True, |
| 1193 | } |
| 1194 | if not state.collapsed |
| 1195 | and state.status_text != "Sending" |
| 1196 | else False, |
| 1197 | ), |
| 1198 | ], |
| 1199 | ), |
| 1200 | ], |
| 1201 | ) |
| 1202 | ], |
| 1203 | ) |
| 1204 | |
| 1205 | |
| 1206 | def render_email_view(id: str, state: EmailView): |
| 1207 | footer: list[WidgetComponent] = [] |
| 1208 | if state.show_back_button: |
| 1209 | footer = [ |
| 1210 | Divider(flush=True), |
| 1211 | Row( |
| 1212 | children=[ |
| 1213 | Button( |
| 1214 | onClickAction=ShowInboxAction.create( |
| 1215 | BasePayload(widget_id=id), loading_behavior="container" |
| 1216 | ), |
| 1217 | label="Back", |
| 1218 | color="primary", |
| 1219 | variant="outline", |
| 1220 | pill=True, |
| 1221 | iconStart="chevron-left", |
| 1222 | ) |
| 1223 | ] |
| 1224 | ), |
| 1225 | ] |
| 1226 | |
| 1227 | return Card( |
| 1228 | key="email.view", |
| 1229 | size="lg", |
| 1230 | status=state.status(), |
| 1231 | children=[ |
| 1232 | Row( |
| 1233 | gap=3, |
| 1234 | children=[ |
| 1235 | Image( |
| 1236 | src=state.email.sender_image, |
| 1237 | size=40, |
| 1238 | radius="full", |
| 1239 | frame=True, |
| 1240 | ), |
| 1241 | Col( |
| 1242 | children=[ |
| 1243 | Text( |
| 1244 | value=state.email.sender, |
| 1245 | weight="semibold", |
| 1246 | size="md", |
| 1247 | color="emphasis", |
| 1248 | ), |
| 1249 | Text( |
| 1250 | value=f"To: {state.email.to}", |
| 1251 | size="sm", |
| 1252 | color="secondary", |
| 1253 | ), |
| 1254 | ] |
| 1255 | ), |
| 1256 | Spacer(), |
| 1257 | Text( |
| 1258 | value=state.email.sent_at or "", |
| 1259 | size="sm", |
| 1260 | color="secondary", |
| 1261 | ), |
| 1262 | ], |
| 1263 | ), |
| 1264 | Divider(flush=True), |
| 1265 | Col( |
| 1266 | gap=6, |
| 1267 | children=[ |
| 1268 | Text( |
| 1269 | value=state.email.subject, |
| 1270 | weight="semibold", |
| 1271 | size="xl", |
| 1272 | color="emphasis", |
| 1273 | ), |
| 1274 | Markdown(value=state.email.body), |
| 1275 | ], |
| 1276 | ), |
| 1277 | ] |
| 1278 | + footer, |
| 1279 | ) |
| 1280 | |
| 1281 | |
| 1282 | def render_emails_list(id: str, state: EmailsList): |
| 1283 | return ListView( |
| 1284 | key="email.inbox", |
| 1285 | status=state.status(), |
| 1286 | children=[ |
| 1287 | back_button_list_item( |
| 1288 | ShowWidgetAction.create( |
| 1289 | ShowWidgetPayload(widget="email", widget_id=id), |
| 1290 | loading_behavior="container", |
| 1291 | ) |
| 1292 | ) |
| 1293 | ] |
| 1294 | + [ |
| 1295 | ListViewItem( |
| 1296 | onClickAction=ViewEmailAction.create( |
| 1297 | ViewEmailPayload(email_id=email.id, widget_id=id) |
| 1298 | ), |
| 1299 | gap=3, |
| 1300 | align="start", |
| 1301 | key=email.id, |
| 1302 | children=[ |
| 1303 | Image( |
| 1304 | src=email.sender_image, |
| 1305 | size="40px", |
| 1306 | radius="md" if email.sender_type == "org" else "full", |
| 1307 | frame=True, |
| 1308 | ), |
| 1309 | Col( |
| 1310 | children=[ |
| 1311 | Row( |
| 1312 | align="start", |
| 1313 | children=[ |
| 1314 | Col( |
| 1315 | children=[ |
| 1316 | Text( |
| 1317 | value=email.sender, |
| 1318 | weight="semibold", |
| 1319 | color="emphasis", |
| 1320 | ), |
| 1321 | Text( |
| 1322 | value=email.subject, |
| 1323 | color="emphasis", |
| 1324 | size="sm", |
| 1325 | ), |
| 1326 | ] |
| 1327 | ), |
| 1328 | Spacer(), |
| 1329 | Text( |
| 1330 | value=email.sent_at or "", |
| 1331 | size="sm", |
| 1332 | color="secondary", |
| 1333 | ), |
| 1334 | ], |
| 1335 | ), |
| 1336 | Text( |
| 1337 | value=re.sub(r"\s+", " ", email.body)[:500], |
| 1338 | size="sm", |
| 1339 | color="secondary", |
| 1340 | maxLines=2, |
| 1341 | ), |
| 1342 | ] |
| 1343 | ), |
| 1344 | ], |
| 1345 | ) |
| 1346 | for email in state.emails |
| 1347 | ], |
| 1348 | ) |
| 1349 | |
| 1350 | |
| 1351 | # TASK TEMPLATES |
| 1352 | |
| 1353 | |
| 1354 | def render_task_draft(id: str, state: TaskDraft): |
| 1355 | disabled = state.collapsed or state.status_text == "Creating task" |
| 1356 | |
| 1357 | return Card( |
| 1358 | key="tasks.draft", |
| 1359 | size="lg", |
| 1360 | padding=0, |
| 1361 | status=state.status(), |
| 1362 | collapsed=state.collapsed, |
| 1363 | asForm=True, |
| 1364 | confirm={ |
| 1365 | "label": "Create task", |
| 1366 | "action": CreateTaskAction.create( |
| 1367 | CreateTaskPayload(todo=state.todo, widget_id=id), |
| 1368 | loading_behavior="self", |
| 1369 | ), |
| 1370 | } |
| 1371 | if not state.collapsed |
| 1372 | else None, |
| 1373 | cancel={ |
| 1374 | "label": "Cancel", |
| 1375 | "action": CancelTaskAction.create(BasePayload(widget_id=id)), |
| 1376 | } |
| 1377 | if not state.collapsed |
| 1378 | else None, |
| 1379 | children=[ |
| 1380 | Col( |
| 1381 | padding=4, |
| 1382 | gap=2, |
| 1383 | children=[ |
| 1384 | Text( |
| 1385 | editable={"name": "todo.title", "required": True} |
| 1386 | if not disabled |
| 1387 | else False, |
| 1388 | value=state.todo.title, |
| 1389 | weight="semibold", |
| 1390 | color="emphasis", |
| 1391 | size="lg", |
| 1392 | ), |
| 1393 | Text( |
| 1394 | value=state.todo.description, |
| 1395 | color="emphasis", |
| 1396 | minLines=6, |
| 1397 | editable={"name": "todo.description", "autoFocus": True} |
| 1398 | if not disabled |
| 1399 | else False, |
| 1400 | ), |
| 1401 | ], |
| 1402 | ), |
| 1403 | Col( |
| 1404 | padding={"x": 4, "y": 3.5}, |
| 1405 | background="surface-secondary", |
| 1406 | border={"top": {"size": 1, "color": "subtle"}}, |
| 1407 | children=[ |
| 1408 | Row( |
| 1409 | gap=2, |
| 1410 | children=[ |
| 1411 | Row( |
| 1412 | gap=2, |
| 1413 | width="fit-content", |
| 1414 | wrap="wrap", |
| 1415 | children=[ |
| 1416 | Select( |
| 1417 | name="todo.priority", |
| 1418 | disabled=disabled, |
| 1419 | defaultValue=state.todo.priority, |
| 1420 | pill=True, |
| 1421 | options=[ |
| 1422 | {"value": "low", "label": "Low priority"}, |
| 1423 | {"value": "high", "label": "High priority"}, |
| 1424 | ], |
| 1425 | ), |
| 1426 | Select( |
| 1427 | name="todo.timeframe", |
| 1428 | disabled=disabled, |
| 1429 | defaultValue=state.todo.timeframe, |
| 1430 | onChangeAction=UpdateDraftTaskAction.create( |
| 1431 | UpdateDraftTaskPayload( |
| 1432 | todo=state.todo, widget_id=id |
| 1433 | ) |
| 1434 | ), |
| 1435 | pill=True, |
| 1436 | options=[ |
| 1437 | {"value": "today", "label": "Due today"}, |
| 1438 | { |
| 1439 | "value": "tomorrow", |
| 1440 | "label": "Due tomorrow", |
| 1441 | }, |
| 1442 | { |
| 1443 | "value": "week", |
| 1444 | "label": "Due by end of week", |
| 1445 | }, |
| 1446 | { |
| 1447 | "value": "month", |
| 1448 | "label": "Due by end of month", |
| 1449 | }, |
| 1450 | { |
| 1451 | "value": "custom", |
| 1452 | "label": "Specific date", |
| 1453 | }, |
| 1454 | ], |
| 1455 | ), |
| 1456 | *( |
| 1457 | [ |
| 1458 | Row( |
| 1459 | gap=2, |
| 1460 | children=[ |
| 1461 | Text( |
| 1462 | value="Due by", |
| 1463 | size="sm", |
| 1464 | color="secondary", |
| 1465 | ), |
| 1466 | DatePicker( |
| 1467 | name="todo.due_date", |
| 1468 | defaultValue=state.todo.due_date, |
| 1469 | pill=True, |
| 1470 | disabled=disabled, |
| 1471 | ), |
| 1472 | ], |
| 1473 | ) |
| 1474 | ] |
| 1475 | if state.todo.timeframe == "custom" |
| 1476 | else [] |
| 1477 | ), |
| 1478 | ], |
| 1479 | ), |
| 1480 | ], |
| 1481 | ), |
| 1482 | ], |
| 1483 | ), |
| 1484 | ], |
| 1485 | ) |
| 1486 | |
| 1487 | |
| 1488 | def render_task_view(id: str, state: TaskView): |
| 1489 | header: list[WidgetComponent] = ( |
| 1490 | [ |
| 1491 | Row( |
| 1492 | margin={"x": -2, "top": -2, "bottom": -1}, |
| 1493 | children=[ |
| 1494 | Button( |
| 1495 | onClickAction=ViewTasksAction.create( |
| 1496 | BasePayload(widget_id=id), loading_behavior="container" |
| 1497 | ), |
| 1498 | label="Back", |
| 1499 | color="secondary", |
| 1500 | variant="ghost", |
| 1501 | iconStart="chevron-left", |
| 1502 | size="xs", |
| 1503 | pill=True, |
| 1504 | ), |
| 1505 | ], |
| 1506 | ), |
| 1507 | Divider(flush=True), |
| 1508 | ] |
| 1509 | if state.show_back_button |
| 1510 | else [] |
| 1511 | ) |
| 1512 | |
| 1513 | body = [ |
| 1514 | Col( |
| 1515 | gap=1, |
| 1516 | children=[ |
| 1517 | Text( |
| 1518 | value=f"{state.task.priority} priority", |
| 1519 | color=state.task.priority_color(), |
| 1520 | size="sm", |
| 1521 | weight="medium", |
| 1522 | ), |
| 1523 | Title( |
| 1524 | color="emphasis", |
| 1525 | value=state.task.title, |
| 1526 | weight="semibold", |
| 1527 | size="lg", |
| 1528 | ), |
| 1529 | Row( |
| 1530 | align="center", |
| 1531 | height=26, |
| 1532 | children=[ |
| 1533 | Text( |
| 1534 | key="task.due_date", |
| 1535 | value=state.task.humanized_due_date(), |
| 1536 | color="tertiary", |
| 1537 | size="sm", |
| 1538 | ) |
| 1539 | ], |
| 1540 | ) |
| 1541 | if not state.task.completed |
| 1542 | else Row( |
| 1543 | key="task.completed", |
| 1544 | gap=1, |
| 1545 | height=22, |
| 1546 | margin={"top": 1}, |
| 1547 | justify="start", |
| 1548 | width="fit-content", |
| 1549 | padding={"left": 1, "right": 2}, |
| 1550 | background="blue-50", |
| 1551 | radius="full", |
| 1552 | children=[ |
| 1553 | Icon(name="check-circle-filled", color="blue-400"), |
| 1554 | Text( |
| 1555 | value="Complete", |
| 1556 | color="blue-400", |
| 1557 | size="xs", |
| 1558 | weight="semibold", |
| 1559 | ), |
| 1560 | ], |
| 1561 | ), |
| 1562 | ], |
| 1563 | ), |
| 1564 | Divider(flush=True), |
| 1565 | Text(value=state.task.description, minLines=6), |
| 1566 | ] |
| 1567 | |
| 1568 | footer = [ |
| 1569 | Transition( |
| 1570 | children=Col( |
| 1571 | align="start", |
| 1572 | children=[ |
| 1573 | Button( |
| 1574 | onClickAction=ToggleTaskCompleteAction.create( |
| 1575 | ToggleTaskCompletePayload( |
| 1576 | task_id=state.task.id, widget_id=id |
| 1577 | ) |
| 1578 | ), |
| 1579 | label="Mark complete", |
| 1580 | color="secondary", |
| 1581 | variant="outline", |
| 1582 | iconStart="check-circle", |
| 1583 | pill=True, |
| 1584 | ), |
| 1585 | ], |
| 1586 | ) |
| 1587 | if not state.task.completed |
| 1588 | else None |
| 1589 | ) |
| 1590 | ] |
| 1591 | |
| 1592 | return Card( |
| 1593 | key="tasks.view", |
| 1594 | size="lg", |
| 1595 | status=state.status(), |
| 1596 | children=[Col(gap=3, children=header + body + footer)], |
| 1597 | ) |
| 1598 | |
| 1599 | |
| 1600 | def render_tasks_list(id: str, state: TasksList): |
| 1601 | return ListView( |
| 1602 | key="tasks.list", |
| 1603 | status=state.status(), |
| 1604 | limit=5, |
| 1605 | children=[ |
| 1606 | back_button_list_item( |
| 1607 | ShowWidgetAction.create(ShowWidgetPayload(widget="tasks", widget_id=id)) |
| 1608 | ), |
| 1609 | ] |
| 1610 | + [ |
| 1611 | ListViewItem( |
| 1612 | onClickAction=ViewTaskAction.create( |
| 1613 | ViewTaskPayload(task_id=task.id, widget_id=id) |
| 1614 | ), |
| 1615 | children=[ |
| 1616 | Col( |
| 1617 | children=[ |
| 1618 | Row( |
| 1619 | gap=3, |
| 1620 | children=[ |
| 1621 | Button( |
| 1622 | size="3xs", |
| 1623 | variant=task.completed and "solid" or "outline", |
| 1624 | color=task.completed and "info" or "primary", |
| 1625 | iconStart=task.completed and "check" or None, |
| 1626 | uniform=True, |
| 1627 | pill=True, |
| 1628 | iconSize="lg", |
| 1629 | label="", |
| 1630 | onClickAction=ToggleTaskCompleteAction.create( |
| 1631 | ToggleTaskCompletePayload( |
| 1632 | task_id=task.id, widget_id=id |
| 1633 | ) |
| 1634 | ), |
| 1635 | ), |
| 1636 | Text( |
| 1637 | value=task.title, |
| 1638 | weight="medium", |
| 1639 | color="emphasis", |
| 1640 | ), |
| 1641 | ], |
| 1642 | ), |
| 1643 | Transition( |
| 1644 | children=Row( |
| 1645 | padding={"left": 8.5}, |
| 1646 | children=[ |
| 1647 | Text( |
| 1648 | value=task.humanized_due_date(), |
| 1649 | size="sm", |
| 1650 | weight="medium", |
| 1651 | color=task.urgency_color(), |
| 1652 | ), |
| 1653 | ], |
| 1654 | ) |
| 1655 | if not task.completed |
| 1656 | else None, |
| 1657 | ), |
| 1658 | ], |
| 1659 | ), |
| 1660 | ], |
| 1661 | ) |
| 1662 | for task in state.tasks |
| 1663 | ], |
| 1664 | ) |
| 1665 | |
| 1666 | |
| 1667 | # CALENDAR TEMPLATES |
| 1668 | |
| 1669 | |
| 1670 | def render_calendar_event_draft(id: str, state: CalendarEventDraft): |
| 1671 | return Card( |
| 1672 | key="calendar.draft", |
| 1673 | status=state.status(), |
| 1674 | collapsed=state.collapsed, |
| 1675 | confirm={ |
| 1676 | "label": "Add to calendar", |
| 1677 | "action": CreateEventAction.create( |
| 1678 | CreateEventPayload(event=state.event, widget_id=id), |
| 1679 | loading_behavior="self", |
| 1680 | ), |
| 1681 | } |
| 1682 | if not state.collapsed |
| 1683 | else None, |
| 1684 | cancel={ |
| 1685 | "label": "Discard", |
| 1686 | "action": DiscardEventAction.create(BasePayload(widget_id=id)), |
| 1687 | } |
| 1688 | if not state.collapsed |
| 1689 | else None, |
| 1690 | children=[ |
| 1691 | Row( |
| 1692 | align="stretch", |
| 1693 | justify="stretch", |
| 1694 | children=[ |
| 1695 | Col( |
| 1696 | width="64px", |
| 1697 | children=[ |
| 1698 | Text( |
| 1699 | value="Wed", |
| 1700 | size="xs", |
| 1701 | color="tertiary", |
| 1702 | weight="semibold", |
| 1703 | ), |
| 1704 | Title( |
| 1705 | value="16", |
| 1706 | size="xl", |
| 1707 | color="emphasis", |
| 1708 | weight="semibold", |
| 1709 | ), |
| 1710 | ], |
| 1711 | ), |
| 1712 | Col( |
| 1713 | gap=2, |
| 1714 | flex="auto", |
| 1715 | children=[ |
| 1716 | Row( |
| 1717 | padding=2, |
| 1718 | background="surface-tertiary", |
| 1719 | radius="md", |
| 1720 | align="stretch", |
| 1721 | gap=3, |
| 1722 | children=[ |
| 1723 | Box(background="red-400", width=4, radius="full"), |
| 1724 | Col( |
| 1725 | children=[ |
| 1726 | Text(value="Lunch", weight="medium"), |
| 1727 | Text( |
| 1728 | value="12:00 - 12:45 PM", |
| 1729 | size="xs", |
| 1730 | color="secondary", |
| 1731 | ), |
| 1732 | ], |
| 1733 | ), |
| 1734 | ], |
| 1735 | ), |
| 1736 | Row( |
| 1737 | padding=2, |
| 1738 | border={"style": "dashed", "size": 1}, |
| 1739 | radius="md", |
| 1740 | align="stretch", |
| 1741 | gap=3, |
| 1742 | children=[ |
| 1743 | Box( |
| 1744 | background=state.event.calendar_color(), |
| 1745 | width=4, |
| 1746 | radius="full", |
| 1747 | ), |
| 1748 | Col( |
| 1749 | children=[ |
| 1750 | Text( |
| 1751 | value=state.event.title, weight="medium" |
| 1752 | ), |
| 1753 | Text( |
| 1754 | value=state.event.time(), |
| 1755 | size="xs", |
| 1756 | color="secondary", |
| 1757 | ), |
| 1758 | ], |
| 1759 | ), |
| 1760 | ], |
| 1761 | ), |
| 1762 | Row( |
| 1763 | padding=2, |
| 1764 | background="surface-tertiary", |
| 1765 | radius="md", |
| 1766 | align="stretch", |
| 1767 | gap=3, |
| 1768 | children=[ |
| 1769 | Box(background="red-400", width=4, radius="full"), |
| 1770 | Col( |
| 1771 | children=[ |
| 1772 | Text(value="Team standup", weight="medium"), |
| 1773 | Text( |
| 1774 | value="3:30 - 4:00 PM", |
| 1775 | size="xs", |
| 1776 | color="secondary", |
| 1777 | ), |
| 1778 | ], |
| 1779 | ), |
| 1780 | ], |
| 1781 | ), |
| 1782 | ], |
| 1783 | ), |
| 1784 | ], |
| 1785 | ), |
| 1786 | ], |
| 1787 | ) |
| 1788 | |
| 1789 | |
| 1790 | def render_calendar_event_view(id: str, state: CalendarEventView): |
| 1791 | back_button_row: list[WidgetComponent] = ( |
| 1792 | [ |
| 1793 | Row( |
| 1794 | margin={"x": -2, "top": -2, "bottom": -2}, |
| 1795 | children=[ |
| 1796 | Button( |
| 1797 | onClickAction=ViewScheduleAction.create( |
| 1798 | BasePayload(widget_id=id), |
| 1799 | loading_behavior="container", |
| 1800 | ), |
| 1801 | label="Back", |
| 1802 | color="secondary", |
| 1803 | variant="ghost", |
| 1804 | iconStart="chevron-left", |
| 1805 | size="xs", |
| 1806 | pill=True, |
| 1807 | ), |
| 1808 | ], |
| 1809 | ), |
| 1810 | Divider(flush=True), |
| 1811 | ] |
| 1812 | if state.show_back_button |
| 1813 | else [] |
| 1814 | ) |
| 1815 | |
| 1816 | return Card( |
| 1817 | key="calendar.view", |
| 1818 | size="lg", |
| 1819 | status=state.status(), |
| 1820 | children=[ |
| 1821 | Col( |
| 1822 | gap=4, |
| 1823 | children=back_button_row |
| 1824 | + [ |
| 1825 | Col( |
| 1826 | gap=1, |
| 1827 | children=[ |
| 1828 | Row( |
| 1829 | gap=2, |
| 1830 | children=[ |
| 1831 | Box( |
| 1832 | radius="full", |
| 1833 | background=state.event.calendar_color(), |
| 1834 | size=8, |
| 1835 | ), |
| 1836 | Text( |
| 1837 | value=state.event.calendar, |
| 1838 | size="sm", |
| 1839 | color="emphasis", |
| 1840 | weight="medium", |
| 1841 | ), |
| 1842 | ], |
| 1843 | ), |
| 1844 | Text(value=state.event.title, size="xl", weight="semibold"), |
| 1845 | Row( |
| 1846 | gap=2, |
| 1847 | children=[ |
| 1848 | Text(value=state.event.date, color="emphasis"), |
| 1849 | Text( |
| 1850 | value=state.event.time(), |
| 1851 | color="tertiary", |
| 1852 | ), |
| 1853 | ], |
| 1854 | ), |
| 1855 | ], |
| 1856 | ), |
| 1857 | Image( |
| 1858 | src=asset("map.png"), |
| 1859 | radius="sm", |
| 1860 | width="100%", |
| 1861 | height="230px", |
| 1862 | fit="cover", |
| 1863 | ), |
| 1864 | ], |
| 1865 | ), |
| 1866 | ], |
| 1867 | ) |
| 1868 | |
| 1869 | |
| 1870 | def render_calendar_events_list(id: str, state: CalendarEventsList): |
| 1871 | return ListView( |
| 1872 | key="calendar.list", |
| 1873 | status=state.status(), |
| 1874 | limit=5, |
| 1875 | children=[ |
| 1876 | back_button_list_item( |
| 1877 | ShowWidgetAction.create( |
| 1878 | ShowWidgetPayload(widget="calendar", widget_id=id), |
| 1879 | loading_behavior="container", |
| 1880 | ) |
| 1881 | ) |
| 1882 | ] |
| 1883 | + [ |
| 1884 | ListViewItem( |
| 1885 | onClickAction=ViewEventAction.create( |
| 1886 | ViewEventPayload(event_id=event.id, widget_id=id), |
| 1887 | loading_behavior="container", |
| 1888 | ), |
| 1889 | align="stretch", |
| 1890 | gap=4, |
| 1891 | children=[ |
| 1892 | Box(radius="full", background=event.calendar_color(), width=4), |
| 1893 | Col( |
| 1894 | children=[ |
| 1895 | Text( |
| 1896 | value=event.title, |
| 1897 | weight="medium", |
| 1898 | ), |
| 1899 | Text( |
| 1900 | value=event.time(), |
| 1901 | size="xs", |
| 1902 | color="secondary", |
| 1903 | ), |
| 1904 | ], |
| 1905 | ), |
| 1906 | ], |
| 1907 | ) |
| 1908 | for event in state.events |
| 1909 | ], |
| 1910 | ) |
| 1911 | |