@artooi/agents-web-components 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/LICENSE +21 -0
- package/README.md +4397 -2
- package/dist/agents-web-components.bundle.js +5708 -0
- package/dist/agents-web-components.bundle.js.map +7 -0
- package/dist/bundle.d.ts +3 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/chat.d.ts +3 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +16373 -0
- package/dist/chat.js.map +7 -0
- package/dist/chunk-ARNLFGCC.js +7715 -0
- package/dist/chunk-ARNLFGCC.js.map +7 -0
- package/dist/constants.d.ts +575 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/core/activity_registration.d.ts +19 -0
- package/dist/core/activity_registration.d.ts.map +1 -0
- package/dist/core/activity_registry.d.ts +46 -0
- package/dist/core/activity_registry.d.ts.map +1 -0
- package/dist/core/activity_renderer.d.ts +20 -0
- package/dist/core/activity_renderer.d.ts.map +1 -0
- package/dist/core/ag_ui_chat.d.ts +836 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -0
- package/dist/core/agui_client.d.ts +370 -0
- package/dist/core/agui_client.d.ts.map +1 -0
- package/dist/core/attachment.d.ts +42 -0
- package/dist/core/attachment.d.ts.map +1 -0
- package/dist/core/chat_registry.d.ts +23 -0
- package/dist/core/chat_registry.d.ts.map +1 -0
- package/dist/core/client_seed.d.ts +27 -0
- package/dist/core/client_seed.d.ts.map +1 -0
- package/dist/core/conversation_store.d.ts +195 -0
- package/dist/core/conversation_store.d.ts.map +1 -0
- package/dist/core/create_http_agent.d.ts +57 -0
- package/dist/core/create_http_agent.d.ts.map +1 -0
- package/dist/core/define_chat.d.ts +13 -0
- package/dist/core/define_chat.d.ts.map +1 -0
- package/dist/core/events/attachments_detail.d.ts +9 -0
- package/dist/core/events/attachments_detail.d.ts.map +1 -0
- package/dist/core/events/bar_height_detail.d.ts +6 -0
- package/dist/core/events/bar_height_detail.d.ts.map +1 -0
- package/dist/core/events/custom_agent_detail.d.ts +8 -0
- package/dist/core/events/custom_agent_detail.d.ts.map +1 -0
- package/dist/core/events/dock_width_detail.d.ts +8 -0
- package/dist/core/events/dock_width_detail.d.ts.map +1 -0
- package/dist/core/events/feedback_detail.d.ts +28 -0
- package/dist/core/events/feedback_detail.d.ts.map +1 -0
- package/dist/core/events/invalidate_detail.d.ts +16 -0
- package/dist/core/events/invalidate_detail.d.ts.map +1 -0
- package/dist/core/events/maximize_detail.d.ts +5 -0
- package/dist/core/events/maximize_detail.d.ts.map +1 -0
- package/dist/core/events/run_finished_detail.d.ts +36 -0
- package/dist/core/events/run_finished_detail.d.ts.map +1 -0
- package/dist/core/events/state_detail.d.ts +5 -0
- package/dist/core/events/state_detail.d.ts.map +1 -0
- package/dist/core/events/submit_detail.d.ts +8 -0
- package/dist/core/events/submit_detail.d.ts.map +1 -0
- package/dist/core/events/thread_detail.d.ts +27 -0
- package/dist/core/events/thread_detail.d.ts.map +1 -0
- package/dist/core/events/thread_title_detail.d.ts +8 -0
- package/dist/core/events/thread_title_detail.d.ts.map +1 -0
- package/dist/core/events/toggle_detail.d.ts +5 -0
- package/dist/core/events/toggle_detail.d.ts.map +1 -0
- package/dist/core/events/tool_run.d.ts +11 -0
- package/dist/core/events/tool_run.d.ts.map +1 -0
- package/dist/core/events/unread_detail.d.ts +11 -0
- package/dist/core/events/unread_detail.d.ts.map +1 -0
- package/dist/core/message_role.d.ts +4 -0
- package/dist/core/message_role.d.ts.map +1 -0
- package/dist/core/read_flag.d.ts +11 -0
- package/dist/core/read_flag.d.ts.map +1 -0
- package/dist/core/read_json_attribute.d.ts +15 -0
- package/dist/core/read_json_attribute.d.ts.map +1 -0
- package/dist/core/read_max_tool_rounds.d.ts +9 -0
- package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
- package/dist/core/remote_conversation_store.d.ts +71 -0
- package/dist/core/remote_conversation_store.d.ts.map +1 -0
- package/dist/core/run_handlers.d.ts +85 -0
- package/dist/core/run_handlers.d.ts.map +1 -0
- package/dist/core/run_index.d.ts +58 -0
- package/dist/core/run_index.d.ts.map +1 -0
- package/dist/core/storage_scope.d.ts +118 -0
- package/dist/core/storage_scope.d.ts.map +1 -0
- package/dist/core/tool_outcome.d.ts +27 -0
- package/dist/core/tool_outcome.d.ts.map +1 -0
- package/dist/core/transcribe_audio.d.ts +32 -0
- package/dist/core/transcribe_audio.d.ts.map +1 -0
- package/dist/core/upload_attachment.d.ts +44 -0
- package/dist/core/upload_attachment.d.ts.map +1 -0
- package/dist/core/utils.d.ts +117 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/dom/animations.d.ts +122 -0
- package/dist/dom/animations.d.ts.map +1 -0
- package/dist/dom/dom_driver.d.ts +28 -0
- package/dist/dom/dom_driver.d.ts.map +1 -0
- package/dist/dom/highlight_overlay.d.ts +39 -0
- package/dist/dom/highlight_overlay.d.ts.map +1 -0
- package/dist/dom/native_setter.d.ts +5 -0
- package/dist/dom/native_setter.d.ts.map +1 -0
- package/dist/dom/ring_color.d.ts +16 -0
- package/dist/dom/ring_color.d.ts.map +1 -0
- package/dist/dom/utils.d.ts +3 -0
- package/dist/dom/utils.d.ts.map +1 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +223 -0
- package/dist/index.js.map +7 -0
- package/dist/skills/fill_template.d.ts +16 -0
- package/dist/skills/fill_template.d.ts.map +1 -0
- package/dist/skills/parse_skills.d.ts +8 -0
- package/dist/skills/parse_skills.d.ts.map +1 -0
- package/dist/skills/skill.d.ts +33 -0
- package/dist/skills/skill.d.ts.map +1 -0
- package/dist/skills/skill_catalog.d.ts +69 -0
- package/dist/skills/skill_catalog.d.ts.map +1 -0
- package/dist/skills/skill_name_from.d.ts +12 -0
- package/dist/skills/skill_name_from.d.ts.map +1 -0
- package/dist/tools/chat_surface_tools.d.ts +77 -0
- package/dist/tools/chat_surface_tools.d.ts.map +1 -0
- package/dist/tools/client_tool_registry.d.ts +58 -0
- package/dist/tools/client_tool_registry.d.ts.map +1 -0
- package/dist/tools/is_destructive.d.ts +14 -0
- package/dist/tools/is_destructive.d.ts.map +1 -0
- package/dist/tools/is_navigates.d.ts +9 -0
- package/dist/tools/is_navigates.d.ts.map +1 -0
- package/dist/tools/page_action_tools.d.ts +30 -0
- package/dist/tools/page_action_tools.d.ts.map +1 -0
- package/dist/tools/page_map.d.ts +16 -0
- package/dist/tools/page_map.d.ts.map +1 -0
- package/dist/tools/page_state.d.ts +27 -0
- package/dist/tools/page_state.d.ts.map +1 -0
- package/dist/tools/parse_tool_catalog.d.ts +21 -0
- package/dist/tools/parse_tool_catalog.d.ts.map +1 -0
- package/dist/tools/route_map.d.ts +37 -0
- package/dist/tools/route_map.d.ts.map +1 -0
- package/dist/tools/tool_catalog.d.ts +86 -0
- package/dist/tools/tool_catalog.d.ts.map +1 -0
- package/dist/tools/tool_dispatch.d.ts +82 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -0
- package/dist/ui/charts/chart_block.d.ts +57 -0
- package/dist/ui/charts/chart_block.d.ts.map +1 -0
- package/dist/ui/charts/chart_spec_from.d.ts +12 -0
- package/dist/ui/charts/chart_spec_from.d.ts.map +1 -0
- package/dist/ui/charts/chart_styles.d.ts +2 -0
- package/dist/ui/charts/chart_styles.d.ts.map +1 -0
- package/dist/ui/charts/chart_tool.d.ts +15 -0
- package/dist/ui/charts/chart_tool.d.ts.map +1 -0
- package/dist/ui/composer/attachment_styles.d.ts +2 -0
- package/dist/ui/composer/attachment_styles.d.ts.map +1 -0
- package/dist/ui/composer/attachment_tray.d.ts +53 -0
- package/dist/ui/composer/attachment_tray.d.ts.map +1 -0
- package/dist/ui/composer/auto_grow.d.ts +10 -0
- package/dist/ui/composer/auto_grow.d.ts.map +1 -0
- package/dist/ui/composer/build_composer.d.ts +32 -0
- package/dist/ui/composer/build_composer.d.ts.map +1 -0
- package/dist/ui/composer/composer_attachments.d.ts +57 -0
- package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
- package/dist/ui/composer/composer_styles.d.ts +2 -0
- package/dist/ui/composer/composer_styles.d.ts.map +1 -0
- package/dist/ui/composer/composer_voice.d.ts +48 -0
- package/dist/ui/composer/composer_voice.d.ts.map +1 -0
- package/dist/ui/composer/skill_styles.d.ts +2 -0
- package/dist/ui/composer/skill_styles.d.ts.map +1 -0
- package/dist/ui/composer/skills_menu.d.ts +34 -0
- package/dist/ui/composer/skills_menu.d.ts.map +1 -0
- package/dist/ui/composer/voice_input.d.ts +46 -0
- package/dist/ui/composer/voice_input.d.ts.map +1 -0
- package/dist/ui/excerpts/attach_copy_buttons.d.ts +13 -0
- package/dist/ui/excerpts/attach_copy_buttons.d.ts.map +1 -0
- package/dist/ui/excerpts/copy_payload.d.ts +30 -0
- package/dist/ui/excerpts/copy_payload.d.ts.map +1 -0
- package/dist/ui/excerpts/page_quote_offer.d.ts +33 -0
- package/dist/ui/excerpts/page_quote_offer.d.ts.map +1 -0
- package/dist/ui/excerpts/place_offer.d.ts +9 -0
- package/dist/ui/excerpts/place_offer.d.ts.map +1 -0
- package/dist/ui/excerpts/quote_selection.d.ts +66 -0
- package/dist/ui/excerpts/quote_selection.d.ts.map +1 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
- package/dist/ui/fill_ui_string.d.ts +32 -0
- package/dist/ui/fill_ui_string.d.ts.map +1 -0
- package/dist/ui/history/checkpoint_menu.d.ts +41 -0
- package/dist/ui/history/checkpoint_menu.d.ts.map +1 -0
- package/dist/ui/history/conversation_history.d.ts +317 -0
- package/dist/ui/history/conversation_history.d.ts.map +1 -0
- package/dist/ui/history/history_styles.d.ts +2 -0
- package/dist/ui/history/history_styles.d.ts.map +1 -0
- package/dist/ui/history/relative_time.d.ts +23 -0
- package/dist/ui/history/relative_time.d.ts.map +1 -0
- package/dist/ui/history/thread_drawer.d.ts +64 -0
- package/dist/ui/history/thread_drawer.d.ts.map +1 -0
- package/dist/ui/history/utils.d.ts +2 -0
- package/dist/ui/history/utils.d.ts.map +1 -0
- package/dist/ui/interrupts/approval_card.d.ts +64 -0
- package/dist/ui/interrupts/approval_card.d.ts.map +1 -0
- package/dist/ui/interrupts/approval_styles.d.ts +2 -0
- package/dist/ui/interrupts/approval_styles.d.ts.map +1 -0
- package/dist/ui/interrupts/confirmation_card.d.ts +51 -0
- package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -0
- package/dist/ui/interrupts/confirmation_styles.d.ts +2 -0
- package/dist/ui/interrupts/confirmation_styles.d.ts.map +1 -0
- package/dist/ui/interrupts/pending_decision.d.ts +22 -0
- package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
- package/dist/ui/interrupts/question_card.d.ts +49 -0
- package/dist/ui/interrupts/question_card.d.ts.map +1 -0
- package/dist/ui/interrupts/question_styles.d.ts +2 -0
- package/dist/ui/interrupts/question_styles.d.ts.map +1 -0
- package/dist/ui/place/place.d.ts +17 -0
- package/dist/ui/place/place.d.ts.map +1 -0
- package/dist/ui/place/place_chip.d.ts +26 -0
- package/dist/ui/place/place_chip.d.ts.map +1 -0
- package/dist/ui/place/place_from_route.d.ts +9 -0
- package/dist/ui/place/place_from_route.d.ts.map +1 -0
- package/dist/ui/place/place_styles.d.ts +2 -0
- package/dist/ui/place/place_styles.d.ts.map +1 -0
- package/dist/ui/placement/clamp_launcher.d.ts +20 -0
- package/dist/ui/placement/clamp_launcher.d.ts.map +1 -0
- package/dist/ui/placement/clamp_panel.d.ts +13 -0
- package/dist/ui/placement/clamp_panel.d.ts.map +1 -0
- package/dist/ui/placement/collapse_styles.d.ts +2 -0
- package/dist/ui/placement/collapse_styles.d.ts.map +1 -0
- package/dist/ui/placement/dock_styles.d.ts +2 -0
- package/dist/ui/placement/dock_styles.d.ts.map +1 -0
- package/dist/ui/placement/embedded_styles.d.ts +2 -0
- package/dist/ui/placement/embedded_styles.d.ts.map +1 -0
- package/dist/ui/placement/launcher_drag.d.ts +49 -0
- package/dist/ui/placement/launcher_drag.d.ts.map +1 -0
- package/dist/ui/placement/launcher_placement.d.ts +77 -0
- package/dist/ui/placement/launcher_placement.d.ts.map +1 -0
- package/dist/ui/placement/launcher_styles.d.ts +2 -0
- package/dist/ui/placement/launcher_styles.d.ts.map +1 -0
- package/dist/ui/placement/page_styles.d.ts +2 -0
- package/dist/ui/placement/page_styles.d.ts.map +1 -0
- package/dist/ui/placement/panel_drag.d.ts +40 -0
- package/dist/ui/placement/panel_drag.d.ts.map +1 -0
- package/dist/ui/placement/panel_placement.d.ts +173 -0
- package/dist/ui/placement/panel_placement.d.ts.map +1 -0
- package/dist/ui/placement/place_widget.d.ts +31 -0
- package/dist/ui/placement/place_widget.d.ts.map +1 -0
- package/dist/ui/placement/preset_styles.d.ts +2 -0
- package/dist/ui/placement/preset_styles.d.ts.map +1 -0
- package/dist/ui/placement/resize_handle.d.ts +78 -0
- package/dist/ui/placement/resize_handle.d.ts.map +1 -0
- package/dist/ui/placement/resize_styles.d.ts +2 -0
- package/dist/ui/placement/resize_styles.d.ts.map +1 -0
- package/dist/ui/placement/sidebar_styles.d.ts +2 -0
- package/dist/ui/placement/sidebar_styles.d.ts.map +1 -0
- package/dist/ui/placement/small_viewport_styles.d.ts +2 -0
- package/dist/ui/placement/small_viewport_styles.d.ts.map +1 -0
- package/dist/ui/placement/surface_for.d.ts +93 -0
- package/dist/ui/placement/surface_for.d.ts.map +1 -0
- package/dist/ui/progress/prettify_tool_name.d.ts +11 -0
- package/dist/ui/progress/prettify_tool_name.d.ts.map +1 -0
- package/dist/ui/progress/run_announcer.d.ts +41 -0
- package/dist/ui/progress/run_announcer.d.ts.map +1 -0
- package/dist/ui/progress/run_notice.d.ts +21 -0
- package/dist/ui/progress/run_notice.d.ts.map +1 -0
- package/dist/ui/progress/run_notice_styles.d.ts +2 -0
- package/dist/ui/progress/run_notice_styles.d.ts.map +1 -0
- package/dist/ui/progress/subagent_panel.d.ts +101 -0
- package/dist/ui/progress/subagent_panel.d.ts.map +1 -0
- package/dist/ui/progress/subagent_progress.d.ts +57 -0
- package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
- package/dist/ui/progress/subagent_update.d.ts +28 -0
- package/dist/ui/progress/subagent_update.d.ts.map +1 -0
- package/dist/ui/progress/thoughts_block.d.ts +57 -0
- package/dist/ui/progress/thoughts_block.d.ts.map +1 -0
- package/dist/ui/progress/thoughts_styles.d.ts +2 -0
- package/dist/ui/progress/thoughts_styles.d.ts.map +1 -0
- package/dist/ui/progress/tool_call_card.d.ts +167 -0
- package/dist/ui/progress/tool_call_card.d.ts.map +1 -0
- package/dist/ui/progress/tool_group_styles.d.ts +2 -0
- package/dist/ui/progress/tool_group_styles.d.ts.map +1 -0
- package/dist/ui/progress/tool_step_group.d.ts +50 -0
- package/dist/ui/progress/tool_step_group.d.ts.map +1 -0
- package/dist/ui/progress/tool_step_styles.d.ts +2 -0
- package/dist/ui/progress/tool_step_styles.d.ts.map +1 -0
- package/dist/ui/shell/adopt_styles.d.ts +28 -0
- package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
- package/dist/ui/shell/build_header.d.ts +45 -0
- package/dist/ui/shell/build_header.d.ts.map +1 -0
- package/dist/ui/shell/build_launcher.d.ts +33 -0
- package/dist/ui/shell/build_launcher.d.ts.map +1 -0
- package/dist/ui/shell/collapse_glyph.d.ts +14 -0
- package/dist/ui/shell/collapse_glyph.d.ts.map +1 -0
- package/dist/ui/shell/frame_styles.d.ts +2 -0
- package/dist/ui/shell/frame_styles.d.ts.map +1 -0
- package/dist/ui/shell/glyph_slot.d.ts +7 -0
- package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
- package/dist/ui/shell/header_button.d.ts +11 -0
- package/dist/ui/shell/header_button.d.ts.map +1 -0
- package/dist/ui/shell/icon_element.d.ts +12 -0
- package/dist/ui/shell/icon_element.d.ts.map +1 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts.map +1 -0
- package/dist/ui/shell/theme_styles.d.ts +2 -0
- package/dist/ui/shell/theme_styles.d.ts.map +1 -0
- package/dist/ui/shell/token_styles.d.ts +2 -0
- package/dist/ui/shell/token_styles.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +2 -0
- package/dist/ui/styles.d.ts.map +1 -0
- package/dist/ui/themes/classic_styles.d.ts +2 -0
- package/dist/ui/themes/classic_styles.d.ts.map +1 -0
- package/dist/ui/themes/classic_tokens.d.ts +2 -0
- package/dist/ui/themes/classic_tokens.d.ts.map +1 -0
- package/dist/ui/themes/code_tokens.d.ts +2 -0
- package/dist/ui/themes/code_tokens.d.ts.map +1 -0
- package/dist/ui/themes/ledger_styles.d.ts +2 -0
- package/dist/ui/themes/ledger_styles.d.ts.map +1 -0
- package/dist/ui/themes/ledger_tokens.d.ts +2 -0
- package/dist/ui/themes/ledger_tokens.d.ts.map +1 -0
- package/dist/ui/themes/modern_styles.d.ts +2 -0
- package/dist/ui/themes/modern_styles.d.ts.map +1 -0
- package/dist/ui/themes/quiet_styles.d.ts +2 -0
- package/dist/ui/themes/quiet_styles.d.ts.map +1 -0
- package/dist/ui/themes/scheme_styles.d.ts +2 -0
- package/dist/ui/themes/scheme_styles.d.ts.map +1 -0
- package/dist/ui/themes/soft_styles.d.ts +2 -0
- package/dist/ui/themes/soft_styles.d.ts.map +1 -0
- package/dist/ui/themes/soft_tokens.d.ts +2 -0
- package/dist/ui/themes/soft_tokens.d.ts.map +1 -0
- package/dist/ui/themes/theme_hosts.d.ts +15 -0
- package/dist/ui/themes/theme_hosts.d.ts.map +1 -0
- package/dist/ui/transcript/answer_actions.d.ts +50 -0
- package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
- package/dist/ui/transcript/answer_stream.d.ts +56 -0
- package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
- package/dist/ui/transcript/attachment_chip_styles.d.ts +2 -0
- package/dist/ui/transcript/attachment_chip_styles.d.ts.map +1 -0
- package/dist/ui/transcript/attachment_chips.d.ts +21 -0
- package/dist/ui/transcript/attachment_chips.d.ts.map +1 -0
- package/dist/ui/transcript/build_transcript.d.ts +21 -0
- package/dist/ui/transcript/build_transcript.d.ts.map +1 -0
- package/dist/ui/transcript/greeting_styles.d.ts +2 -0
- package/dist/ui/transcript/greeting_styles.d.ts.map +1 -0
- package/dist/ui/transcript/groups_tool_steps.d.ts +13 -0
- package/dist/ui/transcript/groups_tool_steps.d.ts.map +1 -0
- package/dist/ui/transcript/message_action_styles.d.ts +2 -0
- package/dist/ui/transcript/message_action_styles.d.ts.map +1 -0
- package/dist/ui/transcript/message_actions.d.ts +74 -0
- package/dist/ui/transcript/message_actions.d.ts.map +1 -0
- package/dist/ui/transcript/message_styles.d.ts +2 -0
- package/dist/ui/transcript/message_styles.d.ts.map +1 -0
- package/dist/ui/transcript/render_markdown.d.ts +40 -0
- package/dist/ui/transcript/render_markdown.d.ts.map +1 -0
- package/dist/ui/transcript/render_or_warn.d.ts +16 -0
- package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
- package/dist/ui/transcript/reveal_words.d.ts +8 -0
- package/dist/ui/transcript/reveal_words.d.ts.map +1 -0
- package/dist/ui/transcript/starter_chips.d.ts +21 -0
- package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
- package/dist/ui/transcript/stick_to_bottom.d.ts +55 -0
- package/dist/ui/transcript/stick_to_bottom.d.ts.map +1 -0
- package/dist/ui/transcript/suggestion_chips.d.ts +29 -0
- package/dist/ui/transcript/suggestion_chips.d.ts.map +1 -0
- package/dist/ui/transcript/transcript.d.ts +196 -0
- package/dist/ui/transcript/transcript.d.ts.map +1 -0
- package/dist/ui/transcript/transcript_styles.d.ts +2 -0
- package/dist/ui/transcript/transcript_styles.d.ts.map +1 -0
- package/dist/ui/ui_strings.d.ts +352 -0
- package/dist/ui/ui_strings.d.ts.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +86 -3
- package/src/bundle.ts +2 -0
- package/src/chat.ts +2 -0
- package/src/constants.ts +650 -0
- package/src/core/activity_registration.ts +19 -0
- package/src/core/activity_registry.ts +133 -0
- package/src/core/activity_renderer.ts +19 -0
- package/src/core/ag_ui_chat.ts +3966 -0
- package/src/core/agui_client.ts +1028 -0
- package/src/core/attachment.ts +62 -0
- package/src/core/chat_registry.ts +53 -0
- package/src/core/client_seed.ts +27 -0
- package/src/core/conversation_store.ts +478 -0
- package/src/core/create_http_agent.ts +97 -0
- package/src/core/define_chat.ts +25 -0
- package/src/core/events/attachments_detail.ts +9 -0
- package/src/core/events/bar_height_detail.ts +5 -0
- package/src/core/events/custom_agent_detail.ts +7 -0
- package/src/core/events/dock_width_detail.ts +7 -0
- package/src/core/events/feedback_detail.ts +27 -0
- package/src/core/events/invalidate_detail.ts +15 -0
- package/src/core/events/maximize_detail.ts +4 -0
- package/src/core/events/run_finished_detail.ts +36 -0
- package/src/core/events/state_detail.ts +4 -0
- package/src/core/events/submit_detail.ts +8 -0
- package/src/core/events/thread_detail.ts +26 -0
- package/src/core/events/thread_title_detail.ts +7 -0
- package/src/core/events/toggle_detail.ts +4 -0
- package/src/core/events/tool_run.ts +10 -0
- package/src/core/events/unread_detail.ts +10 -0
- package/src/core/message_role.ts +4 -0
- package/src/core/read_flag.ts +13 -0
- package/src/core/read_json_attribute.ts +30 -0
- package/src/core/read_max_tool_rounds.ts +13 -0
- package/src/core/remote_conversation_store.ts +279 -0
- package/src/core/run_handlers.ts +524 -0
- package/src/core/run_index.ts +114 -0
- package/src/core/storage_scope.ts +291 -0
- package/src/core/tool_outcome.ts +39 -0
- package/src/core/transcribe_audio.ts +76 -0
- package/src/core/upload_attachment.ts +130 -0
- package/src/core/utils.ts +230 -0
- package/src/dom/animations.ts +341 -0
- package/src/dom/dom_driver.ts +91 -0
- package/src/dom/highlight_overlay.ts +204 -0
- package/src/dom/native_setter.ts +40 -0
- package/src/dom/ring_color.ts +206 -0
- package/src/dom/utils.ts +5 -0
- package/src/index.ts +220 -0
- package/src/skills/fill_template.ts +31 -0
- package/src/skills/parse_skills.ts +31 -0
- package/src/skills/skill.ts +32 -0
- package/src/skills/skill_catalog.ts +172 -0
- package/src/skills/skill_name_from.ts +19 -0
- package/src/tools/chat_surface_tools.ts +183 -0
- package/src/tools/client_tool_registry.ts +80 -0
- package/src/tools/is_destructive.ts +17 -0
- package/src/tools/is_navigates.ts +12 -0
- package/src/tools/page_action_tools.ts +123 -0
- package/src/tools/page_map.ts +26 -0
- package/src/tools/page_state.ts +57 -0
- package/src/tools/parse_tool_catalog.ts +41 -0
- package/src/tools/route_map.ts +132 -0
- package/src/tools/tool_catalog.ts +334 -0
- package/src/tools/tool_dispatch.ts +453 -0
- package/src/ui/charts/chart_block.ts +519 -0
- package/src/ui/charts/chart_spec_from.ts +125 -0
- package/src/ui/charts/chart_styles.ts +63 -0
- package/src/ui/charts/chart_tool.ts +72 -0
- package/src/ui/composer/attachment_styles.ts +71 -0
- package/src/ui/composer/attachment_tray.ts +295 -0
- package/src/ui/composer/auto_grow.ts +12 -0
- package/src/ui/composer/build_composer.ts +114 -0
- package/src/ui/composer/composer_attachments.ts +324 -0
- package/src/ui/composer/composer_styles.ts +177 -0
- package/src/ui/composer/composer_voice.ts +93 -0
- package/src/ui/composer/skill_styles.ts +120 -0
- package/src/ui/composer/skills_menu.ts +188 -0
- package/src/ui/composer/voice_input.ts +282 -0
- package/src/ui/excerpts/attach_copy_buttons.ts +73 -0
- package/src/ui/excerpts/copy_payload.ts +156 -0
- package/src/ui/excerpts/page_quote_offer.ts +203 -0
- package/src/ui/excerpts/place_offer.ts +24 -0
- package/src/ui/excerpts/quote_selection.ts +345 -0
- package/src/ui/excerpts/transcript_quote_offer.ts +175 -0
- package/src/ui/fill_ui_string.ts +41 -0
- package/src/ui/history/checkpoint_menu.ts +300 -0
- package/src/ui/history/conversation_history.ts +1169 -0
- package/src/ui/history/history_styles.ts +492 -0
- package/src/ui/history/relative_time.ts +50 -0
- package/src/ui/history/thread_drawer.ts +471 -0
- package/src/ui/history/utils.ts +11 -0
- package/src/ui/interrupts/approval_card.ts +202 -0
- package/src/ui/interrupts/approval_styles.ts +52 -0
- package/src/ui/interrupts/confirmation_card.ts +138 -0
- package/src/ui/interrupts/confirmation_styles.ts +114 -0
- package/src/ui/interrupts/pending_decision.ts +33 -0
- package/src/ui/interrupts/question_card.ts +213 -0
- package/src/ui/interrupts/question_styles.ts +73 -0
- package/src/ui/place/place.ts +16 -0
- package/src/ui/place/place_chip.ts +146 -0
- package/src/ui/place/place_from_route.ts +27 -0
- package/src/ui/place/place_styles.ts +108 -0
- package/src/ui/placement/clamp_launcher.ts +38 -0
- package/src/ui/placement/clamp_panel.ts +30 -0
- package/src/ui/placement/collapse_styles.ts +472 -0
- package/src/ui/placement/dock_styles.ts +116 -0
- package/src/ui/placement/embedded_styles.ts +14 -0
- package/src/ui/placement/launcher_drag.ts +206 -0
- package/src/ui/placement/launcher_placement.ts +133 -0
- package/src/ui/placement/launcher_styles.ts +189 -0
- package/src/ui/placement/page_styles.ts +47 -0
- package/src/ui/placement/panel_drag.ts +138 -0
- package/src/ui/placement/panel_placement.ts +1975 -0
- package/src/ui/placement/place_widget.ts +64 -0
- package/src/ui/placement/preset_styles.ts +49 -0
- package/src/ui/placement/resize_handle.ts +229 -0
- package/src/ui/placement/resize_styles.ts +201 -0
- package/src/ui/placement/sidebar_styles.ts +113 -0
- package/src/ui/placement/small_viewport_styles.ts +70 -0
- package/src/ui/placement/surface_for.ts +183 -0
- package/src/ui/progress/prettify_tool_name.ts +16 -0
- package/src/ui/progress/run_announcer.ts +67 -0
- package/src/ui/progress/run_notice.ts +61 -0
- package/src/ui/progress/run_notice_styles.ts +52 -0
- package/src/ui/progress/subagent_panel.ts +227 -0
- package/src/ui/progress/subagent_progress.ts +200 -0
- package/src/ui/progress/subagent_update.ts +89 -0
- package/src/ui/progress/thoughts_block.ts +165 -0
- package/src/ui/progress/thoughts_styles.ts +74 -0
- package/src/ui/progress/tool_call_card.ts +429 -0
- package/src/ui/progress/tool_group_styles.ts +313 -0
- package/src/ui/progress/tool_step_group.ts +317 -0
- package/src/ui/progress/tool_step_styles.ts +486 -0
- package/src/ui/shell/adopt_styles.ts +34 -0
- package/src/ui/shell/build_header.ts +121 -0
- package/src/ui/shell/build_launcher.ts +79 -0
- package/src/ui/shell/collapse_glyph.ts +42 -0
- package/src/ui/shell/frame_styles.ts +147 -0
- package/src/ui/shell/glyph_slot.ts +12 -0
- package/src/ui/shell/header_button.ts +22 -0
- package/src/ui/shell/icon_element.ts +33 -0
- package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
- package/src/ui/shell/read_launcher_icon_url.ts +9 -0
- package/src/ui/shell/theme_styles.ts +11 -0
- package/src/ui/shell/token_styles.ts +480 -0
- package/src/ui/styles.ts +110 -0
- package/src/ui/themes/classic_styles.ts +32 -0
- package/src/ui/themes/classic_tokens.ts +51 -0
- package/src/ui/themes/code_tokens.ts +21 -0
- package/src/ui/themes/ledger_styles.ts +169 -0
- package/src/ui/themes/ledger_tokens.ts +33 -0
- package/src/ui/themes/modern_styles.ts +80 -0
- package/src/ui/themes/quiet_styles.ts +118 -0
- package/src/ui/themes/scheme_styles.ts +26 -0
- package/src/ui/themes/soft_styles.ts +149 -0
- package/src/ui/themes/soft_tokens.ts +29 -0
- package/src/ui/themes/theme_hosts.ts +14 -0
- package/src/ui/transcript/answer_actions.ts +165 -0
- package/src/ui/transcript/answer_stream.ts +123 -0
- package/src/ui/transcript/attachment_chip_styles.ts +116 -0
- package/src/ui/transcript/attachment_chips.ts +82 -0
- package/src/ui/transcript/build_transcript.ts +67 -0
- package/src/ui/transcript/greeting_styles.ts +171 -0
- package/src/ui/transcript/groups_tool_steps.ts +16 -0
- package/src/ui/transcript/message_action_styles.ts +155 -0
- package/src/ui/transcript/message_actions.ts +234 -0
- package/src/ui/transcript/message_styles.ts +262 -0
- package/src/ui/transcript/render_markdown.ts +181 -0
- package/src/ui/transcript/render_or_warn.ts +22 -0
- package/src/ui/transcript/reveal_words.ts +41 -0
- package/src/ui/transcript/starter_chips.ts +42 -0
- package/src/ui/transcript/stick_to_bottom.ts +126 -0
- package/src/ui/transcript/suggestion_chips.ts +73 -0
- package/src/ui/transcript/transcript.ts +555 -0
- package/src/ui/transcript/transcript_styles.ts +153 -0
- package/src/ui/ui_strings.ts +550 -0
- package/src/version.ts +1 -0
package/README.md
CHANGED
|
@@ -1,3 +1,4398 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @artooi/agents-web-components
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/Artui/agents-web-components/actions/workflows/tests.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@artooi/agents-web-components)
|
|
5
|
+
[](https://github.com/Artui/agents-web-components/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
A **framework-free** `<ag-ui-chat>` Web Component over the [AG-UI](https://docs.ag-ui.com)
|
|
8
|
+
protocol. Drop it into any page — SPA or MPA, any framework or none — point it at an AG-UI
|
|
9
|
+
endpoint, and you get a streaming chat sidebar that can call tools you register in the browser.
|
|
10
|
+
|
|
11
|
+
It wraps [`@ag-ui/client`](https://www.npmjs.com/package/@ag-ui/client)'s `HttpAgent` and ships:
|
|
12
|
+
|
|
13
|
+
- A Custom Element with a self-contained Shadow DOM chat UI (header, scrolling transcript,
|
|
14
|
+
input row), themeable via CSS custom properties.
|
|
15
|
+
- A pluggable **client-side tool registry** — `registerTool({ name, description, parameters,
|
|
16
|
+
handler })`; every registered tool is added to each run's `RunAgentInput.tools`.
|
|
17
|
+
- Generic **DOM-driver primitives** (`fillField`, `clickElement`, `setControlValue`) and
|
|
18
|
+
**animation primitives** (`typeInto`, `highlightThenClick`, …) so the agent can drive the page
|
|
19
|
+
at human-readable speed.
|
|
20
|
+
- An **inline confirmation card** that intercepts tool calls needing confirmation (those whose
|
|
21
|
+
JSON Schema carries `x-destructive: true`, or a per-call `confirmPredicate`) before the handler
|
|
22
|
+
runs — rendered right in the transcript, never a modal overlay.
|
|
23
|
+
- **Markdown + HTML rendering** of assistant replies (sanitized `marked` + DOMPurify), with
|
|
24
|
+
themes, density/placement presets, incoming-text animations, tool-call display modes, an
|
|
25
|
+
animated thinking indicator, and an opt-in **skills** palette (prompt chips + `/`-commands).
|
|
26
|
+
- A **new-chat** button and a **collapse** toggle built into the header.
|
|
27
|
+
- An **MPA durability story**: a durable conversation store, a stable thread id, and a resumable
|
|
28
|
+
run loop that survives full page reloads (`x-navigates` + `navigationResult`).
|
|
29
|
+
- **Host seams** for SPAs: a route map, an auto-injected page map, page state, and an optional
|
|
30
|
+
`navigate()` callback.
|
|
31
|
+
|
|
32
|
+
No framework, no Django, no admin specifics live here. Downstream consumers (e.g.
|
|
33
|
+
`django-admin-agent`) register their own tool handlers on top via the pluggable registry.
|
|
34
|
+
|
|
35
|
+
This package is the successor of `@artooi/ag-ui-web-component`. Coming from it? See
|
|
36
|
+
[Moving from `@artooi/ag-ui-web-component`](#moving-from-artooiag-ui-web-component).
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Table of contents
|
|
41
|
+
|
|
42
|
+
- [Install](#install)
|
|
43
|
+
- [Quickstart](#quickstart)
|
|
44
|
+
- [Authenticating requests](#authenticating-requests)
|
|
45
|
+
- [Core concepts](#core-concepts)
|
|
46
|
+
- [The run loop and the AG-UI client](#the-run-loop-and-the-ag-ui-client)
|
|
47
|
+
- [Stopping a run](#stopping-a-run)
|
|
48
|
+
- [Registering tools](#registering-tools)
|
|
49
|
+
- [Inline confirmation (`x-destructive` / `x-confirm` / `confirmPredicate`)](#inline-confirmation-x-destructive--x-confirm--confirmpredicate)
|
|
50
|
+
- [DOM-driver and animation primitives](#dom-driver-and-animation-primitives)
|
|
51
|
+
- [Page-action tools](#page-action-tools)
|
|
52
|
+
- [New chat and collapse](#new-chat-and-collapse)
|
|
53
|
+
- [Maximizing the panel](#maximizing-the-panel)
|
|
54
|
+
- [Collapsing to the launcher](#collapsing-to-the-launcher)
|
|
55
|
+
- [Collapsing to a bottom bar](#collapsing-to-a-bottom-bar)
|
|
56
|
+
- [Tool-call display modes](#tool-call-display-modes)
|
|
57
|
+
- [Delegated sub-agents](#delegated-sub-agents)
|
|
58
|
+
- [Markdown rendering](#markdown-rendering)
|
|
59
|
+
- [Follow-up suggestions](#follow-up-suggestions)
|
|
60
|
+
- [Editing a gated call before approving it](#editing-a-gated-call-before-approving-it)
|
|
61
|
+
- [Localizing the timestamps](#localizing-the-timestamps)
|
|
62
|
+
- [Message actions: copy, retry, feedback](#message-actions-copy-retry-feedback)
|
|
63
|
+
- [Quoting a selection](#quoting-a-selection)
|
|
64
|
+
- [Run notices: compaction and agent skills](#run-notices-compaction-and-agent-skills)
|
|
65
|
+
- [Skills: prompt chips and slash palette](#skills-prompt-chips-and-slash-palette)
|
|
66
|
+
- [MPA durability: surviving full page reloads](#mpa-durability-surviving-full-page-reloads)
|
|
67
|
+
- [Who the stored conversation belongs to (`user-key`)](#who-the-stored-conversation-belongs-to-user-key)
|
|
68
|
+
- [Mounting more than one chat on a page](#mounting-more-than-one-chat-on-a-page)
|
|
69
|
+
- [Host seams: the SPA story](#host-seams-the-spa-story)
|
|
70
|
+
- [Where the user is: the place chip](#where-the-user-is-the-place-chip)
|
|
71
|
+
- [Public API surface](#public-api-surface)
|
|
72
|
+
- [Theming, density, and placement](#theming-density-and-placement)
|
|
73
|
+
- [Themes](#themes)
|
|
74
|
+
- [Internationalization (i18n)](#internationalization-i18n)
|
|
75
|
+
- [Building the bundle](#building-the-bundle)
|
|
76
|
+
- [Moving from `@artooi/ag-ui-web-component`](#moving-from-artooiag-ui-web-component)
|
|
77
|
+
- [Compatibility](#compatibility)
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm install @artooi/agents-web-components
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The package ships two builds (see [`package.json` exports](package.json)):
|
|
88
|
+
|
|
89
|
+
| Entry | What it is | When to use |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `@artooi/agents-web-components` | ESM library build; `@ag-ui/*` stay **external** | You bundle the app yourself (Vite, webpack, esbuild) and want to dedupe `@ag-ui/*`. |
|
|
92
|
+
| `@artooi/agents-web-components/chat` | The same library build's `<ag-ui-chat>` entry: `AgUiChat` and `defineChat` | Beside the root entry; the two share one module instance, so import from both freely. |
|
|
93
|
+
| `@artooi/agents-web-components/bundle` | ESM bundle with `@ag-ui/*` **inlined**, minified | Drop in via a single `<script type="module">` with no build step. |
|
|
94
|
+
|
|
95
|
+
### The vendored-bundle story
|
|
96
|
+
|
|
97
|
+
The `/bundle` entry inlines every dependency into one self-contained ESM file
|
|
98
|
+
(`dist/agents-web-components.bundle.js`). This is the artefact intended for **vendoring**: a host
|
|
99
|
+
that can't (or won't) run a JS build — for example a Django app — copies the built bundle into its
|
|
100
|
+
`static/` directory and serves it directly. `django-admin-agent` re-vendors a pinned built bundle
|
|
101
|
+
on every release. For SPA hosts that already have a bundler, import the bare package name instead
|
|
102
|
+
so `@ag-ui/client` / `@ag-ui/core` are deduped against the rest of your app.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Quickstart
|
|
107
|
+
|
|
108
|
+
Drop the element into your page and register the tools the agent may call:
|
|
109
|
+
|
|
110
|
+
```html
|
|
111
|
+
<script type="module">
|
|
112
|
+
import { fillField, clickElement, X_DESTRUCTIVE_KEY } from "@artooi/agents-web-components";
|
|
113
|
+
import { defineChat } from "@artooi/agents-web-components/chat";
|
|
114
|
+
|
|
115
|
+
// Register the <ag-ui-chat> Custom Element. An explicit call, not an import
|
|
116
|
+
// side effect, so importing the package defines nothing. Idempotent; it throws
|
|
117
|
+
// if another class already holds the tag (a second copy of the element).
|
|
118
|
+
defineChat();
|
|
119
|
+
|
|
120
|
+
const chat = document.querySelector("ag-ui-chat");
|
|
121
|
+
|
|
122
|
+
// Extra request headers (e.g. CSRF), sent with every request the element makes.
|
|
123
|
+
// For a credential that rotates, set chat.getHeaders instead - it is consulted
|
|
124
|
+
// per request. See "Authenticating requests".
|
|
125
|
+
chat.headers = { "X-CSRFToken": getCsrfToken() };
|
|
126
|
+
|
|
127
|
+
// A non-destructive tool: fills a text field with a typing animation.
|
|
128
|
+
chat.registerTool({
|
|
129
|
+
name: "fill_field",
|
|
130
|
+
description: "Fill a text input by id with a value.",
|
|
131
|
+
parameters: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: { field: { type: "string" }, value: { type: "string" } },
|
|
134
|
+
required: ["field", "value"],
|
|
135
|
+
},
|
|
136
|
+
handler: async ({ field, value }) => {
|
|
137
|
+
await fillField(document.getElementById(field), String(value));
|
|
138
|
+
return "ok";
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// A destructive tool: x-destructive at the JSON-Schema root gates it behind
|
|
143
|
+
// the inline confirmation card before the handler runs.
|
|
144
|
+
chat.registerTool({
|
|
145
|
+
name: "save_article",
|
|
146
|
+
description: "Save the article. Destructive — asks for confirmation.",
|
|
147
|
+
parameters: { type: "object", properties: {}, [X_DESTRUCTIVE_KEY]: true },
|
|
148
|
+
handler: async () => {
|
|
149
|
+
await clickElement(document.getElementById("save"));
|
|
150
|
+
return "saved";
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
<ag-ui-chat endpoint="/agent/" title-text="Assistant"></ag-ui-chat>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
> [!IMPORTANT]
|
|
159
|
+
> **If nothing appears, check that `defineChat()` ran.** Forgetting it looks
|
|
160
|
+
> like nothing at all. `<ag-ui-chat>` is then an *unknown element*, which every
|
|
161
|
+
> browser renders as an inline 0x0 box, in flow, with no console warning and no
|
|
162
|
+
> error — so the page looks finished and the chat is simply absent. There is
|
|
163
|
+
> nothing to search for.
|
|
164
|
+
>
|
|
165
|
+
> A bare `import "@artooi/agents-web-components"` registers nothing, deliberately:
|
|
166
|
+
> registration is an explicit call so the package stays tree-shakeable.
|
|
167
|
+
>
|
|
168
|
+
> `customElements.get("ag-ui-chat")` in a console answers it in one line.
|
|
169
|
+
> `undefined` means the call did not run.
|
|
170
|
+
|
|
171
|
+
That's the whole integration: an `endpoint` attribute pointing at your AG-UI server, optional
|
|
172
|
+
`headers`, and the tools you want the agent to be able to invoke in the browser. If your API is on
|
|
173
|
+
another origin, add `credentials="include"` too; see
|
|
174
|
+
[Authenticating requests](#authenticating-requests).
|
|
175
|
+
|
|
176
|
+
### Attributes and properties
|
|
177
|
+
|
|
178
|
+
**Attributes** (set in HTML; the CSS-only ones are styling presets with no JS API):
|
|
179
|
+
|
|
180
|
+
| Attribute | Property | Notes |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| `endpoint` | `endpoint` | The AG-UI endpoint URL. Required to send: without it a send is refused with a `console.error` naming the missing attribute and a `not-connected` run notice in the transcript. Reflecting getter + setter. |
|
|
183
|
+
| `credentials` | `credentials` | Cookie policy for every request the element makes: `omit` / `same-origin` / `include`. Unset means the browser default (`same-origin`), which sends no cookies cross-origin. See [Authenticating requests](#authenticating-requests). |
|
|
184
|
+
| `title-text` | — | Header label; defaults to `"Assistant"`. `"auto"` makes the header, the rail and the bar label follow the thread on screen (`threadTitle`), falling back to the strings table's `title` while it has none. Live: writing it after the element connects re-labels the header. See [When each attribute is read](#when-each-attribute-is-read). |
|
|
185
|
+
| `data-tool-display` | `toolDisplay` | How tool calls are drawn: `grouped` (the default: a run's consecutive calls fold into one row) / `inline` / `minimal` / `compact` / `full` (per-call cards; `full` was the default before `grouped`). An unknown value reads as `grouped`. See [Tool-call display modes](#tool-call-display-modes). |
|
|
186
|
+
| `data-tool-group-display` | `toolGroupDisplay` | How an opened step group draws its calls under `grouped`: `full` (the default) / `compact` / `minimal` / `inline`. Ignored under the four card modes. An absent or unknown value reads as `full`. See [Choosing how a group draws its calls](#choosing-how-a-group-draws-its-calls). |
|
|
187
|
+
| `data-text-animation` | — | Incoming-text reveal: `none` (default) / `fade` / `word`. |
|
|
188
|
+
| `data-prompt-chips` | — | Present (bare, or any value but `"false"`) to surface skills as chips. |
|
|
189
|
+
| `data-slash-commands` | — | Present (bare, or any value but `"false"`) to enable the `/`-command palette. |
|
|
190
|
+
| `data-skills` | — | Inline JSON skill catalog. A value that will not parse is ignored and reported on the console, rather than reading as an empty catalog. |
|
|
191
|
+
| `data-skills-url` | — | URL of a JSON skill catalog (fetched with the element's headers and cookie policy). |
|
|
192
|
+
| `data-tools-url` | — | URL of a server tool-label catalog (`[{ name, summary, description? }]`), fetched with the element's headers and cookie policy; labels tool-call cards for server-side tools. |
|
|
193
|
+
| `user-key` | `userKey` | Who the stored conversation belongs to — any string identifying the signed-in principal. Joins the storage namespace, and **changing it purges what the previous principal left behind**. Live (not connect-time): a logout is the host's to announce. See [Who the stored conversation belongs to](#who-the-stored-conversation-belongs-to-user-key). |
|
|
194
|
+
| `user-name` | `userName` | The signed-in user's display name, for the [greeting](#the-greeting-on-an-empty-page) an empty conversation shows under `placement="page"`. Presentation only: unlike `user-key` it scopes nothing and is never sent. Absent or blank gives the nameless greeting. Live. |
|
|
195
|
+
| `data-threads-url` | — | URL of a server thread index (django-ag-ui's `ThreadsView`); enables durable, cross-device chat history. |
|
|
196
|
+
| `data-threads-cache` | — | **On by default.** `="false"` stops mirroring message bodies into `sessionStorage` when `data-threads-url` is set, for a deployment that put history on the server so transcripts stay off the client. Only meaningful alongside `data-threads-url`. |
|
|
197
|
+
| `data-runs-url` | — | URL of a server run index (django-ag-ui's `RunsView`); reveals the header's *Continue a run* panel. See [Resuming a run](#resuming-a-run). |
|
|
198
|
+
| `data-attachments-url` | — | URL of the file-upload endpoint (django-ag-ui's `AttachmentsView`); reveals the composer's paperclip picker, drag-and-drop, and paste. |
|
|
199
|
+
| `data-attachment-accept` | — | `<input accept>` list for client-side type filtering (e.g. `image/*,.pdf`). The server stays authoritative. |
|
|
200
|
+
| `data-attachment-max-bytes` | — | Client-side upload size cap in bytes (default 10 MiB; `0` disables). The server stays authoritative. |
|
|
201
|
+
| `data-transcribe-url` | — | URL of the voice-transcription endpoint (django-ag-ui's `TranscribeView`); reveals the composer's mic button. See [Voice input](#voice-input). |
|
|
202
|
+
| `data-theme-toggle` | — | Boolean: show a built-in header light⇄dark toggle (persists per tab). Off by default. See [Theme toggle](#theme-toggle). |
|
|
203
|
+
| `data-strings` | `strings` | Partial JSON override of the UI string table (localization). The property wins key-by-key over the attribute; a value that will not parse is ignored and reported on the console. See [Internationalization](#internationalization-i18n). |
|
|
204
|
+
| `data-icon-url` | — | Header (and launcher) icon image URL. A slotted `slot="icon"` wins; see [Header & launcher icon](#header-and-launcher-icon). |
|
|
205
|
+
| `data-launcher-icon-url` | — | Icon image URL for the collapsed launcher only, when it should differ from the header's. Falls back to `data-icon-url`; a slotted `slot="launcher"` wins over both. |
|
|
206
|
+
| `data-unread-badge` | — | **On by default.** `="false"` hides the launcher's unread badge; the count and the `ag-ui-unread` event keep running. See [Collapsing to the launcher](#collapsing-to-the-launcher). |
|
|
207
|
+
| `data-launcher-drag` | — | **On by default.** `="false"` leaves the widget wherever your CSS puts it. Otherwise the collapsed launcher can be dragged anywhere on screen (the panel opens into the clearest space) and the open panel can be dragged by its header. See [Moving the launcher](#moving-the-launcher) and [Moving the panel](#moving-the-panel). |
|
|
208
|
+
| `data-quote-selection` | — | **On by default.** `="false"` stops the transcript offering to quote a selection. `quote()` keeps working either way. See [Quoting a selection](#quoting-a-selection). |
|
|
209
|
+
| `data-message-actions` | — | **Copy and retry by default; feedback is opt-in.** A comma list of the actions a finished answer keeps: `copy` / `retry` / `feedback` (e.g. `"copy,retry,feedback"` to add the rating pair, which needs an `ag-ui-feedback` listener). `="false"` removes the row entirely. See [Message actions](#message-actions-copy-retry-feedback). |
|
|
210
|
+
| `data-max-tool-rounds` | — | Upper bound on frontend tool-call → re-run rounds within one send (default 10; a value below 1 is ignored). Raise it for a page-driving agent whose turn takes many small steps. See [The run loop](#the-run-loop-and-the-ag-ui-client). |
|
|
211
|
+
| `data-page-actions` | — | Opt-in built-in page-action tools: a comma list of `scroll` / `drag` / `chat` (e.g. `"scroll,drag"`). See [Page-action tools](#page-action-tools). |
|
|
212
|
+
| `data-side` | — | For `placement="sidebar"`: which edge it docks to, `right` (default) / `left`. Styling, and read by script for the side `dockWidth` and `ag-ui-dock-width` report. Live. |
|
|
213
|
+
| `data-collapse-icon` | — | The header collapse control's mark: `panel-close` (a window with its docked pane and an arrow into the edge, mirrored on `data-side="left"`) / `chevron-up` / `chevron-down` / `chevron-left` / `chevron-right`. Absent, or any other value, follows the placement: `panel-close` on `sidebar` (and `side`), `chevron-down` everywhere else. A host icon in `slot="icon-collapse"` wins over it. Live. |
|
|
214
|
+
| `data-answer-well` | — | CSS-only boolean: box each assistant turn (its text, tool cards, and thinking) in one bordered "well". Off by default. See [The answer well](#the-answer-well). |
|
|
215
|
+
| `collapsed` | `collapsed` | Reflected boolean; collapses the widget to its [launcher](#collapsing-to-the-launcher) (a rail under `placement="sidebar"`, the header bar under `embedded`). Persisted per tab. `placement="page"` has no collapsed state and ignores it. |
|
|
216
|
+
| `collapse-to` | — | What the panel collapses to: `auto` (the default; absent or any other value reads the same) leaves every placement as it is, `bar` draws a strip along the bottom edge in place of the round launcher, `none` hides the collapsed widget so the host's own control brings it back. Under either, a corner panel still moves by its header and `moveTo` on a wide viewport, and remembers where it was dragged. Live. See [Collapsing to a bottom bar](#collapsing-to-a-bottom-bar). |
|
|
217
|
+
| `anchor` | `anchor` | Under `collapse-to="none"`, a selector for the host's element the panel rises from, right-aligned to it. The `anchor` property takes an element instead and wins over the attribute. Live. |
|
|
218
|
+
| `maximized` | `maximized` | Reflected boolean; grows the open panel to its [maximized size](#maximizing-the-panel). Refused on `page`, `full` and `embedded`, at 600px or less, and while collapsed; a collapse leaves it. Not persisted. Live. |
|
|
219
|
+
| `anchor-side` | — | Under `collapse-to="none"` with an anchor, which side of it the panel opens on: `above`, `below`, or `auto` (the default, and any other value), which takes the side with more room and re-decides on a window resize, never during a drag. Live. |
|
|
220
|
+
| `data-dragging` | — | **Written by the element, not by you.** Stamped on whichever handle a gesture is currently using, so the styles can react and so the element knows not to re-place the widget under a drag in progress. Cleared on `pointerup` and on `pointercancel`. |
|
|
221
|
+
| `data-expand-corner` | — | **Written by the element, not by you.** It stamps the corner a dragged or agent-moved panel opens from, so the collapse animation starts where the panel actually is. Listed because the element reads its own stamp back; setting it yourself is overwritten on the next move. |
|
|
222
|
+
| `data-greeting` | — | CSS-only: `off` turns the [greeting layout](#the-greeting-on-an-empty-page) off under `placement="page"`, where it is on by default. Any other value turns it on under `placement="embedded"`. No other placement has it. |
|
|
223
|
+
| `data-empty` | — | **Written by the element, not by you.** Present while the conversation has nothing in it, which is when the greeting shows. Public as a styling hook, so the chrome around a full-page chat can react to the same state, e.g. `ag-ui-chat[data-empty] ~ .page-footer { display: none }`. |
|
|
224
|
+
| `data-running` | — | **Written by the element, not by you.** Present while an interaction is in flight, the same state that shows the Stop button (see the `running` property). Public as a styling hook, as `data-empty` is, so the chrome around the chat can react to it, e.g. `ag-ui-chat[data-running] ~ .toolbar button { pointer-events: none }`. |
|
|
225
|
+
| `data-threads-search` | — | When the conversation list shows its search box: `auto` (absent, or any other value) from eight conversations, `always` over any list, `never` not at all. Live. See [The conversation list](#the-conversation-list). |
|
|
226
|
+
| `data-small-viewport` | — | `off` keeps the desktop layout at every width, opting out of the [small-viewport override](#small-viewports). Styling, and read by script for the width `dockWidth` reports on a phone. Live. Everything that override sets is a token you can re-state; its trigger is a media query, which is the one thing you cannot. |
|
|
227
|
+
| `data-paste-attach` | — | When to turn a long text paste into an attachment instead of composer text: absent for the 5000-character default, `off` to never, or a positive number of characters. Only acts where `data-attachments-url` (or a custom `uploadHandler`) gives it somewhere to go. |
|
|
228
|
+
| `data-starters` | — | JSON array of prompts offered on an empty transcript, e.g. `'["Summarise this page"]'`. Fallback content for `slot="empty"`, so slotting your own replaces them. Shares the four-prompt and 120-character limits with the suggestion chips a run pushes. Read once at connect. |
|
|
229
|
+
| `data-start-open` | — | Mount the panel open on a first visit. The corner placements otherwise rest at their launcher, the way every corner chat does; a stored choice wins over both. The placements that place themselves are unaffected. |
|
|
230
|
+
| `theme` | — | Styling: an optional theme name (`quiet` (default) / `soft` / `ledger` / `classic`, or a word of your own) and an optional scheme (`light` (default) / `dark` / `auto`), in any order, e.g. `soft dark`. Live: the [header theme toggle](#theme-toggle)'s glyph follows a scheme written after connect. See [Themes](#themes). |
|
|
231
|
+
| `density` | — | CSS-only: `comfortable` (default) / `compact`. |
|
|
232
|
+
| `placement` | — | CSS-only: `floating` (default) / `sidebar` / `page` / `embedded` / `full`. `full` fills the viewport at every width, inside the safe-area insets; the `--ag-ui-inset`, size and `--ag-ui-panel-radius` tokens still apply. |
|
|
233
|
+
|
|
234
|
+
Each header control also takes its own icon slot — `icon-history`, `icon-checkpoints`,
|
|
235
|
+
`icon-new`, `icon-maximize`, `icon-collapse` — with the built-in drawn glyph as the fallback, so a host can project a
|
|
236
|
+
brand `<img>` or `<svg>` in its place. The built-in header marks are drawn at
|
|
237
|
+
`--ag-ui-glyph-size`. The composer's glyphs work the
|
|
238
|
+
same way: `icon-send`, `icon-stop`, `icon-attach`, `icon-voice`.
|
|
239
|
+
|
|
240
|
+
```html
|
|
241
|
+
<ag-ui-chat endpoint="/agent/">
|
|
242
|
+
<svg slot="icon-new" width="16" height="16"><!-- ... --></svg>
|
|
243
|
+
</ag-ui-chat>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The collapse control's built-in mark follows the placement, because a sidebar hides sideways
|
|
247
|
+
into its edge rather than down: `panel-close` (a window with its docked pane and an arrow into
|
|
248
|
+
the edge) on `sidebar` and `side`, mirrored for either on `data-side="left"`, and
|
|
249
|
+
`chevron-down` on `floating`, `full`, `embedded` and every other placement. `data-collapse-icon`
|
|
250
|
+
picks one of the built-in marks outright, at any placement and live: `panel-close`,
|
|
251
|
+
`chevron-up`, `chevron-down`, `chevron-left` or `chevron-right`; any other value reads as
|
|
252
|
+
absent. `panel-close` always points into the edge the panel docks to, so it mirrors on a left
|
|
253
|
+
docked panel whether chosen or defaulted; the chevrons do not turn. For a mark of your own, slot it
|
|
254
|
+
into `icon-collapse`, which wins over the attribute and every placement:
|
|
255
|
+
|
|
256
|
+
```html
|
|
257
|
+
<ag-ui-chat endpoint="/agent/" placement="floating" data-collapse-icon="chevron-right"></ag-ui-chat>
|
|
258
|
+
<ag-ui-chat endpoint="/agent/" placement="sidebar">
|
|
259
|
+
<svg slot="icon-collapse" width="16" height="16"><!-- ... --></svg>
|
|
260
|
+
</ag-ui-chat>
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
#### When each attribute is read
|
|
264
|
+
|
|
265
|
+
The element observes two groups of attributes, and they behave differently once it is in the DOM.
|
|
266
|
+
Nothing outside those groups is observed: a CSS-only attribute (`theme`, `density`,
|
|
267
|
+
`data-answer-well`, `data-tool-group-display`) is read by the stylesheet rather than by script, and `endpoint`,
|
|
268
|
+
`data-tool-display`, `data-text-animation`, `data-runs-url`, `data-page-actions`,
|
|
269
|
+
`data-message-actions`, `data-max-tool-rounds`, `data-unread-badge`, `data-launcher-drag` and
|
|
270
|
+
`data-quote-selection` are re-read at each use, so a late write to any of those simply takes effect. The one attribute in
|
|
271
|
+
neither camp is `data-launcher-icon-url`: it is read while the element connects, like the group
|
|
272
|
+
below, but is not observed, so a late write is inert and says nothing.
|
|
273
|
+
|
|
274
|
+
**Live attributes.** Written at any time, before or after the element connects, and acted on
|
|
275
|
+
either way: `title-text`, `placement`, `collapse-to`, `anchor`, `anchor-side`, `maximized`, `data-side`, `data-small-viewport`, `data-threads-search`, `credentials`, `user-key`,
|
|
276
|
+
`user-name`, `data-tool-group-display`, `data-collapse-icon`, `theme`.
|
|
277
|
+
|
|
278
|
+
**Connect-time attributes.** Read once, while the element connects, to decide what chrome exists at
|
|
279
|
+
all — the tray, the mic, the skills menu, the header mark. Writing one afterwards has **no effect**;
|
|
280
|
+
the element logs a console warning naming the attribute rather than failing silently, because the
|
|
281
|
+
symptom is an affordance that never appears and that reads as a broken component. Set them before
|
|
282
|
+
the element enters the DOM, or remove and re-insert it. See
|
|
283
|
+
[Framework hosts](#framework-hosts-configure-before-you-insert), where the boundary bites hardest.
|
|
284
|
+
The list: `data-attachments-url`, `data-attachment-accept`, `data-attachment-max-bytes`,
|
|
285
|
+
`data-transcribe-url`, `data-threads-url`, `data-threads-cache`, `data-tools-url`,
|
|
286
|
+
`data-skills-url`, `data-skills`, `data-prompt-chips`, `data-slash-commands`, `data-theme-toggle`,
|
|
287
|
+
`data-strings`, `data-icon-url`.
|
|
288
|
+
|
|
289
|
+
What re-inserting costs: the chrome is built again from the attributes as they stand, a run in
|
|
290
|
+
progress is cancelled, and the conversation is redrawn from its stored history — the same rebuild
|
|
291
|
+
`reload()` performs, and the reason that method exists for a host that can only configure the
|
|
292
|
+
element after the fact.
|
|
293
|
+
|
|
294
|
+
**Properties** (JS only, not attributes): `headers`, `getHeaders`, `trustedOrigins`, `allowImages`,
|
|
295
|
+
`autoConfirm`, `confirmPredicate`, `askUser`, `askUserRenderer`, `approvalRenderer`,
|
|
296
|
+
`approveWithEdits`, `agentFactory`, `getTools`, `getContext`, `routeMap`, `navigate`, `getPageMap`,
|
|
297
|
+
`autoInjectPageMap`, `getPlace`, `conversationStore`, `uploadHandler`, `transcribeHandler`, `navigationResult`,
|
|
298
|
+
`skillContext`, `toolSummaries`, `formatToolPayload`, `formatRelativeTime`, `strings`,
|
|
299
|
+
`resolvePageTarget`, `sharedState`, `anchor` (an element, which wins over the attribute's selector), `extraStyles` ([unstable](#d-extrastyles-the-last-mile)), plus the read-only `unread`, `unhandledActivityTypes`, `threadId`, `threadTitle`, `running`, `barHeight` and `dockWidth`, and
|
|
300
|
+
the attribute mirrors `endpoint` / `userKey` / `userName` / `toolDisplay` / `toolGroupDisplay` / `collapsed` /
|
|
301
|
+
`maximized` / `credentials`.
|
|
302
|
+
|
|
303
|
+
`headers` and `getHeaders` authenticate **every** request the element makes, not only the agent
|
|
304
|
+
run; `getHeaders` is the one to use for a credential that rotates. See
|
|
305
|
+
[Authenticating requests](#authenticating-requests).
|
|
306
|
+
|
|
307
|
+
`allowImages` (default `false`) re-enables `<img>` in rendered assistant markdown.
|
|
308
|
+
It is off by default because a model-controlled image URL is fetched by the browser
|
|
309
|
+
with no user interaction — a zero-click exfiltration channel for prompt-injected
|
|
310
|
+
page data. Enable only when the content source is trusted.
|
|
311
|
+
|
|
312
|
+
`toolSummaries` is a `Record<string, string>` mapping tool name → a friendly card
|
|
313
|
+
label, used when a tool has no `x-summary` in its own schema. Built-in and client tools
|
|
314
|
+
should carry `x-summary` directly; this map is the seam for **server-side tools** (drf-mcp,
|
|
315
|
+
the django-ag-ui `@tool` registry), whose schema never reaches the browser — e.g.
|
|
316
|
+
`chat.toolSummaries = { list_projects: "Search projects" }`. Or point
|
|
317
|
+
`data-tools-url` at a server catalog endpoint (django-ag-ui's `tools/`) and the
|
|
318
|
+
labels are fetched automatically — per card, `x-summary` → an explicit
|
|
319
|
+
`toolSummaries` entry → the fetched catalog → the raw name.
|
|
320
|
+
|
|
321
|
+
`sharedState` is AG-UI shared state, documented under
|
|
322
|
+
[the run loop](#the-run-loop-and-the-ag-ui-client); `unread` and `unhandledActivityTypes` are
|
|
323
|
+
read-only counters, covered under [the unread badge](#the-unread-badge) and
|
|
324
|
+
[finding out what arrived](#finding-out-what-arrived); `threadId` and `running` are covered under
|
|
325
|
+
[driving the conversation from your own chrome](#driving-the-conversation-from-your-own-chrome).
|
|
326
|
+
|
|
327
|
+
Code blocks in an agent's answer carry a **copy button**, revealed on hover or
|
|
328
|
+
keyboard focus and styleable via the `code-copy` part. Override its labels with
|
|
329
|
+
the `copyCode` / `copied` / `copyFailed` strings.
|
|
330
|
+
|
|
331
|
+
**Methods**: `registerTool`, `registerPageState`, `registerActivityRenderer`, `setSkills`,
|
|
332
|
+
`sendMessage`, `attachFile`, `appendMessage`, `retryLastTurn`, `quote`, `offerQuoteInPage`,
|
|
333
|
+
`enableCharts`, `newChat`, `setCollapsed`, `toggleCollapsed`, `setMaximized`, `toggleMaximized`,
|
|
334
|
+
`describeSurface`, `moveTo`, `resetSize`, `toggleTheme`, `openThreads`, `closeThreads`, `switchThread`, `deleteThread`,
|
|
335
|
+
`openCheckpoints`, `closeCheckpoints`, `toggleCheckpoints`, `refreshPlace` and `reload`.
|
|
336
|
+
|
|
337
|
+
### Sending from your own UI
|
|
338
|
+
|
|
339
|
+
`sendMessage(content, attachments?)` sends as if the user had typed it — user bubble,
|
|
340
|
+
`ag-ui-submit` event, run started. `ag-ui-submit` means a turn was sent, not that a run started:
|
|
341
|
+
a Retry and a run continued from the checkpoint panel start runs without one. To know whether
|
|
342
|
+
anything is running, read [`running`](#driving-the-conversation-from-your-own-chrome) or style on
|
|
343
|
+
`data-running`. Use it for an "Ask about this order" button, a command
|
|
344
|
+
palette, a prompt fired when your page mounts the chat, or a composer of your own replacing the
|
|
345
|
+
built-in one.
|
|
346
|
+
|
|
347
|
+
It returns a `Promise<boolean>`. It resolves `true` once the turn has been handed to the agent
|
|
348
|
+
and its run has settled, frontend-tool rounds included, and `false` when nothing was sent:
|
|
349
|
+
|
|
350
|
+
- for an entirely empty message;
|
|
351
|
+
- while a run, or a checkpoint continuation picked from the panel, is in flight. A continuation
|
|
352
|
+
counts from the pick, and a send from the call rather than from when its run starts, so a
|
|
353
|
+
`sendMessage` from an `ag-ui-submit` listener, or a second call in the same task, is refused
|
|
354
|
+
rather than starting a second run;
|
|
355
|
+
- with no `endpoint` set, which the transcript also says;
|
|
356
|
+
- while a reload resumes a run a navigating tool interrupted. The resume counts from the moment
|
|
357
|
+
the element asks its conversation store for the conversation, and it ends by starting that
|
|
358
|
+
run, so a send then is refused, and the built-in Send queues behind the resumed answer;
|
|
359
|
+
- when your own code that runs on the way to the request — an `ag-ui-submit` listener, the
|
|
360
|
+
conversation store's save — stops it by starting a new chat or changing `user-key`. A
|
|
361
|
+
`getContext` or `getTools` that does the same stops it too, but after the turn was handed
|
|
362
|
+
over, so the call resolves `true`.
|
|
363
|
+
|
|
364
|
+
**While the element restores a stored conversation, a send waits for it.** Every restore —
|
|
365
|
+
when the element connects, on `reload()`, when a thread is picked from the drawer, and when
|
|
366
|
+
`user-key` changes from one principal to another — first asks the conversation store for the conversation, and with
|
|
367
|
+
`data-threads-url` that is a request to your server. A `sendMessage` made in that time waits
|
|
368
|
+
until the conversation is drawn, then sends with it: the bubble, the `ag-ui-submit` event and the
|
|
369
|
+
request all come after the replay, later than the call by the length of the load. So a prompt
|
|
370
|
+
fired on mount needs no timing of its own. If New chat, a thread switch, a change from one
|
|
371
|
+
`user-key` to another or a `reload()` comes while it waits, the conversation it was addressed
|
|
372
|
+
to is gone, so it is dropped and resolves `false` at once. The element leaving the page drops it
|
|
373
|
+
too, but it resolves `false` only once the load answers or the element is inserted again. A
|
|
374
|
+
first `user-key` on an anonymous conversation adopts that conversation rather than replacing
|
|
375
|
+
it, so a send waiting then still goes out. A second `sendMessage` while one waits is refused. The built-in Send queues what is typed during the load ("Waiting to send"),
|
|
376
|
+
and sends it after the conversation is drawn, behind a `sendMessage` that was waiting.
|
|
377
|
+
|
|
378
|
+
Unlike the built-in Send it does **not** queue, so your composer keeps what it tried to send, and
|
|
379
|
+
it does **not** consult the attachment tray: what you pass is what is sent, so your composer stays
|
|
380
|
+
in charge of its own state.
|
|
381
|
+
|
|
382
|
+
`retryLastTurn()` keeps the same rules, because it starts a run on the same conversation. It
|
|
383
|
+
returns `false` while a run, a send, another Retry or a continuation is in flight, and while
|
|
384
|
+
any restore of the stored conversation is still loading. It does not wait for the load as a send
|
|
385
|
+
does: until the conversation is drawn there is nothing on screen to retry, and afterwards it
|
|
386
|
+
would ask the previous visit's last question again. It counts from the call as a send does: until its run starts, a `sendMessage` or a
|
|
387
|
+
second Retry is refused, the built-in Send queues, and a checkpoint pick is refused. It also
|
|
388
|
+
returns `false` when your own code stops it before its run starts: a conversation store, or an
|
|
389
|
+
activity or tool renderer it redraws through, that starts a new chat or changes `user-key`. A
|
|
390
|
+
`getContext` or `getTools` that does the same stops it too, before anything is sent, but it runs
|
|
391
|
+
after the Retry has handed its turn to the client, so the call resolves `true`. A store that
|
|
392
|
+
throws from the Retry's save leaves the conversation as it was, and `retryLastTurn()` rejects
|
|
393
|
+
with its error.
|
|
394
|
+
|
|
395
|
+
`attachFile(file)` queues a file into the tray exactly as the picker and drag-and-drop do, with
|
|
396
|
+
the same validation and progress chip. It returns `false` when uploads are not configured
|
|
397
|
+
(no `data-attachments-url` and no `uploadHandler`) — the only way to tell, since with no tray
|
|
398
|
+
there is nothing to report through.
|
|
399
|
+
|
|
400
|
+
Uploading is asynchronous, so watch `ag-ui-attachments` for the result. Its `detail` carries
|
|
401
|
+
`{ attachments, pending }`: the durable refs of everything that has finished, and how many are
|
|
402
|
+
still in flight. Send once `pending` is `0`, or you will leave files behind.
|
|
403
|
+
|
|
404
|
+
```js
|
|
405
|
+
chat.addEventListener("ag-ui-attachments", (e) => {
|
|
406
|
+
const { attachments, pending } = e.detail;
|
|
407
|
+
sendButton.disabled = pending > 0;
|
|
408
|
+
sendButton.onclick = () => chat.sendMessage(input.value, attachments);
|
|
409
|
+
});
|
|
410
|
+
chat.attachFile(fileInput.files[0]);
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
A self-contained live playground lives in [`demo/`](demo/) — run `make demo` to serve it against a
|
|
414
|
+
mock AG-UI server.
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## Authenticating requests
|
|
419
|
+
|
|
420
|
+
The element talks to more than one endpoint. Beyond the AG-UI run itself, it may fetch the thread
|
|
421
|
+
index and a thread's messages, the tool-label and skill catalogs, the run index, and it may POST an
|
|
422
|
+
upload or a voice clip. **Every one of them is authenticated the same way**, by the element rather
|
|
423
|
+
than by the agent — so configuring authentication on a custom `agentFactory` authenticates the run
|
|
424
|
+
and nothing else, and the history drawer comes back empty because its request was anonymous.
|
|
425
|
+
|
|
426
|
+
| Request | Endpoint | Transport |
|
|
427
|
+
| --- | --- | --- |
|
|
428
|
+
| The agent run | `endpoint` | `fetch` (SSE), via `agentFactory` |
|
|
429
|
+
| Thread index / a thread's messages / rename / delete | `data-threads-url` | `fetch` |
|
|
430
|
+
| Tool-label catalog | `data-tools-url` | `fetch` |
|
|
431
|
+
| Skill catalog | `data-skills-url` | `fetch` |
|
|
432
|
+
| Run index | `data-runs-url` | `fetch` |
|
|
433
|
+
| Voice transcription | `data-transcribe-url` | `fetch` |
|
|
434
|
+
| File upload | `data-attachments-url` | `XMLHttpRequest` (for progress events) |
|
|
435
|
+
|
|
436
|
+
### `headers` and `getHeaders`
|
|
437
|
+
|
|
438
|
+
`headers` is a plain record sent with every request above:
|
|
439
|
+
|
|
440
|
+
```js
|
|
441
|
+
chat.headers = { "X-CSRFToken": getCsrfToken() };
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
It is read at request time, but only an assignment changes it — so a token captured there is pinned
|
|
445
|
+
until you remember to assign again. For anything that rotates (a short-lived JWT, a re-issued CSRF
|
|
446
|
+
token) set **`getHeaders`** instead, a function consulted immediately before every request:
|
|
447
|
+
|
|
448
|
+
```js
|
|
449
|
+
chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Because it is called per request, a token refreshed between two requests reaches the second one —
|
|
453
|
+
including mid-conversation, on the cached agent's own stream.
|
|
454
|
+
|
|
455
|
+
The two **compose**: they are merged per key, `getHeaders` winning, so a fixed header and a rotating
|
|
456
|
+
one can be configured independently and neither silently drops the other.
|
|
457
|
+
|
|
458
|
+
```js
|
|
459
|
+
chat.headers = { "X-Client": "admin" };
|
|
460
|
+
chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
|
|
461
|
+
// every request: X-Client: admin + a freshly-read Authorization
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Cross-origin cookies (`credentials`)
|
|
465
|
+
|
|
466
|
+
If your API is on a different origin from the page — `app.example.com` calling `api.example.com`
|
|
467
|
+
counts, subdomains are cross-origin — the browser's default of `same-origin` sends **no cookies at
|
|
468
|
+
all**. The requests still go out; they arrive unauthenticated, and the server answers `401` while
|
|
469
|
+
looking perfectly configured. Set the cookie policy explicitly:
|
|
470
|
+
|
|
471
|
+
```html
|
|
472
|
+
<ag-ui-chat endpoint="https://api.example.com/agent/" credentials="include"></ag-ui-chat>
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
```js
|
|
476
|
+
chat.credentials = "include"; // mirrors the attribute
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
It takes `fetch`'s own three modes — `omit`, `same-origin`, `include` — applies to every request in
|
|
480
|
+
the table above, and is read per request, so a late assignment applies to everything after it.
|
|
481
|
+
Anything else is rejected where you wrote it: an unknown value assigned as a property throws, and an
|
|
482
|
+
unknown value in the attribute is reported to the console and ignored, rather than becoming a `401`
|
|
483
|
+
later on.
|
|
484
|
+
|
|
485
|
+
The server has to agree: `Access-Control-Allow-Credentials: true` and a concrete
|
|
486
|
+
`Access-Control-Allow-Origin` (the wildcard is invalid with credentials).
|
|
487
|
+
|
|
488
|
+
One asymmetry: uploads use `XMLHttpRequest` for real progress events, and its cookie switch is
|
|
489
|
+
two-state. `include` turns it on; every other value leaves it off. `omit` therefore cannot suppress
|
|
490
|
+
cookies on a *same-origin* upload — supply your own `uploadHandler` if that matters.
|
|
491
|
+
|
|
492
|
+
### Where those credentials are allowed to go
|
|
493
|
+
|
|
494
|
+
Every URL in the table above is a plain HTML attribute, and `headers` / `getHeaders` are attached to
|
|
495
|
+
whatever they name. That is what makes a cross-origin agent work — and it is also why a page must
|
|
496
|
+
never build one of those attributes out of a URL parameter, a CMS field, or anything else it did not
|
|
497
|
+
choose itself. Whoever supplies the value chooses where the token goes: the browser preflights the
|
|
498
|
+
custom header, any server willing to answer receives it, and it leaves on the element's first
|
|
499
|
+
request, before the user has typed anything.
|
|
500
|
+
|
|
501
|
+
Treat all seven as trusted configuration. When any of them resolves to another origin, the element
|
|
502
|
+
says so on the console once per origin, naming the destination and the header names it is about to
|
|
503
|
+
send. That covers all seven, not the agent endpoint alone: the tool catalog, the skills list, the
|
|
504
|
+
thread index, the attachment upload and the transcription endpoint carry the same headers, and
|
|
505
|
+
reporting only the agent would report the least interesting of them.
|
|
506
|
+
|
|
507
|
+
To confirm destinations you chose on purpose and silence the notice, name their origins:
|
|
508
|
+
|
|
509
|
+
```js
|
|
510
|
+
chat.trustedOrigins = ["https://api.example.com"];
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
That covers every endpoint the element requests itself, and is forwarded to `createHttpAgent`, so a
|
|
514
|
+
host that does not override `agentFactory` needs nothing else. A custom factory can also be given
|
|
515
|
+
the option directly:
|
|
516
|
+
|
|
517
|
+
```js
|
|
518
|
+
chat.agentFactory = (options) =>
|
|
519
|
+
createHttpAgent({ ...options, trustedOrigins: ["https://api.example.com"] });
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
Origins are compared as `URL.origin` produces them — scheme, host and port. A notice is a notice,
|
|
523
|
+
not a refusal: nothing is blocked, because a cross-origin agent is a supported deployment and
|
|
524
|
+
refusing would break working installations to defend against a page that is already interpolating
|
|
525
|
+
untrusted data into its own markup.
|
|
526
|
+
|
|
527
|
+
### Framework hosts: configure before you insert
|
|
528
|
+
|
|
529
|
+
`headers`, `getHeaders` and `credentials` are read when a request is made, so they can be set at any
|
|
530
|
+
time. Several other things are read once, while the element **connects**: `strings`, `uploadHandler`
|
|
531
|
+
and `transcribeHandler` (they decide whether the attach and voice affordances exist at all) and
|
|
532
|
+
every chrome-building `data-*` attribute — and the catalogs and thread history are requested at, or
|
|
533
|
+
just after, that same moment.
|
|
534
|
+
|
|
535
|
+
React attaches `ref`s *after* it inserts the node, which puts the canonical integration on the wrong
|
|
536
|
+
side of that boundary. Create the element, configure it, then append:
|
|
537
|
+
|
|
538
|
+
```jsx
|
|
539
|
+
function Assistant() {
|
|
540
|
+
const host = useRef(null);
|
|
541
|
+
|
|
542
|
+
useEffect(() => {
|
|
543
|
+
defineChat();
|
|
544
|
+
const chat = document.createElement("ag-ui-chat");
|
|
545
|
+
|
|
546
|
+
// Configure first - every one of these is read as the element connects.
|
|
547
|
+
chat.setAttribute("endpoint", "/agent/");
|
|
548
|
+
chat.setAttribute("data-threads-url", "/agent/threads/");
|
|
549
|
+
chat.credentials = "include";
|
|
550
|
+
chat.getHeaders = () => ({ Authorization: `Bearer ${auth.accessToken()}` });
|
|
551
|
+
chat.registerTool(myTool);
|
|
552
|
+
|
|
553
|
+
// ...then insert it.
|
|
554
|
+
host.current.appendChild(chat);
|
|
555
|
+
return () => chat.remove();
|
|
556
|
+
}, []);
|
|
557
|
+
|
|
558
|
+
return <div ref={host} />;
|
|
559
|
+
}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
Writing it as `<ag-ui-chat ref={...} />` in JSX and configuring in the ref callback mostly works —
|
|
563
|
+
the catalog requests are held back one microtask precisely so a ref assigned in the same commit is
|
|
564
|
+
honoured — but the thread-history request is **not** deferred, so that one goes out with whatever
|
|
565
|
+
was configured at insertion. It starts at insertion because the restore is what a `sendMessage()`
|
|
566
|
+
from the same commit waits on: a restore that had not started yet would have nothing to hold it,
|
|
567
|
+
and the send would go out without the conversation.
|
|
568
|
+
|
|
569
|
+
If your credentials can only arrive later still — an awaited token, a passive effect — call
|
|
570
|
+
**`reload()`** once they land:
|
|
571
|
+
|
|
572
|
+
```js
|
|
573
|
+
const token = await auth.login();
|
|
574
|
+
chat.getHeaders = () => ({ Authorization: `Bearer ${token}` });
|
|
575
|
+
await chat.reload();
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
`reload()` re-runs everything the element loads on startup (tool catalog, skills, thread history)
|
|
579
|
+
with the configuration as it then stands. It is a reload, not a merge: the in-flight run is
|
|
580
|
+
cancelled and the transcript is rebuilt from the persisted history, so call it when configuration
|
|
581
|
+
lands rather than between turns.
|
|
582
|
+
|
|
583
|
+
#### The same boundary in four frameworks
|
|
584
|
+
|
|
585
|
+
Each framework reaches that pre-insertion window differently, and only one of them reaches it
|
|
586
|
+
declaratively. Built and driven in all four:
|
|
587
|
+
|
|
588
|
+
| Host | Pre-insertion window | What to do |
|
|
589
|
+
| --- | --- | --- |
|
|
590
|
+
| React | None — refs attach after insertion | `createElement`, configure, `appendChild` (above) |
|
|
591
|
+
| **Vue 3** | **Yes** — a directive's `beforeMount` | Attributes in the template, properties in the directive |
|
|
592
|
+
| Svelte 5 | None — `use:` actions and `$effect` run after insertion | Same as React |
|
|
593
|
+
| Angular | None — bindings apply during change detection | Same as React, in `ngOnInit` with `@ViewChild({ static: true })` |
|
|
594
|
+
|
|
595
|
+
**Vue** is the one host that can configure declaratively, because a custom directive's `beforeMount`
|
|
596
|
+
runs while the element is still detached:
|
|
597
|
+
|
|
598
|
+
```vue
|
|
599
|
+
<script setup>
|
|
600
|
+
const vConfigure = {
|
|
601
|
+
beforeMount(element) {
|
|
602
|
+
element.getHeaders = () => ({ Authorization: `Bearer ${token()}` });
|
|
603
|
+
element.registerTool(myTool);
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
</script>
|
|
607
|
+
|
|
608
|
+
<template>
|
|
609
|
+
<ag-ui-chat v-configure endpoint="/agent/" data-threads-url="/agent/threads/" />
|
|
610
|
+
</template>
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Tell Vue's compiler the tag is a custom element, or it will warn and try to resolve a component:
|
|
614
|
+
`vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === "ag-ui-chat" } } })`.
|
|
615
|
+
|
|
616
|
+
**Svelte 5**'s `use:` action and `$effect` both run after the node is in the DOM, so build the
|
|
617
|
+
element by hand in an `$effect` and append it — the React shape, in runes. **Angular** needs
|
|
618
|
+
`CUSTOM_ELEMENTS_SCHEMA` on the component and, if it wraps the panel in its own component, one line
|
|
619
|
+
of CSS: `:host { display: contents }`. Angular's host element otherwise lands between your grid and
|
|
620
|
+
the children it sizes, and the panel renders a few hundred pixels tall in the middle of the page.
|
|
621
|
+
|
|
622
|
+
---
|
|
623
|
+
|
|
624
|
+
## Core concepts
|
|
625
|
+
|
|
626
|
+
### The run loop and the AG-UI client
|
|
627
|
+
|
|
628
|
+
`<ag-ui-chat>` is the view; [`AgUiClient`](src/core/agui_client.ts) is the orchestration layer over
|
|
629
|
+
an AG-UI `AbstractAgent`. On the first send the element builds a client (via the overridable
|
|
630
|
+
`agentFactory`, which defaults to [`createHttpAgent`](src/core/create_http_agent.ts)). Each turn:
|
|
631
|
+
|
|
632
|
+
1. The user message is appended and the agent runs once.
|
|
633
|
+
2. AG-UI subscriber events are translated into the element's handlers — streaming text deltas
|
|
634
|
+
render into a bubble; each `TOOL_CALL_END` becomes a tool-call card.
|
|
635
|
+
3. Any **frontend** tool calls collected during the run are executed locally, their results are
|
|
636
|
+
appended as `tool` messages, and the agent is re-run with the results.
|
|
637
|
+
4. This repeats until the agent stops calling frontend tools, bounded by `MAX_TOOL_ROUNDS`
|
|
638
|
+
(10) — raise it with `data-max-tool-rounds`, or `AgUiClientConfig.maxToolRounds` when you
|
|
639
|
+
drive the client yourself. The default suits a chat whose tools answer questions; a
|
|
640
|
+
page-driving deployment reaches it legitimately, one round per field filled, and the symptom
|
|
641
|
+
is not an error but an answer that stops mid-task. A value below 1 is ignored rather than
|
|
642
|
+
honoured — it would be a send that never runs the agent at all.
|
|
643
|
+
|
|
644
|
+
Tool calls the client doesn't own (server-side tools the server already executed) are left alone —
|
|
645
|
+
the loop doesn't re-run them, but their streamed `TOOL_CALL_RESULT` is rendered into the tool-call
|
|
646
|
+
card (honouring `data-tool-display`), so server-side output is visible too. The current tool catalog
|
|
647
|
+
and context are read **fresh on every run** (`getTools()` / `getContext()`), so they always reflect
|
|
648
|
+
the current page state.
|
|
649
|
+
|
|
650
|
+
#### A tool call that failed
|
|
651
|
+
|
|
652
|
+
A `TOOL_CALL_RESULT` may say how the call ended, as an optional **`outcome`** key in the event's
|
|
653
|
+
`metadata` (`"metadata": { "outcome": "failed" }`). It takes pydantic-ai's own `ToolReturnPart`
|
|
654
|
+
vocabulary — read the values off `TOOL_OUTCOME`:
|
|
655
|
+
|
|
656
|
+
| `outcome` | Card | Means |
|
|
657
|
+
| --- | --- | --- |
|
|
658
|
+
| *(absent)* | done | A result, said the way every server has always said it. |
|
|
659
|
+
| `success` | done | The same thing, stated. |
|
|
660
|
+
| `failed` | error | The call ran and failed. |
|
|
661
|
+
| `denied` | declined | A person or a guard refused it, so it never ran. |
|
|
662
|
+
| `interrupted` | not finished | It produced no result because the run ended first, and nobody refused it. |
|
|
663
|
+
|
|
664
|
+
**Absent is a success, and anything unrecognised is too** — including a value from a later protocol
|
|
665
|
+
version. A card is a claim about what happened, and refusing to
|
|
666
|
+
recognise a word is not grounds for claiming failure. So no server has to change to keep the
|
|
667
|
+
rendering it has today, and a server that adds the field gets the truth on screen instead of a
|
|
668
|
+
green card with a refusal folded inside it.
|
|
669
|
+
|
|
670
|
+
It has to be in `metadata`. `@ag-ui/client` 1.0 strips every key its schemas do not declare from an
|
|
671
|
+
event before any subscriber sees it, with a `[ag-ui][enforce]` console warning, and `metadata` is
|
|
672
|
+
the one place on an event it declares open by key. An `outcome` at the top level of the event —
|
|
673
|
+
where a server wrote it for the 0.x client, and where a django-ag-ui older than 0.63.0 still does —
|
|
674
|
+
never arrives, so the card settles as done. The key arrives on `AgUiClientHandlers.onToolResult` as
|
|
675
|
+
an optional third argument, typed `unknown` because the protocol validates nothing inside
|
|
676
|
+
`metadata`; `toolStatusFromOutcome` does the narrowing.
|
|
677
|
+
|
|
678
|
+
A **frontend** tool never streams this event — the client posts its own result — so its outcome is
|
|
679
|
+
recorded on the `ToolExecution` the executor returns (`{ content, outcome: "denied" }`). That covers
|
|
680
|
+
the two refusals the component makes itself: a confirmation card the user declined, and a call
|
|
681
|
+
blocked because the page moved under the round.
|
|
682
|
+
|
|
683
|
+
Either way the outcome is **persisted on the tool message, in its `metadata`**, so a reload replays
|
|
684
|
+
the card as it settled rather than as a plain result. For a server's result `@ag-ui/client` puts it
|
|
685
|
+
there itself, folding the event's metadata onto the tool message it appends; for its own results the
|
|
686
|
+
client writes it in the same place. See
|
|
687
|
+
[MPA durability](#mpa-durability-surviving-full-page-reloads).
|
|
688
|
+
|
|
689
|
+
**Every request carries a result for every tool call in it.** Several model providers reject a
|
|
690
|
+
turn holding a tool call with no result, and a run can leave one open in more ways than one: Stop
|
|
691
|
+
while the stream is still arriving, a round that ends on `RUN_ERROR`, a call naming a tool nothing
|
|
692
|
+
here owns, a server that never streamed the result. So before each request the client answers
|
|
693
|
+
every call still open with the `callNotFinished` string (by default `Not finished: the run ended
|
|
694
|
+
or moved on before this tool call returned a result.`) and the outcome `interrupted`, placed at the
|
|
695
|
+
end of the round that made the call, and the card settles to **not finished** in the same words.
|
|
696
|
+
That result blames nobody, because nobody declined: `declinedAction` is kept for a call a person
|
|
697
|
+
was asked about and turned down — a confirmation card or an approval they declined, or one Stop
|
|
698
|
+
closed while it was open. The calls a resumed approval is answering are left alone, because the
|
|
699
|
+
resume is their answer.
|
|
700
|
+
|
|
701
|
+
The catalog a run advertises is also the set that run can execute. Override `getTools` to scope
|
|
702
|
+
what a page offers — say, exposing `delete_record` only where deleting makes sense — and a call
|
|
703
|
+
naming a tool you withheld is treated exactly as a call naming a tool you never registered: no
|
|
704
|
+
handler runs, the card settles as not finished, and the next request answers the call in those
|
|
705
|
+
words. Withholding is per run, so the
|
|
706
|
+
mount-wide registry can stay complete. Hosts that leave `getTools` alone advertise the built-ins
|
|
707
|
+
plus everything registered, which is precisely what dispatch could reach anyway.
|
|
708
|
+
|
|
709
|
+
### Stopping a run
|
|
710
|
+
|
|
711
|
+
While a run is in flight the **Send button becomes Stop** (same button, label/`aria-label` swap,
|
|
712
|
+
`data-state="running"` for styling); clicking it — or pressing **Escape** in the composer (when
|
|
713
|
+
the skills palette is closed; the palette owns Escape while open) — calls `AgUiClient.cancel()`.
|
|
714
|
+
AG-UI has no server-side cancel route: cancelling **aborts the streaming request**
|
|
715
|
+
(`abortRun()`), and the server observes the disconnect. On cancel:
|
|
716
|
+
|
|
717
|
+
- Partial assistant text already streamed **stays in the transcript** and is persisted via
|
|
718
|
+
`onPersist`, so a reload shows the truncated exchange. A muted **"⏹ Stopped"** note is appended
|
|
719
|
+
(`.stopped-note`) — a deliberate stop is not an error, so no bubble.
|
|
720
|
+
- The run loop stops: tool calls collected before the abort are **not executed**, and no further
|
|
721
|
+
round starts. A frontend tool handler already running completes and its result is kept, but it
|
|
722
|
+
doesn't trigger a re-run, and the calls after it in the same round do not start. Each call that
|
|
723
|
+
did not run settles as **not finished**, and the next request answers
|
|
724
|
+
it in those words rather than sending it without a result.
|
|
725
|
+
- An **open confirmation card is declined** (`data-resolved="declined"`) — cancelling the run
|
|
726
|
+
answers the pending question. Likewise an open **approval card** is denied, and the next request
|
|
727
|
+
carries that decline as the call's result; a call already approved when Stop lands was declined
|
|
728
|
+
by nobody, so it is answered as not finished instead. An open **question card** (`ask_user`)
|
|
729
|
+
resolves with an empty answer. Reloading the page while a card is open does not decline it,
|
|
730
|
+
because nobody answered: the call comes back not finished; see
|
|
731
|
+
[MPA durability](#mpa-durability-surviving-full-page-reloads).
|
|
732
|
+
- The new `onCancelled()` handler fires instead of `onError()`; `onSettled()` still follows
|
|
733
|
+
(the terminal-rest guarantee), returning the button to **Send**.
|
|
734
|
+
|
|
735
|
+
`cancel()` with no run in flight is a safe no-op. `newChat()` cancels any in-flight run before
|
|
736
|
+
discarding the client, and so do switching conversations, `reload()` and removing the element. A
|
|
737
|
+
[checkpoint continuation](#resuming-a-run) is the run in flight while it lasts, so Stop and all of
|
|
738
|
+
these end it too. So does a turn that has not made its request yet: called from host code a send
|
|
739
|
+
or a resume runs on the way — the conversation store's save, `getContext`, `getTools` —
|
|
740
|
+
`cancel()` stops it before anything is sent, and `onCancelled()` and `onSettled()` fire as for any
|
|
741
|
+
other Stop.
|
|
742
|
+
|
|
743
|
+
A cancelled run ends a moment later, once its request has closed or a running tool handler has
|
|
744
|
+
returned. What it does then stays with the conversation it belonged to: its truncated exchange is
|
|
745
|
+
saved to that thread, `ag-ui-run-finished` still reports the tools it ran, and nothing reaches the
|
|
746
|
+
transcript or the Stop button of the conversation that replaced it.
|
|
747
|
+
|
|
748
|
+
### Registering tools
|
|
749
|
+
|
|
750
|
+
A tool is a `ClientTool`: `{ name, description, parameters, handler }`, where `parameters` is a
|
|
751
|
+
**JSON Schema** and `handler` receives the parsed args and returns a value that is JSON-serialised
|
|
752
|
+
into the tool-result message. Register them on the element:
|
|
753
|
+
|
|
754
|
+
```js
|
|
755
|
+
chat.registerTool({
|
|
756
|
+
name: "search_products",
|
|
757
|
+
description: "Search the catalog.",
|
|
758
|
+
parameters: {
|
|
759
|
+
type: "object",
|
|
760
|
+
properties: { query: { type: "string" } },
|
|
761
|
+
required: ["query"],
|
|
762
|
+
},
|
|
763
|
+
handler: async ({ query }) => await api.search(query),
|
|
764
|
+
});
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
Registering a name twice replaces the earlier handler rather than throwing, so a re-fired
|
|
768
|
+
host ref or React StrictMode's double-invoke is harmless -- but two different tools sharing
|
|
769
|
+
a name means the second silently wins. Each `<ag-ui-chat>` element owns its own
|
|
770
|
+
registry, AG-UI client, and Shadow DOM, so **multiple instances on one page never interfere**.
|
|
771
|
+
The one record they share is which storage namespace each has claimed, and it exists to keep two
|
|
772
|
+
of them out of each other's conversation.
|
|
773
|
+
|
|
774
|
+
**A handler's thrown message leaves the browser.** If a handler rejects, its `Error.message` is
|
|
775
|
+
posted back as that call's tool result: into the conversation, on to the AG-UI endpoint, persisted
|
|
776
|
+
there, and replayed to the model provider on every later round. That is deliberate — a real reason
|
|
777
|
+
is what lets the agent recover — but it means an internal hostname, a signed URL or a
|
|
778
|
+
stack-derived path in a rethrown error is disclosed to parties you never chose. Throw the message
|
|
779
|
+
you would be content for the model to read, and log the detail instead.
|
|
780
|
+
|
|
781
|
+
### Inline confirmation (`x-destructive` / `x-confirm` / `confirmPredicate`)
|
|
782
|
+
|
|
783
|
+
When a tool call needs confirmation, the element appends an **inline confirmation card** (a
|
|
784
|
+
`<div class="confirm">`) to the transcript via
|
|
785
|
+
[`requestConfirmation`](src/ui/confirmation_card.ts) — it is not a modal overlay. The card reads
|
|
786
|
+
naturally after the assistant's explanation, never steals focus from the page, and stays in the
|
|
787
|
+
transcript as a resolved record after the decision:
|
|
788
|
+
|
|
789
|
+
- **Confirm** → the handler runs and the result is posted back.
|
|
790
|
+
- **Cancel** → a `"User declined the action."` result is posted; the agent acknowledges on its
|
|
791
|
+
next turn.
|
|
792
|
+
- **Always allow** → the handler runs *and* this tool stops prompting for the rest of the
|
|
793
|
+
session. See below for when this button appears.
|
|
794
|
+
|
|
795
|
+
Whether a call is gated is decided in this order:
|
|
796
|
+
|
|
797
|
+
1. If `chat.autoConfirm === true`, the call **never** prompts (an "autopilot" toggle).
|
|
798
|
+
2. Else if `chat.confirmPredicate` is set, its boolean return is authoritative — given the tool
|
|
799
|
+
name + parsed args it decides per-call (so one tool can be instant for some args and confirmed
|
|
800
|
+
for others, which a static flag can't express). **A predicate that throws or rejects refuses
|
|
801
|
+
the call**: no card, the handler does not run, the tool card settles as declined, and the agent
|
|
802
|
+
gets the `confirmCheckFailed` string as the result and carries on, as after a decline. The
|
|
803
|
+
error goes to the console, never to the endpoint. A predicate still pending when the run is
|
|
804
|
+
stopped decides nothing: the call does not run, and its card settles as not finished.
|
|
805
|
+
3. Else if the user has waived this tool name for the session, the call runs.
|
|
806
|
+
4. Else the element falls back to [`isDestructive(parameters)`](src/tools/is_destructive.ts),
|
|
807
|
+
which reads the `x-destructive` JSON-Schema flag.
|
|
808
|
+
|
|
809
|
+
#### "Always allow", and why only sometimes
|
|
810
|
+
|
|
811
|
+
A prompt that is approved nearly every time is not a decision, it is a speed bump — and the
|
|
812
|
+
reflex it trains is what makes the rare refusal easy to miss. Anthropic published that users
|
|
813
|
+
approve **~93%** of Claude Code permission prompts manually and called interactive confirmation
|
|
814
|
+
*"behaviorally unreliable as a sole safety mechanism"* on that basis. The waiver exists so the
|
|
815
|
+
prompts that remain still mean something.
|
|
816
|
+
|
|
817
|
+
**The button is offered only on cards raised by step 4** — the `x-destructive` default. Where
|
|
818
|
+
`confirmPredicate` is what gated the call, there is no button, because that predicate is
|
|
819
|
+
documented as authoritative and letting one click retire it would silently defeat a host policy.
|
|
820
|
+
The offer and the allowlist sit on the same path, so there is no dead button either.
|
|
821
|
+
|
|
822
|
+
The waiver is **per tool name and per element**, held in memory and never persisted. A session
|
|
823
|
+
decision that outlived the tab would be a permanent grant made by one click — which is what
|
|
824
|
+
`autoConfirm` already exists to say deliberately. It is cleared when the element goes away, and
|
|
825
|
+
when [`user-key`](#who-the-stored-conversation-belongs-to-user-key) changes hands: one person's
|
|
826
|
+
*Always allow* is not the next person's, and a sign-out in the same tab does not remount anything.
|
|
827
|
+
The first key to arrive keeps it, for the same reason it keeps the conversation.
|
|
828
|
+
|
|
829
|
+
AG-UI has no built-in risk flag, so destructiveness is carried as a JSON-Schema extension at the
|
|
830
|
+
**schema root**: `parameters["x-destructive"] = true` (use the exported `X_DESTRUCTIVE_KEY`
|
|
831
|
+
constant). There is no parallel metadata channel and no name heuristic — destructiveness is exactly
|
|
832
|
+
the `x-destructive` flag (or `confirmPredicate`). The registry forwards the flag verbatim to
|
|
833
|
+
`RunAgentInput.tools`.
|
|
834
|
+
|
|
835
|
+
If the schema carries an `x-confirm` string (use `X_CONFIRM_KEY`), the card shows it as the prompt;
|
|
836
|
+
otherwise it falls back to a generic `Run "<tool>"?`.
|
|
837
|
+
|
|
838
|
+
**This gate covers frontend tools only.** A server-side tool's schema never reaches the browser —
|
|
839
|
+
tool definitions travel client-to-server on `RunAgentInput.tools`, and the only channel coming back
|
|
840
|
+
is the label catalog (`data-tools-url`), which carries `{ name, summary, description? }` and no
|
|
841
|
+
flags. So marking a server tool destructive does not produce a card here; gate it server-side
|
|
842
|
+
instead (see [Server-side tool approval](#server-side-tool-approval-interrupts)), which surfaces as
|
|
843
|
+
an approval card in the same transcript.
|
|
844
|
+
|
|
845
|
+
```js
|
|
846
|
+
// Per-call: confirm a delete only when it would remove more than one row.
|
|
847
|
+
chat.confirmPredicate = (name, args) =>
|
|
848
|
+
name === "delete_rows" && Array.isArray(args.ids) && args.ids.length > 1;
|
|
849
|
+
|
|
850
|
+
// Prompt text via x-confirm:
|
|
851
|
+
chat.registerTool({
|
|
852
|
+
name: "activate_project",
|
|
853
|
+
description: "Activate the current project.",
|
|
854
|
+
parameters: { type: "object", properties: {}, [X_DESTRUCTIVE_KEY]: true, [X_CONFIRM_KEY]: "Activate this project?" },
|
|
855
|
+
handler: async () => await api.activate(),
|
|
856
|
+
});
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
The confirmation card gates **client-registered** tools *before* they run. A **server-side**
|
|
860
|
+
tool runs on the server, so the browser can't intercept it the same way — that is what the
|
|
861
|
+
approval card below is for.
|
|
862
|
+
|
|
863
|
+
### Server-side tool approval (interrupts)
|
|
864
|
+
|
|
865
|
+
When the server gates a destructive tool (e.g. django-ag-ui's `ToolGuard`), the tool **defers**
|
|
866
|
+
instead of executing and the run finishes on an AG-UI *interrupt*. The element then renders an
|
|
867
|
+
**inline approval card** (a `<div class="approval">`) via
|
|
868
|
+
[`requestApproval`](src/ui/approval_card.ts) **inside the tool card of the call it gates**:
|
|
869
|
+
|
|
870
|
+
- **Approve** → the run resumes and the server runs the tool; its result streams back into the
|
|
871
|
+
same card.
|
|
872
|
+
- **Deny** → the run resumes carrying a `cancelled` answer, so the model learns the tool was
|
|
873
|
+
declined; the card settles as declined.
|
|
874
|
+
|
|
875
|
+
This uses the AG-UI protocol's own interrupt/resume mechanism (`RunAgentInput.resume[]`) — the
|
|
876
|
+
wire stays vanilla AG-UI. A **Stop** while an approval card is open denies every open card and
|
|
877
|
+
cancels the run. No configuration is needed on the client; the gate is enabled server-side.
|
|
878
|
+
|
|
879
|
+
**A run can defer several calls, and each is answered on its own.** Importing three rows defers
|
|
880
|
+
three `create_event` calls, and the wire takes a different answer for each. So every question is
|
|
881
|
+
asked **at once**, each in its own tool card, above that call's own arguments — which is what says
|
|
882
|
+
*which* call it is about. The prompt cannot: it comes from the tool, so all three read "Add this
|
|
883
|
+
event to the board?". While the run waits, those cards read **`waiting for you`**
|
|
884
|
+
(`data-status="deferred"`), not "running…" — nothing is running, the stream is over and the server
|
|
885
|
+
is idle. A card asking a question shows its arguments in every `data-tool-display` mode, since
|
|
886
|
+
hiding them would hide the answer to "which one is this".
|
|
887
|
+
|
|
888
|
+
A **frontend** tool such as `ask_user` stays at "running…" while its card is open, because the
|
|
889
|
+
browser really is running it. Only a deferred call was claiming something untrue.
|
|
890
|
+
|
|
891
|
+
**What the card asks.** An AG-UI interrupt carries the question as `message`, and the default is the
|
|
892
|
+
call spelled out — `Approve create_event({"title": "Design sync", …})?` — which is accurate and not
|
|
893
|
+
something to put in front of a person. A server can supply its own wording as **`x-confirm` in the
|
|
894
|
+
interrupt's `metadata`**, the same key a client-side confirmation reads off the tool's schema, and
|
|
895
|
+
the card prefers it:
|
|
896
|
+
|
|
897
|
+
```json
|
|
898
|
+
{ "id": "int-1", "reason": "tool_call", "toolCallId": "call-1",
|
|
899
|
+
"message": "Approve create_event({\"title\": \"Design sync\"})?",
|
|
900
|
+
"metadata": { "x-confirm": "Book Design sync on Friday at 14:00?" } }
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
Anything non-string or blank under that key is ignored in favour of `message`, and with neither the
|
|
904
|
+
card falls back to `strings.approvalPrompt`.
|
|
905
|
+
|
|
906
|
+
**The card approves or denies, and nothing else.** The interrupt's `responseSchema` also advertises
|
|
907
|
+
`editedArgs` and `reason` — the protocol allows a client to rewrite a gated call's arguments before
|
|
908
|
+
letting it run. The built-in card does not offer that; a host that wants it can implement
|
|
909
|
+
`approvalRenderer` and resolve the interrupt itself.
|
|
910
|
+
|
|
911
|
+
**A gated write is still a write the page cannot see.** Approving one runs a *server-side* tool, so
|
|
912
|
+
if your page renders the data it touched, listen for
|
|
913
|
+
[`ag-ui-run-finished`](#host-seams-the-spa-story) and refetch.
|
|
914
|
+
|
|
915
|
+
Like the question card, the approval card is customizable at three levels: **text** (`strings`:
|
|
916
|
+
`approveAction` / `approvalPrompt` / `approve` / `deny` / `toolDeferred`), **CSS** (`::part()`:
|
|
917
|
+
`approval`, `approval-body`, `approval-actions`, `approval-button`, `approval-approve`,
|
|
918
|
+
`approval-deny`, and `tool-card-approval` for the region inside the card), and **full replacement**
|
|
919
|
+
via `chat.approvalRenderer` — given the request (`message` + `toolName`) and a Stop `AbortSignal`,
|
|
920
|
+
render your own UI and resolve `true`/`false`. A renderer is called **once per interrupt,
|
|
921
|
+
concurrently**, so a host that can only ask one thing at a time should queue inside it:
|
|
922
|
+
|
|
923
|
+
```js
|
|
924
|
+
chat.approvalRenderer = (request, { signal }) =>
|
|
925
|
+
myConfirmDialog(request.message ?? `Run ${request.toolName}?`, { signal });
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
**A renderer that throws or rejects falls back to the built-in card** for that interrupt, with a
|
|
929
|
+
`console.warn` naming it. The renderer decides how the question looks, not whether it is asked, so
|
|
930
|
+
nothing runs without a click and the run carries on as if no renderer were set. The one exception
|
|
931
|
+
is a rejection after the signal fired, which is how a renderer is expected to honour Stop: that
|
|
932
|
+
wait resolves as denied, as the built-in card does on the same signal, with no card and no warning.
|
|
933
|
+
|
|
934
|
+
### Asking the user a question (`ask_user`)
|
|
935
|
+
|
|
936
|
+
Set `chat.askUser = true` to offer the agent a built-in `ask_user` frontend tool. When the agent
|
|
937
|
+
calls it, the element renders an **inline question card** (a `<div class="question">`) via
|
|
938
|
+
[`requestQuestion`](src/ui/question_card.ts) and returns the user's answer as the tool result:
|
|
939
|
+
|
|
940
|
+
```js
|
|
941
|
+
chat.askUser = true; // opt in; off by default so the tool catalog is unchanged otherwise
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
`ask_user(question, options?, allow_custom?)` renders `options` as radio buttons, adds a free-text
|
|
945
|
+
field when `allow_custom` is set (or when no options are given), and feeds the chosen or typed
|
|
946
|
+
answer back through the normal frontend-tool path — no new protocol. A **Stop** dismisses an open
|
|
947
|
+
question with an empty answer.
|
|
948
|
+
|
|
949
|
+
The question card is **fully customizable** at three levels:
|
|
950
|
+
|
|
951
|
+
- **Text** — every label is a `strings` key: `askUserAction` (the card's `aria-label`),
|
|
952
|
+
`otherOption`, `answerPlaceholder`, `submit`.
|
|
953
|
+
- **CSS** — every element exposes a `::part()`: `question`, `question-body`, `question-options`,
|
|
954
|
+
`question-choice`, `question-radio`, `question-input`, `question-actions`, `question-button`
|
|
955
|
+
(plus the `--ag-ui-*` theme variables). No shadow piercing.
|
|
956
|
+
- **Full replacement** — set `chat.askUserRenderer` to own the entire UI. Given the parsed request
|
|
957
|
+
and an `AbortSignal` (fired on Stop), render anything — a native modal, a framework component —
|
|
958
|
+
and resolve with the answer (empty string = no answer). The built-in card is bypassed entirely.
|
|
959
|
+
|
|
960
|
+
```js
|
|
961
|
+
// Level 1+2: restyle the built-in card.
|
|
962
|
+
chat.strings = { submit: "Answer", answerPlaceholder: "Type here…" };
|
|
963
|
+
// ag-ui-chat::part(question) { border-radius: 0; }
|
|
964
|
+
|
|
965
|
+
// Level 3: replace the card with your own UI.
|
|
966
|
+
chat.askUserRenderer = (request, { signal }) =>
|
|
967
|
+
myModal.ask(request.question, request.options, { allowCustom: request.allowCustom, signal });
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
**A renderer that throws or rejects falls back to the built-in card** for that call, with a
|
|
971
|
+
`console.warn` naming the tool call. As with `approvalRenderer`, the renderer decides how the
|
|
972
|
+
question looks, not whether it is asked, so the run carries on as if no renderer were set. The one
|
|
973
|
+
exception is a rejection after the signal fired, which is how a renderer is expected to honour Stop:
|
|
974
|
+
that wait resolves with an empty answer, as the built-in card does on the same signal, with no card
|
|
975
|
+
and no warning.
|
|
976
|
+
|
|
977
|
+
### DOM-driver and animation primitives
|
|
978
|
+
|
|
979
|
+
So the agent can visibly drive the host page, the package ships generic, framework-free
|
|
980
|
+
primitives. The **animation** primitives ([`animations.ts`](src/dom/animations.ts)) operate at
|
|
981
|
+
human-readable speed (configurable; pass small/zero durations in tests):
|
|
982
|
+
|
|
983
|
+
- `typeInto(el, value, { charDelayMs })` — clears and types a value character by character,
|
|
984
|
+
firing `input`/`change` events as a real user would.
|
|
985
|
+
- `highlightThenClick(el, { highlightMs })` / `pressThenClick(el, options)` — outline/press an
|
|
986
|
+
element, pause, then click.
|
|
987
|
+
- `selectOption(el, value)` / `toggleControl(el, checked)` — animate a `<select>` / checkbox.
|
|
988
|
+
- `scrollIntoCenterView(el, { settleMs })` — scrolls the element to the vertical centre and
|
|
989
|
+
resolves once the scroll has **settled**, so the ring that follows is drawn where the user is
|
|
990
|
+
looking rather than mid-glide. Awaiting is optional; the scroll is requested synchronously
|
|
991
|
+
either way. Settlement is `scrollend` where the browser has it, a short probe when nothing
|
|
992
|
+
actually moved (the element was already in view), and a 600 ms cap otherwise.
|
|
993
|
+
- `flash(el, { flashMs, color })` / `focusWithFlash(el, { flashMs, color, focus })` — ring the
|
|
994
|
+
element so the user can find it. The ring is an `outline`, not a `box-shadow`, because a shadow
|
|
995
|
+
paints outside the border box and any `overflow: hidden` ancestor sharing the element's box (a
|
|
996
|
+
card, a table cell) clips it away entirely. It holds for **1200 ms** by default and fades out
|
|
997
|
+
over the last third — a 200 ms blink is not long enough to be *found* by someone who does not
|
|
998
|
+
yet know where to look. The colour is `color` when passed, else — see
|
|
999
|
+
[Ring colour](#ring-colour) — the widget's own accent.
|
|
1000
|
+
`flash` leaves focus alone; `focusWithFlash` moves it — see "Flash versus focus" below.
|
|
1001
|
+
- `prefersReducedMotion()` — honoured by every primitive that *moves* something: the hold delays
|
|
1002
|
+
in `pressThenClick` / `selectOption` / `toggleControl` collapse to instant, `scrollIntoCenterView`
|
|
1003
|
+
jumps instead of gliding and settles immediately, and the flash drops its fade while still
|
|
1004
|
+
holding the ring for its full duration. Reduced motion asks for no animation, not for no
|
|
1005
|
+
feedback. `typeInto` and `highlightThenClick` are the exceptions: they keep their
|
|
1006
|
+
explicit-duration contract, so pass `charDelayMs: 0` / `highlightMs: 0` yourself if you want
|
|
1007
|
+
them instant.
|
|
1008
|
+
|
|
1009
|
+
The **DOM-driver** primitives ([`dom_driver.ts`](src/dom/dom_driver.ts)) compose those into the
|
|
1010
|
+
operations a tool handler typically wants:
|
|
1011
|
+
|
|
1012
|
+
- `fillField(el, value, options)` — scroll to, focus-flash, and type into a text field. The
|
|
1013
|
+
flash defaults to `flashMs: 0` here: the field is about to be typed into, which is its own
|
|
1014
|
+
highlight. Pass `flashMs` (and optionally `color`) to ring it first.
|
|
1015
|
+
- `clickElement(el, options)` / `pressButton(el, options)` — scroll to, highlight/press, and click.
|
|
1016
|
+
- `showHighlightOverlay(el, options)` — ring an element from an **overlay drawn outside it**, and
|
|
1017
|
+
optionally dim everything else (`scrim`) or flow a gradient round it (`gradient`). Returns a
|
|
1018
|
+
function that removes it. `flash` and `focusWithFlash` take `scrim` and `gradient` too and route
|
|
1019
|
+
through this when either is asked for.
|
|
1020
|
+
|
|
1021
|
+
**Why a second mechanism.** The plain ring is an `outline` on the element, which is deliberate:
|
|
1022
|
+
a `box-shadow` paints outside the border box, so an `overflow: hidden` ancestor sharing the
|
|
1023
|
+
target's box clips the whole ring away while the helper still reports success. But an outline
|
|
1024
|
+
takes a *colour* — there is no `outline-image` — so a gradient cannot be one, and anything else
|
|
1025
|
+
that can be is a property of the target and lands back inside whatever is clipping it. Dimming
|
|
1026
|
+
everything else needs a surface larger than the target, which is the same problem from the other
|
|
1027
|
+
side. So they are one overlay rather than two features.
|
|
1028
|
+
|
|
1029
|
+
**It is inert.** The overlay never takes a pointer event, at the cut-out or anywhere else — a dim
|
|
1030
|
+
that swallows clicks is a modal the user did not open, and `highlightThenClick` has to reach the
|
|
1031
|
+
control it just finished pointing at. It follows the target on scroll and resize, and under
|
|
1032
|
+
reduced motion the gradient is drawn but does not travel.
|
|
1033
|
+
|
|
1034
|
+
**Themed from the element you point at, not from the widget.** The overlay is appended to the
|
|
1035
|
+
document body so it can escape the clipping it exists to avoid, which means a `var()` in its own
|
|
1036
|
+
style would resolve against the body — so every token is read from the *target's* computed style
|
|
1037
|
+
instead. Set them wherever they inherit to the elements the agent touches, usually `:root`. The
|
|
1038
|
+
ring colour is the one exception, read from the widget as well; see
|
|
1039
|
+
[Ring colour](#ring-colour).
|
|
1040
|
+
|
|
1041
|
+
```css
|
|
1042
|
+
:root {
|
|
1043
|
+
--ag-ui-highlight-ring-color: #0f766e; /* also read from ag-ui-chat; see Ring colour */
|
|
1044
|
+
--ag-ui-highlight-scrim: rgba(15, 15, 25, 0.45);
|
|
1045
|
+
--ag-ui-highlight-gradient: linear-gradient(115deg, transparent 20%, #4f46e5 50%, transparent 80%);
|
|
1046
|
+
--ag-ui-highlight-ring-width: 3; /* unitless; px */
|
|
1047
|
+
--ag-ui-highlight-flow-ms: 2400; /* one pass of the gradient */
|
|
1048
|
+
--ag-ui-highlight-z-index: 2147483001;
|
|
1049
|
+
}
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
`ringWidth` and `flowMs` options override the tokens per call; `color`, `padding` and `radius`
|
|
1053
|
+
have no token because they are per-target rather than per-theme. The overlay's own styles are
|
|
1054
|
+
inline and it lives in the light DOM, so neither a stylesheet rule nor `::part` can reach it —
|
|
1055
|
+
these tokens and options are the whole surface, which is why they cover every value it draws.
|
|
1056
|
+
- `selectControl(el, value)` / `toggleCheckbox(el, checked)` — animate a `<select>` / checkbox.
|
|
1057
|
+
- `setControlValue(el, value)` — set a `<select>` or checkbox without animation, dispatching
|
|
1058
|
+
`input`/`change`.
|
|
1059
|
+
|
|
1060
|
+
Every driver primitive **awaits the scroll** before it animates. A smooth scroll is not awaitable
|
|
1061
|
+
on its own, so a highlight fired straight after `scrollIntoView` could be applied and removed
|
|
1062
|
+
while the element was still travelling — visible to nobody. Budget up to ~600 ms of settle time
|
|
1063
|
+
per action in a browser without `scrollend`; an element already in view costs ~100 ms.
|
|
1064
|
+
|
|
1065
|
+
**Flash versus focus.** `focusWithFlash` does what its name says: it moves keyboard focus. That is
|
|
1066
|
+
rarely what you want just to *point at* something — it takes focus off the composer, can fire blur
|
|
1067
|
+
validation on whatever the user was mid-edit in, and can close an open menu. Reach for `flash(el)`
|
|
1068
|
+
to highlight, and keep `focusWithFlash(el)` for the case where the agent is about to type. Either
|
|
1069
|
+
way you can be explicit with `focus`:
|
|
1070
|
+
|
|
1071
|
+
```js
|
|
1072
|
+
await flash(el); // highlight, focus untouched
|
|
1073
|
+
await focusWithFlash(el); // highlight and take focus
|
|
1074
|
+
await focusWithFlash(el, { focus: false }); // same as flash(el)
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
`focusWithFlash` focuses with `preventScroll: true`, so it cannot fight a smooth scroll that is
|
|
1078
|
+
still in flight.
|
|
1079
|
+
|
|
1080
|
+
The native-setter helpers ([`native_setter.ts`](src/dom/native_setter.ts)) — `setNativeValue` /
|
|
1081
|
+
`setNativeChecked` — set a control through its native prototype setter so React-controlled inputs
|
|
1082
|
+
register the change.
|
|
1083
|
+
|
|
1084
|
+
Each takes an element the caller has already located; host packages wrap them with
|
|
1085
|
+
environment-aware lookups (e.g. "find `#id_<name>`, then `fillField`").
|
|
1086
|
+
|
|
1087
|
+
#### Ring colour
|
|
1088
|
+
|
|
1089
|
+
Every ring the driver draws — the flash outline, the overlay ring, the press and toggle halo — is
|
|
1090
|
+
drawn in the host's page, outside the widget, so a token set on `ag-ui-chat` never reaches it
|
|
1091
|
+
through the cascade. The driver reads the widget directly instead, so on a page that themes
|
|
1092
|
+
nothing the rings follow the widget's theme: Quiet's near-black, Soft's violet, the dark half in a
|
|
1093
|
+
dark scheme. When a call passes no `color`, the first of these that is set wins:
|
|
1094
|
+
|
|
1095
|
+
1. `--ag-ui-highlight-ring-color` on the target (so `:root` reaches every ring);
|
|
1096
|
+
2. `--ag-ui-highlight-ring-color` on the chat — on `ag-ui-chat` or anything above it;
|
|
1097
|
+
3. the target's `--ag-ui-accent`, so a page that themes its accent keeps the rings it has;
|
|
1098
|
+
4. the chat's resolved accent, its theme's default included, held to 3:1 against the page (below);
|
|
1099
|
+
5. the package indigo, when no chat is on the page at all.
|
|
1100
|
+
|
|
1101
|
+
The chat's own accent follows the widget's scheme, not the page's, so a dark widget's near-white
|
|
1102
|
+
accent would all but vanish on a light page. When the ring falls to step 4 it is measured against
|
|
1103
|
+
**the surface behind the target**: the background of the target's parent, and of each element
|
|
1104
|
+
above it until one is opaque, composited over the page canvas (which follows the root's
|
|
1105
|
+
`color-scheme`). The target's own fill is not that surface, because the ring is drawn outside its
|
|
1106
|
+
box: a white field on a dark page is ringed on the dark page. Background images are not read. The
|
|
1107
|
+
ring takes the first of these that holds 3:1 there (WCAG 2.4.13): the accent as the chat resolves
|
|
1108
|
+
it, the other half of the chat's `light-dark()` pair, or the accent stepped toward black or white.
|
|
1109
|
+
Steps 1 to 3 are a host's own choice and are drawn as given, contrast or not.
|
|
1110
|
+
|
|
1111
|
+
```css
|
|
1112
|
+
/* Rings in the brand colour, whatever the widget's accent. */
|
|
1113
|
+
ag-ui-chat { --ag-ui-highlight-ring-color: #0f766e; }
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
The halo of `pressThenClick` and `toggleControl` is the chosen colour at 40%. "The chat" is the
|
|
1117
|
+
most recently connected `<ag-ui-chat>`; on a page with two, pass the one acting as the `chat`
|
|
1118
|
+
option (`RingOwnerOptions`, which every ring-drawing primitive and `showHighlightOverlay` take).
|
|
1119
|
+
|
|
1120
|
+
### Page-action tools
|
|
1121
|
+
|
|
1122
|
+
Two built-in client tools let the agent perform common page interactions without every host
|
|
1123
|
+
re-implementing them. They are **opt-in** via `data-page-actions` — a comma list of the tokens you
|
|
1124
|
+
want — so you control the agent's interaction surface:
|
|
1125
|
+
|
|
1126
|
+
```html
|
|
1127
|
+
<ag-ui-chat endpoint="/agent/" data-page-actions="scroll,drag"></ag-ui-chat>
|
|
1128
|
+
```
|
|
1129
|
+
|
|
1130
|
+
- **`scroll_to`** — scroll a target into view. `target` is `"top"`, `"bottom"`, or a CSS selector
|
|
1131
|
+
/ page-map element id. Read-only (no confirmation). It centres the target **vertically** and
|
|
1132
|
+
brings it into view **horizontally** (`inline: "nearest"`), so on a two-axis surface a
|
|
1133
|
+
horizontal target lands at the near edge rather than in the middle. In view is the contract;
|
|
1134
|
+
"centred" is not, in that axis.
|
|
1135
|
+
- **`drag_and_drop`** — drag the `from` element onto the `to` element (selectors / page-map ids),
|
|
1136
|
+
firing the standard HTML5 drag sequence (`dragstart` → `dragenter`/`dragover`/`drop` → `dragend`)
|
|
1137
|
+
so the page's own drop handler reacts. Useful for reordering sortable lists.
|
|
1138
|
+
- **`chat`** — four tools that let the agent move the panel it is speaking from:
|
|
1139
|
+
`read_chat_surface`, `move_chat`, `minimise_chat`, `restore_chat`.
|
|
1140
|
+
|
|
1141
|
+
This is the one nobody else can offer. Every other assistant's chat is a surface of its own, so
|
|
1142
|
+
there is nothing for it to be in the way *of*; ours is mounted in the page the user is working
|
|
1143
|
+
in, which makes "let me move this aside so you can see the table" something the agent can act on
|
|
1144
|
+
rather than apologise for.
|
|
1145
|
+
|
|
1146
|
+
**They report what happened, not what was asked.** A panel that fills the screen has nowhere to
|
|
1147
|
+
move to, and a placement that places itself owns its position — so `move_chat` answers
|
|
1148
|
+
`moved: false` with the reason and what would work instead, rather than reporting success on a
|
|
1149
|
+
panel that did not budge. `read_chat_surface` is there so the agent can ask before it acts
|
|
1150
|
+
instead of learning through a failure; it is read-only.
|
|
1151
|
+
|
|
1152
|
+
None of them is stamped `x-destructive`. Moving a window destroys nothing, and a confirmation
|
|
1153
|
+
card in front of it would be worse than the move.
|
|
1154
|
+
|
|
1155
|
+
**Your drag surface must listen to drag events, and many "modern" ones do not.** `drag_and_drop`
|
|
1156
|
+
dispatches the native HTML5 sequence with one shared `DataTransfer`. A surface built on a
|
|
1157
|
+
pointer-event drag library — dnd-kit, most React DnD packages, the Angular CDK — listens to
|
|
1158
|
+
`pointerdown`/`pointermove` and **never sees any of it**: the agent's drag is a silent no-op that
|
|
1159
|
+
still reports success. Either use the native API or pick a library that listens to drag events.
|
|
1160
|
+
React's synthetic `onDrop` does receive the dispatched sequence, `DataTransfer` included.
|
|
1161
|
+
|
|
1162
|
+
**A page action reports that it fired, not that it worked.** `drag_and_drop` returns as soon as the
|
|
1163
|
+
sequence is dispatched; whether your drop handler's save succeeded is invisible to it, so a refused
|
|
1164
|
+
change still looks like a successful tool call. Two things follow. Have the page report its own
|
|
1165
|
+
refusals somewhere the agent can read them, and have the agent re-read the page before claiming
|
|
1166
|
+
anything. Where the outcome matters more than the gesture, call the operation as a **server tool**
|
|
1167
|
+
instead — it can return the real error.
|
|
1168
|
+
|
|
1169
|
+
**A page that saves asynchronously should say so.** A verification read straight after a drag can
|
|
1170
|
+
outrun the page's own save and conclude that nothing happened. Report a busy flag in your
|
|
1171
|
+
`getPageMap` (`{ saving: true }` while a write is in flight) and the agent can wait for a page that
|
|
1172
|
+
says it is busy. It cannot wait for one that does not.
|
|
1173
|
+
|
|
1174
|
+
Targets resolve through the overridable `resolvePageTarget` property — `(target) => HTMLElement |
|
|
1175
|
+
null`, defaulting to `document.querySelector`. A host with a page map overrides it to map its own
|
|
1176
|
+
element ids (the same way the DOM-driver primitives are wrapped with environment-aware lookups):
|
|
1177
|
+
|
|
1178
|
+
```js
|
|
1179
|
+
chat.resolvePageTarget = (id) => myPageMap.elementFor(id);
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
**Destructiveness.** Page actions are *not* stamped `x-destructive` — a drag rearranges transient
|
|
1183
|
+
state, and the durable change happens at the page's explicit commit (a Save), which stays in the
|
|
1184
|
+
user's hands. If your page persists *on drop* (a kanban board firing a PATCH from the drop
|
|
1185
|
+
handler), gate `drag_and_drop` with [`confirmPredicate`](#inline-confirmation-x-destructive--x-confirm--confirmpredicate)
|
|
1186
|
+
— or don't enable it. A target that resolves to nothing returns a clean, model-readable tool error.
|
|
1187
|
+
|
|
1188
|
+
---
|
|
1189
|
+
|
|
1190
|
+
## New chat and collapse
|
|
1191
|
+
|
|
1192
|
+
The header carries built-in buttons for a new chat (a pencil on a square), maximize (two arrows pointing out) and
|
|
1193
|
+
collapse (a chevron down, or on a sidebar a panel closing into its edge; `data-collapse-icon`
|
|
1194
|
+
chooses it). The
|
|
1195
|
+
matching JS API:
|
|
1196
|
+
|
|
1197
|
+
- `newChat()` — clears the transcript, drops the in-memory run state, and mints a new thread id.
|
|
1198
|
+
The conversation it leaves is **kept**: it stays in the history drawer to return to, and on a
|
|
1199
|
+
server-backed store it stays on the server. Deleting one is the drawer row's own action. A chat
|
|
1200
|
+
nothing was ever sent in is the exception — it was never listed, so it is dropped rather than
|
|
1201
|
+
left behind. Focus moves to the composer, whichever control started the new chat, unless the
|
|
1202
|
+
widget is collapsed, and the page is not scrolled to it. A restore still loading or replaying
|
|
1203
|
+
the conversation being left stops there, so nothing of it is drawn into the new chat, and a
|
|
1204
|
+
run it was about to resume after a reload is not resumed: coming back to that conversation
|
|
1205
|
+
later answers the interrupted call as not finished. Called before the element connects (or while
|
|
1206
|
+
it is out of the document), it waits: `threadId` stays `null` and nothing fires until the
|
|
1207
|
+
element connects, which then starts the new chat, so the one `ag-ui-thread` event is a `new`.
|
|
1208
|
+
- `describeSurface()` — where the panel is and what can be done to it: placement, collapsed,
|
|
1209
|
+
`maximized` (read-only), what it collapses to (`collapseTo`), whether it can be moved, whether it fills the screen, its box and the viewport. `movable` folds
|
|
1210
|
+
the two reasons a move can fail into the one answer a caller needs.
|
|
1211
|
+
- `moveTo(corner)` — send the panel to `top-left` / `top-right` / `bottom-left` / `bottom-right`,
|
|
1212
|
+
returning whether it went. It takes the axes the same way a user drag does, so the launcher
|
|
1213
|
+
travels with it and switching placement hands them back. Returns `false` rather than pretending
|
|
1214
|
+
when the placement owns its position or the panel fills the screen. Under `collapse-to="bar"`
|
|
1215
|
+
or `"none"` it moves the panel within the room above the bar or the anchor.
|
|
1216
|
+
- `resetSize()` — drop the size the user dragged in the current placement, and its stored copy,
|
|
1217
|
+
so the placement's own size and your `--ag-ui-width` / `--ag-ui-height` apply again; sizes
|
|
1218
|
+
dragged in other placements are kept. For a host that enters a sized state of its own (an
|
|
1219
|
+
expanded mode) instead of the built-in [maximize](#maximizing-the-panel): a user's drag
|
|
1220
|
+
outranks host tokens, so call this first.
|
|
1221
|
+
Under `collapse-to="bar"` or `"none"` it also returns a panel the user dragged off its dock to
|
|
1222
|
+
the dock and forgets the stored spot; a corner panel under `auto` keeps its dragged position.
|
|
1223
|
+
- `setCollapsed(collapsed)` / `toggleCollapsed()` — collapse or expand the widget. The state is
|
|
1224
|
+
reflected as the boolean `collapsed` attribute/property and persisted per-tab in
|
|
1225
|
+
`sessionStorage`, so it survives a reload.
|
|
1226
|
+
|
|
1227
|
+
Each change emits an `ag-ui-toggle` event (the `TOGGLE_EVENT` constant) with
|
|
1228
|
+
`detail: { collapsed: boolean }` (typed `ToggleDetail`), so a host can mirror the state in its own
|
|
1229
|
+
chrome — or hide the built-in toggle and drive the `collapsed` attribute itself.
|
|
1230
|
+
|
|
1231
|
+
```js
|
|
1232
|
+
chat.newChat();
|
|
1233
|
+
chat.toggleCollapsed();
|
|
1234
|
+
chat.addEventListener("ag-ui-toggle", (e) => console.log(e.detail.collapsed));
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
### Maximizing the panel
|
|
1238
|
+
|
|
1239
|
+
The maximize control (two arrows pointing out, `maximize-button` part, `icon-maximize` slot) grows the open panel,
|
|
1240
|
+
and pressed again it restores it (the arrows point in). It is labelled "Maximize" and "Restore" (the `maximize`
|
|
1241
|
+
and `restore` strings), apart from the launcher's and the rail's "Expand", which opens the panel.
|
|
1242
|
+
|
|
1243
|
+
- **Where it is offered.** A corner panel (`floating`, `bottom-left`, the default) and a docked
|
|
1244
|
+
one (`collapse-to="bar"` or `"none"`) grow on both axes; `sidebar` and `side` grow in width
|
|
1245
|
+
only. `page`, `full` and `embedded` have no control, and neither does any viewport of 600px or
|
|
1246
|
+
less, `data-small-viewport="off"` included. The control is absent from the header there, not
|
|
1247
|
+
hidden, and the state is refused: a panel maximized when its placement switches to one of
|
|
1248
|
+
these, or when the window narrows to 600px, leaves it.
|
|
1249
|
+
- **What it grows to.** `--ag-ui-maximized-width` and `--ag-ui-maximized-height`, which default
|
|
1250
|
+
to the panel's cap (`--ag-ui-max-width` / `--ag-ui-max-height`, the room the host left less
|
|
1251
|
+
one gutter). A side column reads the width only.
|
|
1252
|
+
- **The drag.** Entering it takes the dragged size off without forgetting it, and the resize
|
|
1253
|
+
grips are hidden while it lasts. Restoring, and collapsing (which leaves the state), put the
|
|
1254
|
+
placement's own size back, or the size the user dragged in it.
|
|
1255
|
+
- **The API.** `chat.maximized` (reflected as the `maximized` attribute), `setMaximized(bool)`
|
|
1256
|
+
and `toggleMaximized()`. Each change emits `ag-ui-maximize` (the `MAXIMIZE_EVENT` constant)
|
|
1257
|
+
with `detail: { maximized }` (typed `MaximizeDetail`). It fires only while connected: a
|
|
1258
|
+
markup `maximized` is announced once at connect, and only if it holds (a panel that mounts
|
|
1259
|
+
collapsed announces nothing), and a change made while detached is announced once on reconnect,
|
|
1260
|
+
only if it differs from what listeners last heard. It is not stored: a reload opens at the
|
|
1261
|
+
placement's own size. `describeSurface()` reports it, and no agent tool changes it.
|
|
1262
|
+
|
|
1263
|
+
```js
|
|
1264
|
+
chat.addEventListener("ag-ui-maximize", (e) => console.log(e.detail.maximized));
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
```css
|
|
1268
|
+
ag-ui-chat { --ag-ui-maximized-width: 960px; --ag-ui-maximized-height: 85vh; }
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
`--ag-ui-min-page-width` keeps that much of the page beside a `sidebar` or `side` column: it caps
|
|
1272
|
+
the column's width at the viewport less it, whether the width was dragged, set by
|
|
1273
|
+
`--ag-ui-width`, or maximized. It is unset by default, which caps nothing.
|
|
1274
|
+
|
|
1275
|
+
```css
|
|
1276
|
+
ag-ui-chat[placement="sidebar"] { --ag-ui-min-page-width: 480px; }
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
A host that wants its own sized state can still build it: hide the control
|
|
1280
|
+
(`ag-ui-chat::part(maximize-button) { display: none }`), slot a button into `header-actions`, set
|
|
1281
|
+
its own `--ag-ui-width` / `--ag-ui-height` in a class, and call `resetSize()` as it enters, so a
|
|
1282
|
+
dragged size does not outrank its tokens.
|
|
1283
|
+
|
|
1284
|
+
### Driving the conversation from your own chrome
|
|
1285
|
+
|
|
1286
|
+
A host that lists conversations in its own bar, rather than in the built-in drawer, drives the
|
|
1287
|
+
one on screen with the drawer's own paths:
|
|
1288
|
+
|
|
1289
|
+
- `switchThread(threadId)` — put a stored thread on screen and replay it, as picking its row in
|
|
1290
|
+
the drawer does. Returns a `Promise<void>` that resolves once the thread is drawn. **A run in
|
|
1291
|
+
flight is cancelled**, as the drawer and `newChat()` cancel it; read `running` first if you
|
|
1292
|
+
would rather wait. Switching to the thread already on screen does nothing, and so does a call
|
|
1293
|
+
before the element connects; a second call for the thread that is still loading settles with
|
|
1294
|
+
that load, after the replay. If the store rejects the load, the element stays on the thread it
|
|
1295
|
+
was on, redraws it, fires no `ag-ui-thread`, and the promise rejects with the store's error.
|
|
1296
|
+
- `deleteThread(threadId)` — delete a stored thread, as the drawer row's delete does. Deleting
|
|
1297
|
+
the thread on screen cancels its run and puts a fresh thread in its place; deleting another
|
|
1298
|
+
leaves the screen alone. Prefer it to `conversationStore.clear()`, which deletes the record and
|
|
1299
|
+
nothing else: the run, the transcript and the drawer are left pointing at it.
|
|
1300
|
+
- `threadId` *(read-only)* — the thread on screen, `null` before the element first connects.
|
|
1301
|
+
It only reads. `conversationStore.threadId()` mints and stores a thread when the store points
|
|
1302
|
+
at none, so asking it which conversation is showing can start one.
|
|
1303
|
+
- `threadTitle` *(read-only)* — the title of the thread on screen, `""` while nothing has been
|
|
1304
|
+
sent in it. A title renamed here, or one the conversation list shows that the messages do not
|
|
1305
|
+
derive (a server's own), wins; otherwise it is the first user message, and a first message that
|
|
1306
|
+
is exactly a skill's `/token` reads as that skill's catalog `title`, else as the token in words
|
|
1307
|
+
(`/summarize_draw` reads "Summarize draw"). It is set on the first send, before the server lists
|
|
1308
|
+
the thread. Every change fires one **`ag-ui-thread-title`** event (`THREAD_TITLE_EVENT`, detail
|
|
1309
|
+
`{ threadId, title }`, typed `ThreadTitleDetail`), after the `ag-ui-thread` event of the change
|
|
1310
|
+
that caused it. `title-text="auto"` draws it without a listener.
|
|
1311
|
+
- `running` *(read-only)* — whether an interaction is in flight: the composer's own state, the
|
|
1312
|
+
one it shows Stop for, across every tool round, a Retry and a checkpoint continuation as well
|
|
1313
|
+
as a send. Reflected on the element as the `data-running` attribute, written by the element
|
|
1314
|
+
rather than by you, as `data-empty` is, so the chrome around the chat can style on it.
|
|
1315
|
+
|
|
1316
|
+
Every change of conversation fires one **`ag-ui-thread`** event (the `THREAD_EVENT` constant),
|
|
1317
|
+
after the transcript has been replaced, so a listener reads the conversation it is told about.
|
|
1318
|
+
`detail` is `{ threadId, previousThreadId, reason }` (typed `ThreadDetail`). `previousThreadId`
|
|
1319
|
+
is the thread the previous event reported, `null` before the first, and `reason` is one of:
|
|
1320
|
+
|
|
1321
|
+
| `reason` | When |
|
|
1322
|
+
| --- | --- |
|
|
1323
|
+
| `new` | `newChat()` or the built-in New chat button. |
|
|
1324
|
+
| `switch` | `switchThread()` or a row picked in the drawer. Two switches made before the first had replayed report only the second. |
|
|
1325
|
+
| `delete` | The thread on screen was deleted, and a fresh one took its place. |
|
|
1326
|
+
| `restore` | The stored thread was replayed: when the element connects (`previousThreadId` is `null` the first time), or by `reload()`. |
|
|
1327
|
+
| `rescope` | A `user-key` change handed the element to another principal, live or while it was out of the document. |
|
|
1328
|
+
|
|
1329
|
+
A page reload keeps restoring the thread, as [MPA durability](#mpa-durability-surviving-full-page-reloads) describes, and
|
|
1330
|
+
reports it as `restore`. Ignore a `reason` you do not know: more may be added.
|
|
1331
|
+
|
|
1332
|
+
```js
|
|
1333
|
+
chat.addEventListener("ag-ui-thread", (e) => myBar.markActive(e.detail.threadId));
|
|
1334
|
+
myBar.onPick = (threadId) => chat.switchThread(threadId);
|
|
1335
|
+
```
|
|
1336
|
+
|
|
1337
|
+
### The composer's own keys
|
|
1338
|
+
|
|
1339
|
+
**Enter during a run queues.** A second run cannot start while one is in flight —
|
|
1340
|
+
it would orphan the first — so that key used to do nothing at all, silently. A
|
|
1341
|
+
checkpoint continuation picked from the panel counts as in flight from the pick
|
|
1342
|
+
rather than from its first event, which is a request later, so a turn typed in
|
|
1343
|
+
that gap is parked too rather than racing it. What
|
|
1344
|
+
is waiting shows above the composer as chips, each of which takes its message
|
|
1345
|
+
back when pressed, and the next one is sent when the run settles. Stopping the
|
|
1346
|
+
run discards them: sending into a conversation someone has just stopped is the
|
|
1347
|
+
opposite of what stopping meant. Nothing is lost, though — a queued message
|
|
1348
|
+
entered the recall history below when it left the composer, so **Up** gets it
|
|
1349
|
+
back after a Stop, and after its chip is taken back.
|
|
1350
|
+
|
|
1351
|
+
The composer also walks back through what you have already sent, on **Up** and
|
|
1352
|
+
**Down** — the shape every shell and every coding agent uses. It holds every turn
|
|
1353
|
+
that left the composer: one sent at once, one queued behind a run (from the
|
|
1354
|
+
moment it is queued), and one sent to resume or fork a run from the checkpoint
|
|
1355
|
+
panel. A message sent with `sendMessage` is not in it, because it was never
|
|
1356
|
+
typed there. Only from an empty composer and only with the skills palette closed:
|
|
1357
|
+
an arrow inside text is how you move the caret, and taking it unconditionally
|
|
1358
|
+
would break editing to add a shortcut. Arrowing forward past the newest turn
|
|
1359
|
+
empties the box again, so the way out is the key that got you in. A walk lasts
|
|
1360
|
+
only while the composer holds the turn it put there: anything else that writes
|
|
1361
|
+
the box — typing, a quotation, a skill, dictation, or the host page itself — ends
|
|
1362
|
+
it, so the next arrow moves the caret in what was written rather than replacing
|
|
1363
|
+
it. The history is this conversation's: starting a new chat, switching threads
|
|
1364
|
+
or changing `user-key` clears it with the transcript.
|
|
1365
|
+
|
|
1366
|
+
### Collapsing to the launcher
|
|
1367
|
+
|
|
1368
|
+
A collapsed widget shrinks to a **floating launcher** in the corner it already occupies: the
|
|
1369
|
+
panel scales down into it and fades, the launcher grows out of the same point, and clicking the
|
|
1370
|
+
launcher reverses it. Only `transform` and `opacity` animate, so the motion is compositor-only and
|
|
1371
|
+
never reflows your page. Two placements collapse to something else instead — `sidebar` slides out
|
|
1372
|
+
to its [edge rail](#sidebar-placement), and `embedded` / `page` keep the header bar, since one is
|
|
1373
|
+
laid out by your page and the other is a full-screen route.
|
|
1374
|
+
|
|
1375
|
+
The launcher's mark comes from the same seam as the header icon, most specific first: a slotted
|
|
1376
|
+
`slot="launcher"` child, then `data-launcher-icon-url`, then `data-icon-url`, then the built-in
|
|
1377
|
+
speech bubble.
|
|
1378
|
+
|
|
1379
|
+
```html
|
|
1380
|
+
<ag-ui-chat endpoint="/agent/" data-launcher-icon-url="/mark.svg"></ag-ui-chat>
|
|
1381
|
+
|
|
1382
|
+
<!-- or any markup at all -->
|
|
1383
|
+
<ag-ui-chat endpoint="/agent/">
|
|
1384
|
+
<svg slot="launcher" width="26" height="26"><!-- ... --></svg>
|
|
1385
|
+
</ag-ui-chat>
|
|
1386
|
+
```
|
|
1387
|
+
|
|
1388
|
+
```css
|
|
1389
|
+
ag-ui-chat {
|
|
1390
|
+
--ag-ui-launcher-size: 48px;
|
|
1391
|
+
--ag-ui-launcher-radius: 14px; /* 50% for a circle */
|
|
1392
|
+
--ag-ui-launcher-bg: #14532d; /* defaults to the panel background */
|
|
1393
|
+
--ag-ui-launcher-fg: #ffffff; /* defaults to the text colour */
|
|
1394
|
+
--ag-ui-launcher-icon-size: 22px;
|
|
1395
|
+
--ag-ui-launcher-inset: auto 0 0 auto; /* which corner of the widget's box */
|
|
1396
|
+
}
|
|
1397
|
+
```
|
|
1398
|
+
|
|
1399
|
+
The sizes and defaults above are Quiet's. The other themes bring their own launcher: `classic`
|
|
1400
|
+
the 56px circle in the header's colours, `soft` a 56px accent tile, and `ledger` a 44px tile in
|
|
1401
|
+
the text colour.
|
|
1402
|
+
|
|
1403
|
+
> **The collapsed host keeps its box.** Animating the element's own width and height would animate
|
|
1404
|
+
> layout; instead the box stays put with `pointer-events: none`, and the launcher takes the clicks.
|
|
1405
|
+
> A host measuring `getBoundingClientRect()` on a collapsed widget still sees the panel's
|
|
1406
|
+
> footprint — nothing there paints or takes input.
|
|
1407
|
+
|
|
1408
|
+
#### The unread badge
|
|
1409
|
+
|
|
1410
|
+
A collapsed widget is the one state where an answer can arrive with nothing on screen to say so,
|
|
1411
|
+
so the launcher carries a count of the answers that finished while it was closed (capped at `9+`).
|
|
1412
|
+
It is the only affordance here that is **on by default**; `data-unread-badge="false"` turns the
|
|
1413
|
+
badge off.
|
|
1414
|
+
|
|
1415
|
+
**Unseen answers are kept per thread.** Each answer counts against the thread it ran on, and a
|
|
1416
|
+
thread is read only when it is on screen **and** the panel is expanded. On screen in a collapsed
|
|
1417
|
+
panel is not read. So an answer that finished on thread A while collapsed stays unread across a
|
|
1418
|
+
collapsed switch to B, a `newChat()`, or an expand on B, and is read once A is on screen in the
|
|
1419
|
+
open panel. `unread` is the total across threads, which is what the badge shows; it shows on the
|
|
1420
|
+
collapsed launcher only, so an open panel whose other threads hold unseen answers shows no
|
|
1421
|
+
badge. A principal change (a new `user-key`) drops the previous principal's marks, and deleting a
|
|
1422
|
+
thread drops its own.
|
|
1423
|
+
|
|
1424
|
+
A store can carry read state across pages and devices, through two optional members. The element
|
|
1425
|
+
fetches the thread list when the drawer opens or a thread switches, not at page load, so a
|
|
1426
|
+
store's `unread` rows are counted from that fetch on: a page that never opens the drawer shows
|
|
1427
|
+
no badge for answers that finished elsewhere.
|
|
1428
|
+
|
|
1429
|
+
- **`ThreadMeta.unread`** (boolean): a thread `listThreads()` reports as unread, and the page has
|
|
1430
|
+
not counted, counts once, when the list is fetched.
|
|
1431
|
+
- **`markRead(threadId)`**: called when the element reads a thread with unseen answers, and when
|
|
1432
|
+
an answer finishes on the thread in view. A server store maps it to its read endpoint, and
|
|
1433
|
+
should list the thread with `unread: false` afterwards.
|
|
1434
|
+
|
|
1435
|
+
A store without either still has its threads counted and cleared in the page. Neither is
|
|
1436
|
+
implemented by the default `SessionStorageStore`.
|
|
1437
|
+
|
|
1438
|
+
The count is also the launcher's accessible name (`Expand — 2 unread`, from the `expandUnread`
|
|
1439
|
+
string), because a coloured dot says nothing to a screen reader.
|
|
1440
|
+
|
|
1441
|
+
#### Moving the launcher
|
|
1442
|
+
|
|
1443
|
+
The collapsed launcher can be dragged anywhere on screen, and it stays there — per tab, in
|
|
1444
|
+
`sessionStorage`, namespaced per element exactly like the collapsed, theme and size preferences.
|
|
1445
|
+
Arrow keys move it from the keyboard (`Shift` for a larger step). `data-launcher-drag="false"`
|
|
1446
|
+
turns it off; so does any placement that already places the launcher itself — `sidebar` collapses
|
|
1447
|
+
it to a full-height rail, `embedded` and `page` hide it and keep their header bar, and a
|
|
1448
|
+
full-bleed panel has no clear space to open into.
|
|
1449
|
+
|
|
1450
|
+
**The panel then opens into whichever side of the launcher has more room.** For each axis the
|
|
1451
|
+
element compares the space a panel would have on either side of the launcher and pins the side
|
|
1452
|
+
with more of it, so a launcher dragged to the top-left opens down and to the right. What it
|
|
1453
|
+
compares is the room the *panel* would get, not which half of the screen the launcher is in —
|
|
1454
|
+
those give different answers either side of centre, and only the first one is about whether the
|
|
1455
|
+
panel fits.
|
|
1456
|
+
|
|
1457
|
+
A launcher parked where the panel fits neither way — the middle of a short viewport — keeps its
|
|
1458
|
+
position anyway: the panel is clamped into the viewport and the launcher carries the difference,
|
|
1459
|
+
which is why it can end up sitting outside its own host box. Nothing clips it there.
|
|
1460
|
+
|
|
1461
|
+
A drag writes `--ag-ui-inset` and `--ag-ui-launcher-inset` on the host, and an inline custom
|
|
1462
|
+
property outranks your stylesheet's rule for the same one — the same trade a dragged size makes
|
|
1463
|
+
against a placement. Switching to a placement that places itself hands both back.
|
|
1464
|
+
|
|
1465
|
+
> **An undragged launcher is untouched.** With nothing stored, the element writes neither
|
|
1466
|
+
> property and your CSS decides, exactly as before. The geometry is built so that feeding it the
|
|
1467
|
+
> resting position reproduces the default `auto 24px 24px auto` unchanged.
|
|
1468
|
+
|
|
1469
|
+
#### Moving the panel
|
|
1470
|
+
|
|
1471
|
+
An open panel moves by its **header**, the way a window moves by its title bar.
|
|
1472
|
+
|
|
1473
|
+
**The launcher travels the same distance.** Drag the panel 100px left and the bubble it collapses
|
|
1474
|
+
into is 100px left of where it was — it is one widget being moved, not two things being placed.
|
|
1475
|
+
The distance is the panel's own, so a panel held against the viewport's margin stops and the
|
|
1476
|
+
bubble stops with it, and the bubble is then held on screen in its own right.
|
|
1477
|
+
|
|
1478
|
+
**A position you state is kept, not re-derived.** That is the difference between the two drags: a
|
|
1479
|
+
launcher drag says where the bubble goes and lets the panel open into whatever room the viewport
|
|
1480
|
+
has, which is re-decided on every expand and every window resize; a header drag states the
|
|
1481
|
+
panel's own position, so it survives collapsing, reopening and reloading. Dragging the bubble
|
|
1482
|
+
again hands the decision back. Both are stored per tab, and `data-launcher-drag="false"` turns
|
|
1483
|
+
off both.
|
|
1484
|
+
|
|
1485
|
+
Which corner the panel is *pinned* by is re-picked when the drag ends, from where the bubble has
|
|
1486
|
+
ended up, so the next expand still opens into clear space. Re-picking it moves nothing: the
|
|
1487
|
+
corner only says which edges the two insets are written from, and both are written from positions
|
|
1488
|
+
that are already decided.
|
|
1489
|
+
|
|
1490
|
+
The controls in the header keep their own presses — a drag started on a button, a link or a field
|
|
1491
|
+
never begins, including one you slot in.
|
|
1492
|
+
|
|
1493
|
+
There is no keyboard shortcut on the header, deliberately. A header is not a control, and making
|
|
1494
|
+
it focusable would put a tab stop with no role ahead of the controls a keyboard user came for —
|
|
1495
|
+
while arrow keys on the collapsed launcher already move the widget, panel included.
|
|
1496
|
+
|
|
1497
|
+
```js
|
|
1498
|
+
chat.unread; // 2
|
|
1499
|
+
|
|
1500
|
+
// Every change, whether or not the badge renders it — so a host that hides the
|
|
1501
|
+
// badge can put the count in its own chrome. `threadIds` names the threads the
|
|
1502
|
+
// change marked or cleared, and is empty for a collapse toggle that touched none.
|
|
1503
|
+
chat.addEventListener("ag-ui-unread", (e) => setDockBadge(e.detail.unread, e.detail.threadIds));
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
```css
|
|
1507
|
+
ag-ui-chat {
|
|
1508
|
+
--ag-ui-badge-bg: #b91c1c; /* defaults to --ag-ui-danger */
|
|
1509
|
+
--ag-ui-badge-fg: #ffffff;
|
|
1510
|
+
--ag-ui-badge-size: 18px;
|
|
1511
|
+
--ag-ui-badge-font-size: 11px;
|
|
1512
|
+
}
|
|
1513
|
+
```
|
|
1514
|
+
|
|
1515
|
+
### Collapsing to a bottom bar
|
|
1516
|
+
|
|
1517
|
+
`collapse-to` chooses what a collapsed panel becomes, for a host whose layout has a bottom
|
|
1518
|
+
toolbar. It is opt-in: absent, `auto`, or any other value leaves every placement as the
|
|
1519
|
+
sections above describe. It is live, like `placement`.
|
|
1520
|
+
|
|
1521
|
+
- **`bar`: the component's own bar.** A strip along the whole bottom edge of the viewport, in
|
|
1522
|
+
place of the round launcher: the title, the [unread badge](#the-unread-badge), and a running
|
|
1523
|
+
indicator while an interaction is in flight. Pressing the strip toggles the panel, which rises
|
|
1524
|
+
from the bar's right end with the bar still beneath it as the minimise target. The bar's
|
|
1525
|
+
history button opens the conversation list as a popover above the bar, without expanding the
|
|
1526
|
+
panel, its outer edge in line with the panel's on a wide viewport, and a second press closes
|
|
1527
|
+
it; `openThreads()` while collapsed opens it too. Expanding the panel closes the popover, so the
|
|
1528
|
+
panel opens on the conversation. It replaces the launcher only, so it applies to the corner
|
|
1529
|
+
placements, `side` and `full`: `sidebar` keeps its rail, `embedded` its header bar, and `page`
|
|
1530
|
+
has no collapsed state at all.
|
|
1531
|
+
- **`none`: your own bar.** Collapsed is hidden: nothing of the widget is left to see or hit,
|
|
1532
|
+
under every collapsible placement. Your bar's button drives the [public
|
|
1533
|
+
API](#driving-the-conversation-from-your-own-chrome) (`collapsed`, `toggleCollapsed()`), and
|
|
1534
|
+
`anchor` names the element the panel rises from, right-aligned to it. An anchor that resolves
|
|
1535
|
+
to nothing opens the panel from the placement's own corner, with one console warning.
|
|
1536
|
+
While collapsed, `openThreads()` opens the conversation list as a popover beside the anchor,
|
|
1537
|
+
or beside the element you pass as `openThreads({ anchor })`, which wins over the attribute and
|
|
1538
|
+
property for that call. It opens below a button in the top half of the viewport and above one
|
|
1539
|
+
in the bottom half, right-aligned to it and held inside the window (full width less 8px on a
|
|
1540
|
+
phone). Picking a conversation switches to it and closes the list, and leaves the panel
|
|
1541
|
+
collapsed: you get `ag-ui-thread` and decide whether to expand. Escape, a press outside,
|
|
1542
|
+
`closeThreads()` and expanding the panel close it; a press on the button itself does not, so
|
|
1543
|
+
your button can toggle the list. With no anchor at all, `openThreads()` warns once per call
|
|
1544
|
+
and draws nothing. `embedded` hides the whole element while collapsed under `none`, so it has
|
|
1545
|
+
no popover.
|
|
1546
|
+
|
|
1547
|
+
```html
|
|
1548
|
+
<nav class="toolbar">
|
|
1549
|
+
<!-- ... -->
|
|
1550
|
+
<button id="agent-button" type="button">Agent</button>
|
|
1551
|
+
</nav>
|
|
1552
|
+
<ag-ui-chat endpoint="/agent/" collapse-to="none" anchor="#agent-button"></ag-ui-chat>
|
|
1553
|
+
<script type="module">
|
|
1554
|
+
const chat = document.querySelector("ag-ui-chat");
|
|
1555
|
+
const button = document.getElementById("agent-button");
|
|
1556
|
+
button.addEventListener("click", () => chat.toggleCollapsed());
|
|
1557
|
+
// Under none the element announces the state it mounted in, once, so the
|
|
1558
|
+
// button can be labelled before anyone presses it.
|
|
1559
|
+
chat.addEventListener("ag-ui-toggle", (e) => {
|
|
1560
|
+
button.setAttribute("aria-pressed", String(!e.detail.collapsed));
|
|
1561
|
+
});
|
|
1562
|
+
</script>
|
|
1563
|
+
```
|
|
1564
|
+
|
|
1565
|
+
**Clearing your toolbar.** Under `none` the panel stands `--ag-ui-anchor-gap` (default `8px`)
|
|
1566
|
+
above the anchor's top edge. The anchor is the button, not the toolbar around it, so a button
|
|
1567
|
+
sitting inside a taller toolbar puts the panel's foot across the toolbar's top border. Set the gap
|
|
1568
|
+
to the button's distance from the toolbar's top plus the clearance you want:
|
|
1569
|
+
|
|
1570
|
+
```css
|
|
1571
|
+
/* The button stands 7px below the toolbar's top edge; clear it by 8px. */
|
|
1572
|
+
ag-ui-chat {
|
|
1573
|
+
--ag-ui-anchor-gap: 15px;
|
|
1574
|
+
}
|
|
1575
|
+
```
|
|
1576
|
+
|
|
1577
|
+
A gap wider than the gutter is taken off the panel's max height as well, so a tall panel still
|
|
1578
|
+
stops at the top of the window.
|
|
1579
|
+
|
|
1580
|
+
**A button in a header.** The panel opens on the side of the anchor with more room, so a button
|
|
1581
|
+
in a top header opens it below the button and one in a bottom bar opens it above, with no inset
|
|
1582
|
+
of your own. `anchor-side="above"` or `"below"` fixes the side; `auto`, the default, decides again
|
|
1583
|
+
when the window is resized (never during a drag), so an anchor near the middle of the window can
|
|
1584
|
+
change sides. Below the anchor the gap is measured from the button's bottom edge, and the max
|
|
1585
|
+
height is the room under it less one gutter. On either side the panel lines up with the anchor's
|
|
1586
|
+
right edge unless that would put either edge inside the 24px gutter of the window, where it stops
|
|
1587
|
+
instead; its width cap is the window less two gutters, maximized included.
|
|
1588
|
+
|
|
1589
|
+
```html
|
|
1590
|
+
<ag-ui-chat endpoint="/agent/" collapse-to="none" anchor="#agent-button" anchor-side="below"></ag-ui-chat>
|
|
1591
|
+
```
|
|
1592
|
+
|
|
1593
|
+
Under `none` the element dispatches `ag-ui-toggle` once as it connects, with whichever state it
|
|
1594
|
+
mounted in, collapsed or open. Under `auto` and `bar` it does not, as before. That event touches
|
|
1595
|
+
neither the stored state nor the unread count.
|
|
1596
|
+
|
|
1597
|
+
**The page's space is yours.** The bar is fixed over the page, so a host pads its own page by
|
|
1598
|
+
the bar's height. The component never writes your CSS, and a custom property cannot carry the
|
|
1599
|
+
height up to your page (it inherits down, never up), so it hands you a number:
|
|
1600
|
+
`barHeight` (CSS px, read-only) and an `ag-ui-bar-height` event (the `BAR_HEIGHT_EVENT`
|
|
1601
|
+
constant, detail `{ height }`) on every change, including a font-size change. Both read `0`
|
|
1602
|
+
where no bar is drawn: under `auto` and `none`, on a phone while the panel is open, and while your
|
|
1603
|
+
size fills the window with the panel open. If you write the height into
|
|
1604
|
+
`--ag-ui-viewport-inset-bottom`, a panel that fills the box that leaves stays judged against that
|
|
1605
|
+
box once the bar goes and the box grows by the bar's height: it settles with the bar gone and room
|
|
1606
|
+
at its foot. Writing some other share of the height does not settle; publish it to your page as below.
|
|
1607
|
+
|
|
1608
|
+
```js
|
|
1609
|
+
const chat = document.querySelector("ag-ui-chat");
|
|
1610
|
+
const pad = () => {
|
|
1611
|
+
document.documentElement.style.setProperty("--chat-bar-height", `${chat.barHeight}px`);
|
|
1612
|
+
};
|
|
1613
|
+
chat.addEventListener("ag-ui-bar-height", pad);
|
|
1614
|
+
pad();
|
|
1615
|
+
```
|
|
1616
|
+
|
|
1617
|
+
```css
|
|
1618
|
+
body {
|
|
1619
|
+
padding-bottom: var(--chat-bar-height, 0px);
|
|
1620
|
+
}
|
|
1621
|
+
```
|
|
1622
|
+
|
|
1623
|
+
**A fixed sidebar pushes your page the same way.** A docked `placement="sidebar"` (or `"side"`)
|
|
1624
|
+
is fixed over the page, so `dockWidth` (CSS px, read-only) and an `ag-ui-dock-width` event (the
|
|
1625
|
+
`DOCK_WIDTH_EVENT` constant, detail `{ width, side }`, `side` being `"left"` or `"right"`, as
|
|
1626
|
+
`data-side` says) hand you the width it covers at rest: the panel's width while open, the rail's
|
|
1627
|
+
(`--ag-ui-rail-width`, 52 by default) while collapsed to it, and `0` under `collapse-to="none"` or
|
|
1628
|
+
a launcher, under every other placement, on a phone while the panel is open, and under
|
|
1629
|
+
`--ag-ui-position: static`, where your own layout already makes room. The event fires once per
|
|
1630
|
+
change of the resting value, never per frame of the width transition, and once at the end of a
|
|
1631
|
+
resize drag.
|
|
1632
|
+
|
|
1633
|
+
```js
|
|
1634
|
+
const chat = document.querySelector("ag-ui-chat");
|
|
1635
|
+
const push = () => {
|
|
1636
|
+
const { dockWidth: width } = chat;
|
|
1637
|
+
const side = document.querySelector("ag-ui-chat").getAttribute("data-side") ?? "right";
|
|
1638
|
+
document.documentElement.style.setProperty("--chat-dock-left", side === "left" ? `${width}px` : "0px");
|
|
1639
|
+
document.documentElement.style.setProperty("--chat-dock-right", side === "left" ? "0px" : `${width}px`);
|
|
1640
|
+
};
|
|
1641
|
+
chat.addEventListener("ag-ui-dock-width", push);
|
|
1642
|
+
push();
|
|
1643
|
+
```
|
|
1644
|
+
|
|
1645
|
+
```css
|
|
1646
|
+
body {
|
|
1647
|
+
padding-left: var(--chat-dock-left, 0px);
|
|
1648
|
+
padding-right: var(--chat-dock-right, 0px);
|
|
1649
|
+
}
|
|
1650
|
+
```
|
|
1651
|
+
|
|
1652
|
+
**No inset needed.** Under `bar` and `none` you do not set `--ag-ui-inset` to keep the panel off
|
|
1653
|
+
your toolbar: its max height is the viewport less the bar's height (or, under `none`, the
|
|
1654
|
+
distance to the anchor's top, or below it from the anchor's bottom) and one gutter, and its
|
|
1655
|
+
bottom sits on the bar or the anchor (its top, when it opens below).
|
|
1656
|
+
|
|
1657
|
+
**Docked, and still movable.** On a wide viewport a docked corner panel (the corner placements
|
|
1658
|
+
under `bar`, or under `none` with or without an anchor) moves by its header, and `moveTo` moves
|
|
1659
|
+
it, as it would the corner panel it is; `describeSurface()` reports `movable: true`. There is no
|
|
1660
|
+
launcher to drag: the bar and your own control stay where they are.
|
|
1661
|
+
|
|
1662
|
+
- **Above the dock.** A dragged panel stays inside the viewport and above the bar's top edge, so it
|
|
1663
|
+
never covers the bar it minimises to. Under `none` with an anchor it stays above the anchor's
|
|
1664
|
+
top edge the same way (below its bottom edge, when the panel opens below it), because the
|
|
1665
|
+
anchor is your own way back and has to stay in view;
|
|
1666
|
+
`--ag-ui-anchor-gap` no longer applies once the panel is dragged, and the screen-edge margin
|
|
1667
|
+
(8px) does. Under `none` with no anchor only the viewport holds it, as for a corner panel.
|
|
1668
|
+
- **Remembered like a corner panel.** Minimising and reopening brings it back where it was
|
|
1669
|
+
dragged, and so does a reload. The spot is kept apart from the one a corner panel stores under
|
|
1670
|
+
`auto`, so switching `collapse-to` never puts one surface's spot on the other. A spot that no
|
|
1671
|
+
longer fits, because the window shrank or the bar grew, is held inside the room that is left
|
|
1672
|
+
and comes back whole when there is room again.
|
|
1673
|
+
- **Resizing.** A panel standing on its dock keeps the edges on the bar or the anchor still and the
|
|
1674
|
+
free ones follow the grip. Once dragged off, every edge follows its grip, as on a corner panel.
|
|
1675
|
+
- **Back to the dock.** `resetSize()` drops the placement's dragged size and returns the panel to its dock,
|
|
1676
|
+
forgetting the stored spot, so a reload opens it there too.
|
|
1677
|
+
- **Keeping it fixed.** `data-launcher-drag="false"` turns the header drag and `moveTo` off here as
|
|
1678
|
+
everywhere else, and the panel stays on its dock.
|
|
1679
|
+
- **A host's size that fills the window.** Set `--ag-ui-width` and `--ag-ui-height` to the whole
|
|
1680
|
+
window, with `--ag-ui-max-width` and `--ag-ui-max-height` out of the way (the defaults stop short
|
|
1681
|
+
of it), and the panel stands at the start of the room you left free, whether it was moved or not.
|
|
1682
|
+
The bar and the grips go while it does, as on a phone, with `barHeight` at 0 and the header's
|
|
1683
|
+
minimise control as the way back. The bar comes back when the panel is collapsed, and both when
|
|
1684
|
+
your size stops filling. The stored spot stays, and the panel goes back to it, or to its dock,
|
|
1685
|
+
once your size stops filling. An inline `--ag-ui-inset` of your own stays where you wrote it.
|
|
1686
|
+
|
|
1687
|
+
Placements that own their position (`side` and `full` under `bar`, `sidebar` and `embedded`
|
|
1688
|
+
under `none`) do not move, as without `collapse-to`.
|
|
1689
|
+
|
|
1690
|
+
**Phones.** At 600px or less the open panel stays full-bleed, over the bar, and the header's
|
|
1691
|
+
minimise control is the way back; the bar shows only while collapsed, so no second strip sits
|
|
1692
|
+
at the bottom edge while the keyboard is up. The panel does not move there, and a spot dragged on
|
|
1693
|
+
a wide viewport does not apply: it comes back when the window is wide again.
|
|
1694
|
+
|
|
1695
|
+
The bar is styled through the parts `bar` (the strip's wrapper, holding the launcher, the place and
|
|
1696
|
+
the history button) and `bar-history`, and the launcher's own parts (`launcher`, `rail-label`,
|
|
1697
|
+
`launcher-badge`) inside it.
|
|
1698
|
+
|
|
1699
|
+
**The place in the bar.** When [`getPlace`](#where-the-user-is-the-place-chip) (or the route map
|
|
1700
|
+
standing in for it) names a place, the strip shows it beside the title as quiet secondary text: the
|
|
1701
|
+
`bar-place` part holding `place-label` and `place-detail`, the parts the chip uses. It comes from
|
|
1702
|
+
the chip's one read, so `refreshPlace()`, `popstate` and a withheld place update it as they update
|
|
1703
|
+
the chip. A long place truncates with an ellipsis before the history button leaves the strip, and
|
|
1704
|
+
no place draws nothing. A place the user withheld stays in the strip dashed and struck through
|
|
1705
|
+
(`data-excluded` on `bar-place`), because the bar must not claim the page is sent. The phone strip,
|
|
1706
|
+
shown while collapsed, carries it too.
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
---
|
|
1710
|
+
|
|
1711
|
+
## Charts
|
|
1712
|
+
|
|
1713
|
+
Markdown here goes through a narrow sanitiser and images are off by default — a
|
|
1714
|
+
model-controlled image URL is fetched with no user interaction, which turns
|
|
1715
|
+
prompt-injected page data into a zero-click exfiltration channel. So a chart
|
|
1716
|
+
does not arrive as markup. It arrives as **data**, and the component builds the
|
|
1717
|
+
SVG itself: the model chooses the numbers, the component chooses the DOM.
|
|
1718
|
+
|
|
1719
|
+
Off unless you ask for it, by either route or both:
|
|
1720
|
+
|
|
1721
|
+
```js
|
|
1722
|
+
const chat = document.querySelector("ag-ui-chat");
|
|
1723
|
+
chat.enableCharts(["tool", "activity"]);
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
Order does not matter. Calling it after the element is on the page redraws any
|
|
1727
|
+
charts already in the restored history, so you do not have to reach the element
|
|
1728
|
+
before it connects — which you generally cannot.
|
|
1729
|
+
|
|
1730
|
+
Specs are bounded, and anything outside is dropped rather than half-drawn: at
|
|
1731
|
+
most **20,000 points** and **2,000 labels**, and every point a finite number no
|
|
1732
|
+
larger than **1e15**. The first two keep a stored transcript from blocking the
|
|
1733
|
+
main thread on every reload; the last one keeps the value range finite, since
|
|
1734
|
+
scaling divides by it.
|
|
1735
|
+
|
|
1736
|
+
**`"tool"`** registers a `render_chart` tool the agent may call. The numbers are
|
|
1737
|
+
in its context, so it can talk about them; it costs one model round.
|
|
1738
|
+
|
|
1739
|
+
**`"activity"`** draws a chart the server pushes as an `ACTIVITY_SNAPSHOT` with
|
|
1740
|
+
`activityType: "chart"` (exported as `CHART_ACTIVITY_TYPE`). The data never
|
|
1741
|
+
reaches the model, there is no extra round, and this is the only route that can
|
|
1742
|
+
**update a chart in place** — the server repeats the same `messageId` to redraw
|
|
1743
|
+
it, or sends an `ACTIVITY_DELTA` to move one series as a computation advances.
|
|
1744
|
+
|
|
1745
|
+
**Whether a pushed chart survives a reload depends on where the conversation is
|
|
1746
|
+
stored.** A client-side store keeps activities, so it comes back. A server that
|
|
1747
|
+
stores the thread as the model's message history does not — a pushed chart is
|
|
1748
|
+
deliberately not in that history, which is the reason to push it. A chart the
|
|
1749
|
+
agent asked for survives either way, because its spec travels as the tool call's
|
|
1750
|
+
arguments and the component redraws from those without re-running anything.
|
|
1751
|
+
|
|
1752
|
+
Either way the payload is the same shape:
|
|
1753
|
+
|
|
1754
|
+
```json
|
|
1755
|
+
{
|
|
1756
|
+
"kind": "bar",
|
|
1757
|
+
"title": "Signups this week",
|
|
1758
|
+
"labels": ["Mon", "Tue", "Wed"],
|
|
1759
|
+
"series": [{ "label": "new", "points": [12, 19, 9] }]
|
|
1760
|
+
}
|
|
1761
|
+
```
|
|
1762
|
+
|
|
1763
|
+
`kind` is one of `bar`, `line`, `pie`, `scatter`, `stacked`; anything else is
|
|
1764
|
+
drawn as a bar rather than refused. Every series needs exactly one point per
|
|
1765
|
+
label — a shorter one misaligns every value after the gap, and a chart that is
|
|
1766
|
+
subtly wrong still reads as authoritative, so the whole spec is dropped instead.
|
|
1767
|
+
A pie's slices are its labels, so it draws the first series only.
|
|
1768
|
+
|
|
1769
|
+
Theme the series with `--ag-ui-chart-1` … `--ag-ui-chart-6`, size it with
|
|
1770
|
+
`--ag-ui-chart-max-width`, and style the block through the `chart-block`,
|
|
1771
|
+
`chart-title` and `chart-legend` parts.
|
|
1772
|
+
|
|
1773
|
+
**A chart stops at its own width, and is sized in pixels rather than scaled to
|
|
1774
|
+
them.** Two separate things, and a widened panel needs both.
|
|
1775
|
+
|
|
1776
|
+
It is drawn for the width the block actually has -- one SVG unit per CSS pixel
|
|
1777
|
+
-- and redrawn when that width changes, so a 10px axis label is 10px in a 380px
|
|
1778
|
+
panel and in a 1200px one. Where the labels no longer fit, the axis draws every
|
|
1779
|
+
second or third rather than a smear of overlapping words. Below 220px it goes
|
|
1780
|
+
back to scaling, since at that size nothing fits either way.
|
|
1781
|
+
|
|
1782
|
+
And it takes the width it needs rather than the width it is offered, capping at
|
|
1783
|
+
`--ag-ui-chart-max-width` (**480px**): widening the panel should no more resize
|
|
1784
|
+
a chart than it resizes a message. Raise the token for a bigger chart and the
|
|
1785
|
+
labels stay 10px -- the cap is about how much room the drawing gets, never
|
|
1786
|
+
about how big its type is. Height follows width inside a 160-320px band.
|
|
1787
|
+
|
|
1788
|
+
### Drawing something other than a chart
|
|
1789
|
+
|
|
1790
|
+
`render_chart` is built on a seam any tool can use. A `ClientTool` may declare a
|
|
1791
|
+
pure `render` beside its `handler`:
|
|
1792
|
+
|
|
1793
|
+
```js
|
|
1794
|
+
chat.registerTool({
|
|
1795
|
+
name: "show_route",
|
|
1796
|
+
description: "Draw the route on a map.",
|
|
1797
|
+
parameters: { type: "object", properties: { stops: { type: "array" } } },
|
|
1798
|
+
handler: () => "route shown",
|
|
1799
|
+
render: (args) => buildMapElement(args.stops), // pure; no side effects
|
|
1800
|
+
});
|
|
1801
|
+
```
|
|
1802
|
+
|
|
1803
|
+
**`render` is the only half a restored transcript replays.** Replaying a tool's
|
|
1804
|
+
*effect* is out of the question — re-running a form-filling tool on every reload
|
|
1805
|
+
is a bug. The replay path is handed the `render` function alone, never the tool
|
|
1806
|
+
that owns it, so the code that runs on restore cannot reach `handler` even by
|
|
1807
|
+
mistake: adding a "no render? fall back to the handler" convenience there means
|
|
1808
|
+
changing a type signature first, which is the moment the question gets asked.
|
|
1809
|
+
|
|
1810
|
+
That is why `render` has to be a pure, deterministic function of its arguments —
|
|
1811
|
+
it runs again every time the conversation is restored.
|
|
1812
|
+
|
|
1813
|
+
---
|
|
1814
|
+
|
|
1815
|
+
## Drawing other things the server pushes
|
|
1816
|
+
|
|
1817
|
+
`activityType` is an open string the protocol does not enumerate — `"chart"` is
|
|
1818
|
+
just the one the component ships a renderer for. Register your own and the
|
|
1819
|
+
server can push anything it likes into the transcript:
|
|
1820
|
+
|
|
1821
|
+
```js
|
|
1822
|
+
chat.registerActivityRenderer({
|
|
1823
|
+
type: "build_status",
|
|
1824
|
+
render: (content) => {
|
|
1825
|
+
const el = document.createElement("div");
|
|
1826
|
+
el.className = "build";
|
|
1827
|
+
el.textContent = `Build ${content.status}`;
|
|
1828
|
+
return el; // return null for content not worth drawing
|
|
1829
|
+
},
|
|
1830
|
+
});
|
|
1831
|
+
```
|
|
1832
|
+
|
|
1833
|
+
**`render` runs again on every thread load**, so it carries the same contract as
|
|
1834
|
+
a client tool's `render`: a pure function of `content`, deterministic, and free
|
|
1835
|
+
of effects outside the node it returns. Activities are materialised into
|
|
1836
|
+
`role: "activity"` messages and persisted with the transcript, so a renderer that
|
|
1837
|
+
writes to the page instead of returning DOM fires again on every restore.
|
|
1838
|
+
|
|
1839
|
+
The component places what you return, keyed by the activity's `messageId`, so a
|
|
1840
|
+
server repeating an id **replaces** your node rather than adding a second one —
|
|
1841
|
+
the same in-place update charts get. Returning `null` removes whatever was there:
|
|
1842
|
+
live and reload should agree, and the stored content is the version that could
|
|
1843
|
+
not be drawn.
|
|
1844
|
+
|
|
1845
|
+
`chart` and `compaction` are registrations exactly like yours, not privileged
|
|
1846
|
+
branches, so registering either name **replaces the built-in**.
|
|
1847
|
+
|
|
1848
|
+
### Which carrier should the server use?
|
|
1849
|
+
|
|
1850
|
+
AG-UI leaves exactly two payload names open, and they are not
|
|
1851
|
+
interchangeable:
|
|
1852
|
+
|
|
1853
|
+
| | Carrier | Reaches | Persisted | Replayed |
|
|
1854
|
+
| --- | --- | --- | --- | --- |
|
|
1855
|
+
| **Content** | `ACTIVITY_SNAPSHOT` | the transcript | yes | yes |
|
|
1856
|
+
| **Imperative** | `CUSTOM` | your page, as [`ag-ui-custom`](#host-seams-the-spa-story) | no | no |
|
|
1857
|
+
|
|
1858
|
+
⇒ **Content has a place in the conversation and should come back. An imperative
|
|
1859
|
+
has no place and no meaning once acted on** — replaying "refetch the board" on
|
|
1860
|
+
every thread load is a bug, not a feature. If it has to survive a reload, it is
|
|
1861
|
+
content.
|
|
1862
|
+
|
|
1863
|
+
### Finding out what arrived
|
|
1864
|
+
|
|
1865
|
+
An activity nobody registered for draws nothing and logs nothing — that is the
|
|
1866
|
+
protocol's own answer, and warning would fire on every forward-compatible
|
|
1867
|
+
server. But silence is hard to debug, so the names are readable:
|
|
1868
|
+
|
|
1869
|
+
```js
|
|
1870
|
+
chat.unhandledActivityTypes; // ["pydantic_ai_thinking", …]
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
Note `"chart"` appears there until you call `enableCharts(["activity"])`, which
|
|
1874
|
+
is the honest answer to "I pushed a chart and nothing happened".
|
|
1875
|
+
|
|
1876
|
+
## Tool-call display modes
|
|
1877
|
+
|
|
1878
|
+
How tool calls are drawn is set via the `data-tool-display` attribute (or `toolDisplay`
|
|
1879
|
+
property), one of `grouped` / `inline` / `minimal` / `compact` / `full` (default `grouped`; an
|
|
1880
|
+
absent or unknown value reads as `grouped`):
|
|
1881
|
+
|
|
1882
|
+
- `grouped` — the default. A run's consecutive tool calls fold into one quiet row, **"Worked
|
|
1883
|
+
through 5 steps"**, with a note such as **"1 failed"** beside it, and the calls one click down
|
|
1884
|
+
as `full` cards, or in the mode `data-tool-group-display` names. Anything else in the turn (text, a confirmation, a question) ends the group, and
|
|
1885
|
+
the next call starts a new one; a call's own rendered output does not. A group of one call shows that call's own label. See
|
|
1886
|
+
[The step group](#the-step-group).
|
|
1887
|
+
- `inline` — the lightest mode: a single status row (icon + summary, no card chrome) with the
|
|
1888
|
+
result behind its own toggle. Reads as one line of the answer — pairs with [the answer
|
|
1889
|
+
well](#the-answer-well).
|
|
1890
|
+
- `minimal` — tool name + status pill only.
|
|
1891
|
+
- `compact` — name + status, with arguments *and* result behind a single collapsed toggle.
|
|
1892
|
+
- `full` — arguments visible, result behind the toggle. The default before `grouped`: set
|
|
1893
|
+
`data-tool-display="full"` to keep per-call cards.
|
|
1894
|
+
|
|
1895
|
+
Whichever mode is on, a settled card's body holds **two labelled regions** — `Arguments` and
|
|
1896
|
+
`Result` (or `Error` / `Declined`) — each with its own part and each pretty-printed. They are
|
|
1897
|
+
never run together into one block, so where the call ends and the answer begins is always
|
|
1898
|
+
visible. Style them via the `tool-card-args` / `tool-card-result` parts, their headings via
|
|
1899
|
+
`tool-card-section-label`, and the whole body via `tool-card-body`.
|
|
1900
|
+
|
|
1901
|
+
**A card names its status as a part.** `::part()` takes no attribute selector, so a rule on
|
|
1902
|
+
`tool-card-status` reaches every outcome alike. The card and its status pill each carry a modifier
|
|
1903
|
+
beside the base part, one of `pending`, `deferred`, `done`, `error`, `declined` or `interrupted`,
|
|
1904
|
+
and it is replaced whenever the status changes:
|
|
1905
|
+
|
|
1906
|
+
```css
|
|
1907
|
+
ag-ui-chat::part(tool-card-error) { border-color: crimson; }
|
|
1908
|
+
ag-ui-chat::part(tool-card-status-error) { font-weight: 700; }
|
|
1909
|
+
```
|
|
1910
|
+
|
|
1911
|
+
**The attribute is live.** Changing `data-tool-display` between the four card modes restyles every
|
|
1912
|
+
card already in the transcript, the way `data-answer-well` does — the modes are pure visibility
|
|
1913
|
+
over one DOM shape, selected by the shadow CSS from the host attribute. Grouping is decided as
|
|
1914
|
+
each card is built: switching **to** `grouped` folds only the calls drawn afterwards, and
|
|
1915
|
+
switching **away** from it flattens the groups already drawn, by CSS, into exactly the cards the
|
|
1916
|
+
new mode builds (the group stops generating a box and its row is hidden).
|
|
1917
|
+
|
|
1918
|
+
### Choosing how a group draws its calls
|
|
1919
|
+
|
|
1920
|
+
Under `grouped`, an opened group draws its calls as `full` cards unless the
|
|
1921
|
+
`data-tool-group-display` attribute (or `toolGroupDisplay` property) names another of the card
|
|
1922
|
+
modes: `compact`, `minimal` or `inline`. Each draws the regions the flat card of that mode draws,
|
|
1923
|
+
laid out as a row of the group's list rather than as a boxed card. An absent or unknown value
|
|
1924
|
+
reads as `full`, so nothing changes for a host that does not set it.
|
|
1925
|
+
|
|
1926
|
+
```html
|
|
1927
|
+
<ag-ui-chat endpoint="/agent/" data-tool-group-display="compact"></ag-ui-chat>
|
|
1928
|
+
```
|
|
1929
|
+
|
|
1930
|
+
It is read only under `grouped`. Under one of the four card modes the groups are flattened into
|
|
1931
|
+
that mode's cards and the inner value is ignored; switching back to `grouped` applies it again.
|
|
1932
|
+
It is live like the outer attribute: changing it restyles groups already in the transcript.
|
|
1933
|
+
|
|
1934
|
+
A call waiting on an approval shows its arguments and its Approve and Deny buttons in every inner
|
|
1935
|
+
mode, as it does in every card mode, and Approve takes focus when the group opens for it.
|
|
1936
|
+
|
|
1937
|
+
### The step group
|
|
1938
|
+
|
|
1939
|
+
Under `grouped` the row reads, by state:
|
|
1940
|
+
|
|
1941
|
+
| State (`data-state` on the `tool-group` part) | Label | Mark |
|
|
1942
|
+
| --- | --- | --- |
|
|
1943
|
+
| `running` | "Working through 3 steps", and the running step's label as a note | spinner |
|
|
1944
|
+
| `waiting` | "3 steps", with "1 waiting for you" | accent dot |
|
|
1945
|
+
| `done` | "Worked through 3 steps" | check |
|
|
1946
|
+
| `error` | "Worked through 3 steps", with "1 failed" in the danger colour | error mark |
|
|
1947
|
+
|
|
1948
|
+
"N declined" is added as a note whenever a step was declined. `data-count` carries the number of
|
|
1949
|
+
steps and `data-open` whether the cards are showing; the head is a button with `aria-expanded`.
|
|
1950
|
+
|
|
1951
|
+
- **It opens itself while a step waits on you.** A server-side approval renders inside the gated
|
|
1952
|
+
call's own card, so a folded group would hide the question it is asking: the group opens when a
|
|
1953
|
+
step goes `deferred`, once per wait, and stays folded if you fold it again. It opens before the
|
|
1954
|
+
approval is drawn, so the Approve button takes focus and scrolls into view. A client-side
|
|
1955
|
+
confirmation and an `ask_user` question render in the answer beside the group rather than inside
|
|
1956
|
+
a card, so they are in view without it opening; the row still reads `waiting` ("1 waiting for
|
|
1957
|
+
you") while one is open, as the card carries a `data-asking` flag for the group to read, and its
|
|
1958
|
+
`data-status` stays `pending`.
|
|
1959
|
+
- **A one-step group is named after its call, and the card's head is not repeated inside.** A turn
|
|
1960
|
+
with exactly one call reads as that call's label ("Fill field"), and the opened list shows the
|
|
1961
|
+
card without its own `tool-card-head` (the row's state mark and notes carry the status). The
|
|
1962
|
+
Details toggle and arguments stay, except under `data-tool-group-display="minimal"`, where the
|
|
1963
|
+
card is only its head: there the row draws no chevron, though it still opens for an approval. A
|
|
1964
|
+
card display mode keeps the head. A run that grows to two calls brings both heads back.
|
|
1965
|
+
- **A running sub-agent does not open it.** The row keeps its spinner and names the delegation
|
|
1966
|
+
instead, "Delegated to researcher" (the `subAgentDelegatedTo` string).
|
|
1967
|
+
- **Repeated labels are told apart.** Two "Fill field" steps read "Fill field" and "Fill field
|
|
1968
|
+
(2)": the ordinal is an element of its own (part `tool-card-repeat`) beside the card's name, so
|
|
1969
|
+
`tool-card-name` is never rewritten, and a card mode hides the ordinal.
|
|
1970
|
+
- **Rendered output stays in view.** A tool's `render` output is placed after the group, in call
|
|
1971
|
+
order, rather than inside it, so a chart is never one click down. It does not end the group: a
|
|
1972
|
+
later call joins the group above it, which is also how a restored conversation draws the run.
|
|
1973
|
+
|
|
1974
|
+
Parts: `tool-group` (with the state beside it as `tool-group-waiting`, `tool-group-running`,
|
|
1975
|
+
`tool-group-done` or `tool-group-error`), `tool-group-head` (the button), `tool-group-icon`, `tool-group-label`,
|
|
1976
|
+
`tool-group-meta` (holding one `.tool-group-note` per note), `tool-group-list` (the cards), and
|
|
1977
|
+
`tool-card-repeat` on a repeated step. The focus ring is `--ag-ui-ring` and the disclosure chevron
|
|
1978
|
+
`--ag-ui-ico-chevron`. Strings (`strings` / `data-strings`): `toolGroupSteps`,
|
|
1979
|
+
`toolGroupWorking`, `toolGroupWorked` (each with `{count}`, always 2 or more), `toolGroupWaiting`,
|
|
1980
|
+
`toolGroupFailed`, `toolGroupDeclined` (each with `{count}`) and `toolStepRepeat` (with `{n}`).
|
|
1981
|
+
|
|
1982
|
+
```html
|
|
1983
|
+
<!-- Keep per-call cards, as before grouped was the default -->
|
|
1984
|
+
<ag-ui-chat endpoint="/agent/" data-tool-display="full"></ag-ui-chat>
|
|
1985
|
+
```
|
|
1986
|
+
|
|
1987
|
+
### Drawing a card's body yourself
|
|
1988
|
+
|
|
1989
|
+
A thirty-field result is a wall of JSON where a host wanted a table, or a
|
|
1990
|
+
sentence. `formatToolPayload` is the seam: it is asked about each region of each
|
|
1991
|
+
card and may return a `Node` to take it over, a `string` to replace its text, or
|
|
1992
|
+
`null` to leave the built-in pretty-print alone.
|
|
1993
|
+
|
|
1994
|
+
```js
|
|
1995
|
+
chat.formatToolPayload = (payload) => {
|
|
1996
|
+
if (payload.kind !== "result" || payload.toolName !== "list_orders") {
|
|
1997
|
+
return null; // everything else keeps the default rendering
|
|
1998
|
+
}
|
|
1999
|
+
const table = document.createElement("table");
|
|
2000
|
+
// ... build it from JSON.parse(payload.text)
|
|
2001
|
+
return table;
|
|
2002
|
+
};
|
|
2003
|
+
```
|
|
2004
|
+
|
|
2005
|
+
Both halves come through the same hook, told apart by `kind`: `arguments`
|
|
2006
|
+
carries the parsed record the call was made with, `result` the raw string the
|
|
2007
|
+
tool returned plus the outcome it settled on. A region a formatter took over is
|
|
2008
|
+
marked `data-formatted`, which relaxes the preformatted whitespace the default
|
|
2009
|
+
JSON block relies on — a table would otherwise inherit it as mangled cell
|
|
2010
|
+
spacing. Whitespace only: the card's face, frame and scroll cap stay, so one long
|
|
2011
|
+
payload still cannot stretch the transcript, and a host wanting different
|
|
2012
|
+
typography restyles the `tool-card-result` part.
|
|
2013
|
+
|
|
2014
|
+
This is **presentation, not translation.** The card and the model already read
|
|
2015
|
+
separate copies of a tool result — the model's is maintained by `@ag-ui/client`
|
|
2016
|
+
from the same event — so a formatter changes what the person reads and nothing
|
|
2017
|
+
the agent reads. That is what makes restyling safe here, and it is also why
|
|
2018
|
+
*rewording* belongs on the server: renamed there, the new wording reaches the
|
|
2019
|
+
model's prose too, instead of leaving the card disagreeing with the answer beside
|
|
2020
|
+
it. A returned string is set as text, never parsed as markup — this is not a
|
|
2021
|
+
second HTML channel into the transcript.
|
|
2022
|
+
|
|
2023
|
+
A gated call carries the decision (`approved by you` / `declined by you`, part
|
|
2024
|
+
`tool-card-decision`, attribute `data-decision`) — from the client-side confirmation card and
|
|
2025
|
+
from the server-side approval interrupt alike. The prompt itself disappears once answered: a
|
|
2026
|
+
prompt and a record are different objects, and the record is the card.
|
|
2027
|
+
|
|
2028
|
+
**The annotation is session-scoped**, like the "run interrupted" notice. AG-UI carries no
|
|
2029
|
+
approval message — the answer rides `resume[]` as transient run input — so a reload restores the
|
|
2030
|
+
tool call and its result but not the note that a human waved it through. If you need "who
|
|
2031
|
+
approved what" durably, that is an audit concern rather than a transcript one; record it
|
|
2032
|
+
server-side.
|
|
2033
|
+
|
|
2034
|
+
If a tool's schema carries an `x-summary` string (use `X_SUMMARY_KEY`), the card shows it on the
|
|
2035
|
+
label instead of the raw tool name.
|
|
2036
|
+
|
|
2037
|
+
Every card leads with a **status icon** drawn entirely in CSS — a spinning ring while the call
|
|
2038
|
+
runs, then a check / cross / slash / dotted ring on success / error / decline / not finished.
|
|
2039
|
+
Re-theme it via custom properties (or the `tool-card-icon` part): `--ag-ui-tool-icon-done`,
|
|
2040
|
+
`--ag-ui-tool-icon-error`, `--ag-ui-tool-icon-declined`, `--ag-ui-tool-icon-interrupted`
|
|
2041
|
+
(quoted-string glyphs) and `--ag-ui-tool-spin-duration` (spinner
|
|
2042
|
+
speed; the spin respects `prefers-reduced-motion`).
|
|
2043
|
+
|
|
2044
|
+
```html
|
|
2045
|
+
<ag-ui-chat endpoint="/agent/" data-tool-display="compact"></ag-ui-chat>
|
|
2046
|
+
```
|
|
2047
|
+
|
|
2048
|
+
---
|
|
2049
|
+
|
|
2050
|
+
## Delegated sub-agents
|
|
2051
|
+
|
|
2052
|
+
A run that hands work to a sub-agent reads as a stall. The parent's
|
|
2053
|
+
`delegate_task` card sits at "running…" for the child's entire duration —
|
|
2054
|
+
however many tools the child calls, however long it takes — with nothing on
|
|
2055
|
+
screen to say anything is happening.
|
|
2056
|
+
|
|
2057
|
+
If your server narrates that, the component draws it. The wire is an ordinary
|
|
2058
|
+
AG-UI `CUSTOM` event named `ag_ui.subagent`, carrying:
|
|
2059
|
+
|
|
2060
|
+
| Key | Meaning |
|
|
2061
|
+
| --- | --- |
|
|
2062
|
+
| `delegationId` | the **parent's own `delegate_task` tool-call id** — not the child's run id |
|
|
2063
|
+
| `agent` | the child agent's name |
|
|
2064
|
+
| `phase` | one of `started`, `tool_call`, `tool_result`, `finished`, `failed` |
|
|
2065
|
+
| `status` | a pre-rendered line, ready to show |
|
|
2066
|
+
| `tool` | `toolCallId`, `name` and `ok`, on the two tool phases only |
|
|
2067
|
+
|
|
2068
|
+
Exactly one `started` opens a delegation and exactly one `finished` or `failed`
|
|
2069
|
+
closes it. `ok` is a tri-state: `null` while the child's call runs, `true` on a
|
|
2070
|
+
result it accepted, `false` on one that came back to it.
|
|
2071
|
+
|
|
2072
|
+
Because the key is the *parent's* call id, the surface attaches to a card that
|
|
2073
|
+
already exists rather than floating a second element with the same identity: one
|
|
2074
|
+
**collapsed row per delegation**, live, carrying the server's `status` line and
|
|
2075
|
+
nothing else, expanding onto the child's own tool calls. A ten-step child costs
|
|
2076
|
+
one row until somebody opens it, and there is no second visual language — it
|
|
2077
|
+
reads the way tool cards already read.
|
|
2078
|
+
|
|
2079
|
+
`status` is why the collapsed row needs no wording of its own. The structured
|
|
2080
|
+
keys are there for a host that would rather write its own.
|
|
2081
|
+
|
|
2082
|
+
The row shows in **every** [display mode](#tool-call-display-modes), including
|
|
2083
|
+
`minimal`. It sits outside the card body rather than in it, because the body is
|
|
2084
|
+
what the density modes hide — and a live progress line that only appeared in
|
|
2085
|
+
`full` would leave exactly the stall it exists to end. Same reasoning that shows
|
|
2086
|
+
a deferred card's arguments whatever the mode.
|
|
2087
|
+
|
|
2088
|
+
**A failure carries no exception text on this channel, deliberately** — the same
|
|
2089
|
+
reasoning that redacts a `RUN_ERROR`, since an exception's words are written for
|
|
2090
|
+
an operator. The detail rides the ordinary tool result for that delegation, which
|
|
2091
|
+
lands in the same card's `Result` region a few pixels below. Nothing here invents
|
|
2092
|
+
words the server declined to send.
|
|
2093
|
+
|
|
2094
|
+
**None of it is persisted.** A `CUSTOM` event never enters the message list, so
|
|
2095
|
+
nothing replays on a thread restore — which is the right half of the
|
|
2096
|
+
[carrier split](#which-carrier-should-the-server-use): a delegation that was live
|
|
2097
|
+
an hour ago is not live now, and replaying its progress would be a lie about a
|
|
2098
|
+
run that is over. Reload mid-run and the tool card is still there; the nested
|
|
2099
|
+
detail is not. That is the intended behaviour.
|
|
2100
|
+
|
|
2101
|
+
Like `ag_ui.invalidate`, this name is **routed** rather than forwarded: it draws
|
|
2102
|
+
itself and does not also arrive as an `ag-ui-custom` event. Every other name
|
|
2103
|
+
still reaches your page untouched.
|
|
2104
|
+
|
|
2105
|
+
Style it through `tool-card-subagent` (the region inside the card), `subagent`,
|
|
2106
|
+
`subagent-row`, `subagent-icon`, `subagent-status`, `subagent-steps`,
|
|
2107
|
+
`subagent-step`, `subagent-step-icon` and `subagent-step-name`. The two glyph
|
|
2108
|
+
states reuse the card's own `--ag-ui-tool-icon-done` / `--ag-ui-tool-icon-error`
|
|
2109
|
+
properties and its spinner speed, so re-theming the cards re-themes these. The
|
|
2110
|
+
row's own chrome comes from `subAgentWorking` and `subAgentSteps` in
|
|
2111
|
+
[`UiStrings`](#internationalization-i18n); everything else on the row is the
|
|
2112
|
+
server's text.
|
|
2113
|
+
|
|
2114
|
+
---
|
|
2115
|
+
|
|
2116
|
+
## Resizing the panel
|
|
2117
|
+
|
|
2118
|
+
The panel carries a grip on **every edge and every corner**, so it can be
|
|
2119
|
+
dragged from whichever side you are already near.
|
|
2120
|
+
|
|
2121
|
+
- `placement="page"` gets **no grips** — a full-bleed layout
|
|
2122
|
+
is `100vw`/`100vh` by definition, so there is nothing to drag.
|
|
2123
|
+
- `placement="sidebar"` keeps only the two vertical edges;
|
|
2124
|
+
the placement owns the height, so a horizontal edge or a corner would
|
|
2125
|
+
advertise a drag that does nothing.
|
|
2126
|
+
- Everything else gets all eight.
|
|
2127
|
+
|
|
2128
|
+
**The edge a grip does not drag is the one that stays put.** That is the whole
|
|
2129
|
+
model: the left grip moves the left edge and holds the right, the right grip
|
|
2130
|
+
does the reverse, a corner does both axes. A grip names its own edge, so no
|
|
2131
|
+
layout can invert it.
|
|
2132
|
+
|
|
2133
|
+
### Dragging the edge your layout was holding still
|
|
2134
|
+
|
|
2135
|
+
A floating panel pinned bottom-right cannot grow rightward on its own — its
|
|
2136
|
+
right edge is what the placement fixed. So **a drag on a pinned edge moves the
|
|
2137
|
+
panel as well as resizing it**, and the component takes the position over by
|
|
2138
|
+
writing `--ag-ui-inset`, the same ownership a dragged launcher takes.
|
|
2139
|
+
|
|
2140
|
+
A grip on a free edge writes nothing but the size, exactly as before, so a host
|
|
2141
|
+
positioning the panel with its own rule keeps that rule until someone drags the
|
|
2142
|
+
edge it was holding.
|
|
2143
|
+
|
|
2144
|
+
Which edges those are belongs to *your* CSS rather than to `placement` — a
|
|
2145
|
+
floating panel a host right-aligns is anchored bottom-left — so the element
|
|
2146
|
+
probes its own geometry and reflects the result as `data-resize-anchor` (e.g.
|
|
2147
|
+
`bottom-right` means those two edges are fixed). Nothing in the stylesheet reads
|
|
2148
|
+
it any more; the element uses it to decide what a drag on a pinned edge costs,
|
|
2149
|
+
and which grip carries the keyboard.
|
|
2150
|
+
|
|
2151
|
+
> **The probe shrinks rather than grows.** Growing by a pixel cannot answer the
|
|
2152
|
+
> question at a size already resting against `max-width` or `max-height`: the
|
|
2153
|
+
> box does not change, no edge moves, and every clamped axis reads as pinned on
|
|
2154
|
+
> the side that is actually free. That was reachable with no user action at all
|
|
2155
|
+
> — the default panel is 380px wide against a max-width of `100vw - 48px`, so
|
|
2156
|
+
> any viewport under 428px was born clamped, with the grip on the wrong corner
|
|
2157
|
+
> and the drag inverted.
|
|
2158
|
+
|
|
2159
|
+
### Sizes, keyboard, and parts
|
|
2160
|
+
|
|
2161
|
+
A drag writes the private `--_drag-width` / `--_drag-height` on the host, not
|
|
2162
|
+
your `--ag-ui-width` / `--ag-ui-height` tokens, which stay yours. The precedence
|
|
2163
|
+
is:
|
|
2164
|
+
|
|
2165
|
+
- **Above 600px**, the dragged size beats a host's `--ag-ui-width` /
|
|
2166
|
+
`--ag-ui-height`, `!important` included, so a user who resized the panel keeps
|
|
2167
|
+
their size. A host that enters a sized state of its own (an "expanded" class)
|
|
2168
|
+
calls `resetSize()` when it does, which drops the current placement's dragged
|
|
2169
|
+
size and its stored copy and hands control back to the host tokens. A host
|
|
2170
|
+
with no such state needs nothing.
|
|
2171
|
+
- **Per placement.** A dragged size belongs to the `placement` it was dragged in
|
|
2172
|
+
and is stored under it: absent and `floating` share one, and `bottom-left`,
|
|
2173
|
+
`sidebar`, `side` and `embedded` each keep their own (`data-side` is not a
|
|
2174
|
+
placement, so a left and a right sidebar share one). Switching `placement`
|
|
2175
|
+
swaps in the new placement's size, or its default when it has none, so a
|
|
2176
|
+
placement switch needs no `resetSize()`. A `collapse-to` change keeps the size.
|
|
2177
|
+
A size stored by a release before this one is read as floating's.
|
|
2178
|
+
- **At 600px or less**, the small-viewport shape ignores the dragged size
|
|
2179
|
+
entirely, for the placements that block covers (`floating`, `bottom-left`,
|
|
2180
|
+
`sidebar`, `side` and the default): the panel is full-bleed whatever was
|
|
2181
|
+
dragged or stored, and a window narrowed after a wide-window drag goes
|
|
2182
|
+
full-bleed too. `page` is already that shape, and `embedded` is left to the
|
|
2183
|
+
host's own box, so neither is touched by the block. `data-small-viewport="off"`
|
|
2184
|
+
keeps a dragged size at every width on the covered placements.
|
|
2185
|
+
|
|
2186
|
+
A dragged size is a private property, so a host should not read it back from
|
|
2187
|
+
the element's inline style; use `getBoundingClientRect()`, or `resetSize()` to
|
|
2188
|
+
clear it.
|
|
2189
|
+
|
|
2190
|
+
**A custom property alone does not leave placement in charge** — an inline custom
|
|
2191
|
+
property still outranks a `:host([placement=…])` rule setting the same property. So the
|
|
2192
|
+
component enforces the split directly: **a placement owns the axes it fixes**,
|
|
2193
|
+
and a dragged or persisted size is only ever applied to the ones it leaves free.
|
|
2194
|
+
Switching placement hands the owned axes back. Without that, a height dragged
|
|
2195
|
+
while floating capped a docked sidebar that had asked for `100vh`.
|
|
2196
|
+
|
|
2197
|
+
**A host rule that sizes the element wins over both.** `ag-ui-chat { flex: 1 }`
|
|
2198
|
+
stretches the panel to its container and the dragged width has no visible
|
|
2199
|
+
effect — which reads as a broken control rather than as your stylesheet winning.
|
|
2200
|
+
Give the element `flex: 0 1 auto` (plus `max-width: 100%`) if it lives in a flex
|
|
2201
|
+
container.
|
|
2202
|
+
|
|
2203
|
+
The size persists per tab (`sessionStorage`, namespaced per element like the
|
|
2204
|
+
collapsed and theme preferences) and is restored before the first paint.
|
|
2205
|
+
|
|
2206
|
+
**Exactly one grip is in the tab order** — the corner diagonally opposite the
|
|
2207
|
+
pinned one, so an arrow key changes the size and never the position. Eight
|
|
2208
|
+
separators between the transcript and the composer would be a keyboard
|
|
2209
|
+
obstacle rather than keyboard parity, and one grip already reaches both axes.
|
|
2210
|
+
Arrow keys resize from it (`Shift` for a larger step).
|
|
2211
|
+
|
|
2212
|
+
Each grip draws a short pill centred on its edge — a dot in a corner — on hover
|
|
2213
|
+
and focus as well as during a drag. **The hit area and the mark are separate on
|
|
2214
|
+
purpose**: the area is the whole strip, so the grip is easy to catch, while the
|
|
2215
|
+
mark stays small enough that it cannot be read as a border and never meets the
|
|
2216
|
+
panel's corner radius.
|
|
2217
|
+
|
|
2218
|
+
Every grip has its own part (`resize-handle-left`, `resize-handle-bottom-right`,
|
|
2219
|
+
and so on, plus `resize-handle` on all of them). Hit areas and marks are sized
|
|
2220
|
+
separately, so a coarser pointer can get a bigger target without a heavier mark.
|
|
2221
|
+
|
|
2222
|
+
```css
|
|
2223
|
+
ag-ui-chat {
|
|
2224
|
+
--ag-ui-grip-corner: 20px; /* the corner squares */
|
|
2225
|
+
--ag-ui-grip-edge: 10px; /* the edge strips */
|
|
2226
|
+
--ag-ui-grip-mark-length: 28px; /* the pill drawn inside them */
|
|
2227
|
+
--ag-ui-grip-mark-thickness: 3px;
|
|
2228
|
+
}
|
|
2229
|
+
```
|
|
2230
|
+
|
|
2231
|
+
---
|
|
2232
|
+
|
|
2233
|
+
## Markdown rendering
|
|
2234
|
+
|
|
2235
|
+
Assistant bubbles render sanitized markdown/HTML via [`marked`](https://www.npmjs.com/package/marked)
|
|
2236
|
+
(GitHub-flavoured, single-newline line breaks) piped through
|
|
2237
|
+
[DOMPurify](https://www.npmjs.com/package/dompurify). User messages stay literal text. The
|
|
2238
|
+
allowlist permits emphasis, code, lists, quotes, headings, links, tables, and — when `allowImages`
|
|
2239
|
+
is set — images; links are hardened with `target="_blank" rel="noopener noreferrer"`;
|
|
2240
|
+
`iframe`/`style`/scripting are excluded, as are every `data-*` and `aria-*` attribute and every
|
|
2241
|
+
`class` but a code fence's `language-*` hint, so model output cannot dress itself up as the
|
|
2242
|
+
component's own approval or tool-call chrome. The exported helper `renderMarkdown(text)` does this
|
|
2243
|
+
standalone. `marked` and `dompurify`
|
|
2244
|
+
are runtime dependencies.
|
|
2245
|
+
|
|
2246
|
+
An animated 3-dot "thinking" indicator (`role="status"`, with an aria-label) appears before the
|
|
2247
|
+
first token and between tool rounds, honouring `prefers-reduced-motion`. It has no public API.
|
|
2248
|
+
|
|
2249
|
+
### Incoming-text animations
|
|
2250
|
+
|
|
2251
|
+
The `data-text-animation` attribute controls how a fully-received assistant message reveals:
|
|
2252
|
+
`none` (default) / `fade` (a CSS fade) / `word` (JS word-by-word via the internal `wrapWords`
|
|
2253
|
+
reveal). It honours `prefers-reduced-motion` (collapsing to instant).
|
|
2254
|
+
|
|
2255
|
+
```html
|
|
2256
|
+
<ag-ui-chat endpoint="/agent/" data-text-animation="word"></ag-ui-chat>
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
---
|
|
2260
|
+
|
|
2261
|
+
## Follow-up suggestions
|
|
2262
|
+
|
|
2263
|
+
The agent can offer the *next* question, not just answer this one. Registered
|
|
2264
|
+
skill chips are static and host-configured, so they can say "summarize this" but
|
|
2265
|
+
never "want me to update the shipping address too?" after a tool has run.
|
|
2266
|
+
|
|
2267
|
+
The server pushes an `ACTIVITY_SNAPSHOT` under the `suggestions` type — with
|
|
2268
|
+
django-ag-ui, `suggestions_activity([...])`. The component draws each prompt as
|
|
2269
|
+
a chip; clicking one sends it as the user's message, exactly as if they had
|
|
2270
|
+
typed it.
|
|
2271
|
+
|
|
2272
|
+
```json
|
|
2273
|
+
{ "activityType": "suggestions",
|
|
2274
|
+
"content": { "prompts": ["Update the shipping address too"] } }
|
|
2275
|
+
```
|
|
2276
|
+
|
|
2277
|
+
**Chips are content.** They ride the same activity envelope as charts, so they
|
|
2278
|
+
persist and a reload puts them back, and a set pushed under an id already on
|
|
2279
|
+
screen replaces that row rather than adding a second one.
|
|
2280
|
+
|
|
2281
|
+
At most **4** prompts of **120** characters each — `MAX_SUGGESTIONS` and
|
|
2282
|
+
`MAX_SUGGESTION_CHARS`, both exported. The server-side helper *raises* past
|
|
2283
|
+
those bounds while this side silently drops: that asymmetry is deliberate, since
|
|
2284
|
+
the producer can report the problem and the client cannot.
|
|
2285
|
+
|
|
2286
|
+
Nothing to enable. A `suggestions` activity from a server that pushes one is
|
|
2287
|
+
drawn; an `activity_type` this component does not know is ignored, which is what
|
|
2288
|
+
the open field is for.
|
|
2289
|
+
|
|
2290
|
+
## Editing a gated call before approving it
|
|
2291
|
+
|
|
2292
|
+
AG-UI's resume payload carries `editedArgs`, and the protocol gates it on the
|
|
2293
|
+
agent's own `approveWithEdits` capability. The approval card can offer it:
|
|
2294
|
+
|
|
2295
|
+
```js
|
|
2296
|
+
chat.approveWithEdits = true; // your server accepts editedArgs
|
|
2297
|
+
```
|
|
2298
|
+
|
|
2299
|
+
**Off by default, and an assertion about your server rather than a
|
|
2300
|
+
negotiation** — capabilities are not on the wire this component reads, so it
|
|
2301
|
+
cannot check. Turned on against a server that ignores `editedArgs`, a user would
|
|
2302
|
+
edit arguments it silently discards, which is worse than not offering.
|
|
2303
|
+
|
|
2304
|
+
The card then shows the call's arguments as editable JSON. `editedArgs` rides
|
|
2305
|
+
the resume payload **only when something actually changed**, so a server can tell
|
|
2306
|
+
"approved as proposed" from "approved, but like this" without diffing what it
|
|
2307
|
+
already sent. Unparseable JSON, or JSON that is not an object, keeps the card
|
|
2308
|
+
open with the reason on it rather than approving the original behind the user's
|
|
2309
|
+
back.
|
|
2310
|
+
|
|
2311
|
+
Only offered for an interrupt naming a tool call this component holds a card
|
|
2312
|
+
for — the card is where the arguments still are.
|
|
2313
|
+
|
|
2314
|
+
## Localizing the timestamps
|
|
2315
|
+
|
|
2316
|
+
There is **no `Intl` anywhere in this component**. The relative timestamps in the
|
|
2317
|
+
thread drawer and checkpoint panel (`"5m ago"`, `"2d ago"`) are deliberately
|
|
2318
|
+
locale-neutral: a component that guessed a locale would disagree with the page
|
|
2319
|
+
it is embedded in, and being wrong in a second language is worse than being
|
|
2320
|
+
neutral in one.
|
|
2321
|
+
|
|
2322
|
+
That is a good default and a bad requirement, so it is replaceable:
|
|
2323
|
+
|
|
2324
|
+
```js
|
|
2325
|
+
const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
|
|
2326
|
+
chat.formatRelativeTime = (ts) =>
|
|
2327
|
+
rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
|
|
2328
|
+
```
|
|
2329
|
+
|
|
2330
|
+
`relativeTime` is exported too, for a host that wants to build on the built-in
|
|
2331
|
+
rather than replace it.
|
|
2332
|
+
|
|
2333
|
+
## Message actions: copy, retry, feedback
|
|
2334
|
+
|
|
2335
|
+
Every finished assistant message carries a small row of actions beneath it —
|
|
2336
|
+
a **sibling** of the bubble, never a child, so the buttons never join the
|
|
2337
|
+
message's own text.
|
|
2338
|
+
|
|
2339
|
+
- **Retry** re-asks the question. History is truncated to the most recent user
|
|
2340
|
+
message inclusive and the run repeats, so the agent answers what it was asked
|
|
2341
|
+
rather than being told its last answer was wrong.
|
|
2342
|
+
- **Copy** puts the message's text on the clipboard, and says so on the button.
|
|
2343
|
+
A refused clipboard permission is reported there too, rather than thrown.
|
|
2344
|
+
- **Thumbs up / down** fire `ag-ui-feedback` (wired below) and **store nothing**.
|
|
2345
|
+
**Off unless you ask for them** — `data-message-actions="copy,retry,feedback"`.
|
|
2346
|
+
Without a listener the buttons still latch, so a reader is told a rating was
|
|
2347
|
+
taken while nothing recorded it.
|
|
2348
|
+
|
|
2349
|
+
Retry sits on the **last** answer only. Re-running an older turn is branching,
|
|
2350
|
+
and for a page-driving agent editing a past turn is not neutral — those turns
|
|
2351
|
+
clicked buttons, and re-running turn 3 does not un-save what turn 5 saved.
|
|
2352
|
+
|
|
2353
|
+
> [!NOTE]
|
|
2354
|
+
> A retried turn **re-runs its tools.** The previous attempt already did what it
|
|
2355
|
+
> did, and this does not undo it. Confirmation still applies, so a destructive
|
|
2356
|
+
> tool asks again — unless the user waived it for this session with
|
|
2357
|
+
> *Always allow*.
|
|
2358
|
+
|
|
2359
|
+
A failed run gets the same row, with Retry and Copy and no rating: error text is
|
|
2360
|
+
what people paste into a bug report, but "the connection dropped" is not a
|
|
2361
|
+
statement about answer quality and mixing it into feedback makes that signal say
|
|
2362
|
+
less. This is why a dropped connection is still rendered as an **error** rather
|
|
2363
|
+
than demoted to a run notice — a notice "never settles, takes no action, and
|
|
2364
|
+
carries no controls", and a failure with a way back needs one.
|
|
2365
|
+
|
|
2366
|
+
The row can be trimmed, or removed, with `data-message-actions` — a comma list of
|
|
2367
|
+
the actions to keep, or `="false"` for none at all:
|
|
2368
|
+
|
|
2369
|
+
```html
|
|
2370
|
+
<!-- copy only: nothing here listens for a rating, and the surface forbids re-runs -->
|
|
2371
|
+
<ag-ui-chat endpoint="/agent/" data-message-actions="copy"></ag-ui-chat>
|
|
2372
|
+
```
|
|
2373
|
+
|
|
2374
|
+
The default is `copy,retry`. Those two work with nothing wired; the rating pair
|
|
2375
|
+
needs a listener, so it is asked for rather than assumed.
|
|
2376
|
+
|
|
2377
|
+
### What Copy puts on the clipboard
|
|
2378
|
+
|
|
2379
|
+
Both flavours of the message: `text/plain` for anywhere, and `text/html` for a
|
|
2380
|
+
target that understands it. A table therefore pastes into a spreadsheet as
|
|
2381
|
+
columns and into a document as a table.
|
|
2382
|
+
|
|
2383
|
+
The plain flavour is serialised structurally rather than read off `textContent`,
|
|
2384
|
+
which is the obvious source and loses everything: it concatenates descendants
|
|
2385
|
+
with no separator, so a table arrives as one unbroken run of cells with the
|
|
2386
|
+
headers welded to the first row. Rows are tab separated because that is what a
|
|
2387
|
+
spreadsheet splits on; list items keep their bullets and numbers; a code block
|
|
2388
|
+
keeps its own whitespace.
|
|
2389
|
+
|
|
2390
|
+
Both flavours are taken from a copy of the message with the component's own
|
|
2391
|
+
buttons removed. The code blocks' copy buttons live *inside* their `pre`, so
|
|
2392
|
+
they are descendants of the message: before this, copying an answer containing a
|
|
2393
|
+
code block copied the word Copy along with it.
|
|
2394
|
+
|
|
2395
|
+
A host that drives its own bar gets the same by passing `html` alongside `text`
|
|
2396
|
+
to `attachMessageActions`; with `text` alone the clipboard gets plain text only,
|
|
2397
|
+
exactly as before.
|
|
2398
|
+
|
|
2399
|
+
### Sizing and theming the row
|
|
2400
|
+
|
|
2401
|
+
The controls are icon-only, so the whole box is the target. It is sized from
|
|
2402
|
+
`--ag-ui-action-size`, with a floor rather than a fixed height so the compact
|
|
2403
|
+
density cannot shrink it below the 24px that keeps it reliably tappable.
|
|
2404
|
+
|
|
2405
|
+
Each control draws its own label on hover **and on keyboard focus**. That is not
|
|
2406
|
+
the `title` attribute repeated: a `title` never appears on focus, so an
|
|
2407
|
+
icon-only button is unnamed for anyone tabbing to it. On a touch device, where
|
|
2408
|
+
there is no hover to reveal it and it would sit over the answer, it is not
|
|
2409
|
+
drawn at all.
|
|
2410
|
+
|
|
2411
|
+
```css
|
|
2412
|
+
ag-ui-chat {
|
|
2413
|
+
--ag-ui-action-size: 28px; /* the control box; floors at 24px */
|
|
2414
|
+
--ag-ui-action-icon-size: 15px; /* the mark inside it */
|
|
2415
|
+
--ag-ui-tooltip-bg: #1f2430;
|
|
2416
|
+
--ag-ui-tooltip-fg: #f5f6fa;
|
|
2417
|
+
}
|
|
2418
|
+
```
|
|
2419
|
+
|
|
2420
|
+
To swap a mark for your own, style its part — `message-action-icon-copy` and its
|
|
2421
|
+
siblings. A slot would be the better channel and cannot be used: these repeat
|
|
2422
|
+
once per message, and a named slot can only be filled once.
|
|
2423
|
+
|
|
2424
|
+
It is per-action rather than one switch because the three disappear for
|
|
2425
|
+
different reasons. Thumbs are only useful to a host listening for
|
|
2426
|
+
`ag-ui-feedback`, and two buttons that lead nowhere are worse than none. Retry
|
|
2427
|
+
re-runs the agent, which a constrained surface may not permit. Copy is the one
|
|
2428
|
+
nobody objects to — and with a single switch, dropping either of the others would
|
|
2429
|
+
have cost it too. Nothing survives, and no row is built at all: an empty row
|
|
2430
|
+
still takes its margin and still announces itself as a group of actions.
|
|
2431
|
+
|
|
2432
|
+
`retryLastTurn()` is public, for a host driving its own message UI.
|
|
2433
|
+
|
|
2434
|
+
```js
|
|
2435
|
+
await chat.retryLastTurn(); // false when there is nothing to ask again, a run is in flight, or a restore is loading
|
|
2436
|
+
```
|
|
2437
|
+
|
|
2438
|
+
### Storing a rating
|
|
2439
|
+
|
|
2440
|
+
`ag-ui-feedback` names the message that was rated, so a host can file the
|
|
2441
|
+
rating against it rather than against its text — two answers can read the same,
|
|
2442
|
+
and nothing about the text survives a reload:
|
|
2443
|
+
|
|
2444
|
+
| Field | Type | What it is |
|
|
2445
|
+
| --- | --- | --- |
|
|
2446
|
+
| `messageId` | `string` | The AG-UI id of the rated assistant message: the id it streamed under (`TEXT_MESSAGE_START` / `TEXT_MESSAGE_END`), or the stored message's `id` when it was restored from history. A restored answer whose store returned no id offers no rating pair, since a rating has to name a message. |
|
|
2447
|
+
| `threadId` | `string` | The conversation it belongs to: the element's thread when the message was drawn. |
|
|
2448
|
+
| `runId` | `string`, optional | The run that streamed it, as that run's `RUN_STARTED` named it. Text a sub-agent streams through the parent run carries the parent's `runId`. **Absent on a restored message** — an AG-UI message carries no run id, so there is none to give and the key is left out rather than guessed. |
|
|
2449
|
+
| `content` | `string` | The message's text, as rendered. |
|
|
2450
|
+
| `rating` | `"up"` \| `"down"` | Which button. |
|
|
2451
|
+
|
|
2452
|
+
```js
|
|
2453
|
+
chat.setAttribute("data-message-actions", "copy,retry,feedback");
|
|
2454
|
+
chat.addEventListener("ag-ui-feedback", (e) => {
|
|
2455
|
+
const { messageId, threadId, runId, rating } = e.detail;
|
|
2456
|
+
// Keyed by message: rating the same answer again replaces the earlier rating.
|
|
2457
|
+
fetch(`/api/threads/${threadId}/ratings/${messageId}/`, {
|
|
2458
|
+
method: "PUT",
|
|
2459
|
+
headers: { "Content-Type": "application/json" },
|
|
2460
|
+
body: JSON.stringify({ rating, ...(runId === undefined ? {} : { runId }) }),
|
|
2461
|
+
});
|
|
2462
|
+
});
|
|
2463
|
+
```
|
|
2464
|
+
|
|
2465
|
+
> [!NOTE]
|
|
2466
|
+
> **A restored message is named by whatever `id` the conversation store returns
|
|
2467
|
+
> for it.** The built-in browser store saves the conversation as it streamed, so
|
|
2468
|
+
> there a message keeps its id across a reload. With `data-threads-url` or a
|
|
2469
|
+
> `conversationStore` of your own, the conversation comes back from your server,
|
|
2470
|
+
> and a message keeps its id only if the server persists the ids it streamed:
|
|
2471
|
+
> one that stores the conversation under fresh ids makes a rating made live and a
|
|
2472
|
+
> rating made after a reload name the same answer by two different ids. Live,
|
|
2473
|
+
> the id is always the one on the wire.
|
|
2474
|
+
|
|
2475
|
+
## Quoting a selection
|
|
2476
|
+
|
|
2477
|
+
Select any text in the transcript and a small **Quote** offer floats beside it.
|
|
2478
|
+
Taking it drops the selection into the composer as a markdown blockquote and
|
|
2479
|
+
leaves the caret on a fresh line under it — a quotation is how a question
|
|
2480
|
+
narrows to one part of an answer, so nothing is sent until you say what you are
|
|
2481
|
+
asking.
|
|
2482
|
+
|
|
2483
|
+
Quoting **appends**, after whatever is already typed, so a second quotation is a
|
|
2484
|
+
second thing being asked about rather than a replacement for the first. Long
|
|
2485
|
+
selections are capped at 500 characters: select-all-then-quote is a gesture the
|
|
2486
|
+
transcript already answers, and pasting the whole conversation back costs tokens
|
|
2487
|
+
to say nothing.
|
|
2488
|
+
|
|
2489
|
+
Set `data-quote-selection="false"` to turn the offer off. The `quote-selection`
|
|
2490
|
+
`part` styles it.
|
|
2491
|
+
|
|
2492
|
+
### The half that matters: selection in **your** page
|
|
2493
|
+
|
|
2494
|
+
The transcript is the easy half. A chat mounted beside a table, a diff or a
|
|
2495
|
+
report is sitting in the surface the user actually works in — and *that*
|
|
2496
|
+
selection is one no hosted chat can reach.
|
|
2497
|
+
|
|
2498
|
+
`offerQuoteInPage()` extends the same select-then-offer gesture to the whole
|
|
2499
|
+
page. It is opt-in, because it listens on your document:
|
|
2500
|
+
|
|
2501
|
+
```js
|
|
2502
|
+
const stop = chat.offerQuoteInPage(); // the whole page
|
|
2503
|
+
chat.offerQuoteInPage(document.querySelector("#report")); // or one region
|
|
2504
|
+
```
|
|
2505
|
+
|
|
2506
|
+
For a deliberate trigger instead of a selection, `quote(text)` is the seam
|
|
2507
|
+
underneath:
|
|
2508
|
+
|
|
2509
|
+
```js
|
|
2510
|
+
// "Ask about this row" — a button on each row of your own table.
|
|
2511
|
+
row.querySelector(".ask").addEventListener("click", () => {
|
|
2512
|
+
chat.quote(row.innerText);
|
|
2513
|
+
});
|
|
2514
|
+
```
|
|
2515
|
+
|
|
2516
|
+
`quote()` never sends — pair it with [`sendMessage()`](#sending-from-your-own-ui)
|
|
2517
|
+
if you want a one-click "explain this" that skips the composer entirely.
|
|
2518
|
+
|
|
2519
|
+
> **Do not write the four-line version of `offerQuoteInPage()`.**
|
|
2520
|
+
> A `mouseup` listener that quotes every settled selection appends to the
|
|
2521
|
+
> composer on every drag the user made to *read*, to copy, or to fix a typo —
|
|
2522
|
+
> and it cannot tell a selection in your prose from one inside the user's own
|
|
2523
|
+
> half-typed `<input>`, because Chrome reports a field's internal selection
|
|
2524
|
+
> through `document.getSelection()` as an ordinary range over the field's
|
|
2525
|
+
> **wrapper**. The text reads back perfectly and nothing about the range says
|
|
2526
|
+
> where it came from; the only signal is `document.activeElement`. That guard,
|
|
2527
|
+
> plus skipping the widget's own transcript, plus retiring a fixed-position
|
|
2528
|
+
> affordance on scroll, is what the method is for.
|
|
2529
|
+
|
|
2530
|
+
> **Reading a selection out of a shadow tree takes care too.**
|
|
2531
|
+
> Engines disagree about what `document.getSelection()` reports for a selection
|
|
2532
|
+
> made *inside* a shadow root: WebKit rescopes the endpoints to the host element,
|
|
2533
|
+
> so you get the whole widget and none of the words, while Chromium hands back
|
|
2534
|
+
> the shadow nodes directly. `getComposedRanges` settles it, and this component
|
|
2535
|
+
> uses it where it exists. `quotableSelection(container, roots)` is exported if
|
|
2536
|
+
> you have the same problem in your own component.
|
|
2537
|
+
|
|
2538
|
+
## Run notices: compaction and agent skills
|
|
2539
|
+
|
|
2540
|
+
Some things a run does are neither text nor a tool the user asked for — the server condensed
|
|
2541
|
+
earlier turns to fit the context window, or the model pulled in an agent skill. Those render as
|
|
2542
|
+
**run notices**: a muted one-line annotation inline in the transcript, styleable via the
|
|
2543
|
+
`run-notice`, `run-notice-icon` and `run-notice-text` `part`s.
|
|
2544
|
+
|
|
2545
|
+
> **Two different things are called "skills".**
|
|
2546
|
+
> The [prompt chips and slash palette](#skills-prompt-chips-and-slash-palette) below are a
|
|
2547
|
+
> **human** affordance — prompts *the user* launches. An **agent skill** is a folder of
|
|
2548
|
+
> instructions *the model* chooses to load mid-run. Only the second produces a run notice.
|
|
2549
|
+
|
|
2550
|
+
Neither notice needs configuration here — both appear when the server is set up to produce them.
|
|
2551
|
+
|
|
2552
|
+
**Compaction.** `django-ag-ui` emits a standard AG-UI `ACTIVITY_SNAPSHOT` with
|
|
2553
|
+
`activityType: "compaction"` when a compaction capability trimmed the history; the notice reports
|
|
2554
|
+
how many messages went. Server side, that means wrapping the capability in `CompactionObserver` —
|
|
2555
|
+
see [django-ag-ui's compaction guide](https://artui.github.io/django-ag-ui/compaction/). Activity
|
|
2556
|
+
events of any other type pass through untouched, so another producer on that channel is not
|
|
2557
|
+
mistaken for a compaction.
|
|
2558
|
+
|
|
2559
|
+
**Agent skills.** There is no dedicated event for these: loading a deferred capability *is* an
|
|
2560
|
+
ordinary `load_capability` tool call, which is what reaches the client. The component recognises
|
|
2561
|
+
it, renders `Using skill <id>`, and suppresses the raw tool card that would otherwise appear
|
|
2562
|
+
beside it — on the live stream and on restored history alike, so a reload shows the same
|
|
2563
|
+
transcript. A `load_capability` call with no usable id falls back to a normal tool card rather
|
|
2564
|
+
than being dropped, since it is still real activity.
|
|
2565
|
+
|
|
2566
|
+
Both strings are overridable like every other — `historyCompacted` (token `{count}`) and
|
|
2567
|
+
`usingSkill` (token `{name}`).
|
|
2568
|
+
|
|
2569
|
+
## Skills: prompt chips and slash palette
|
|
2570
|
+
|
|
2571
|
+
Skills are pre-defined prompts the user can launch from a chip or the `/`-command palette. They are
|
|
2572
|
+
opt-in via two attributes:
|
|
2573
|
+
|
|
2574
|
+
```html
|
|
2575
|
+
<ag-ui-chat endpoint="/agent/" data-prompt-chips="true" data-slash-commands="true"></ag-ui-chat>
|
|
2576
|
+
```
|
|
2577
|
+
|
|
2578
|
+
A `Skill` is `{ name, title, description?, prompt?, sendImmediately?, chip? }`. Skills are merged
|
|
2579
|
+
from three sources — **backend → embed → client** (later wins by `name`):
|
|
2580
|
+
|
|
2581
|
+
- `data-skills-url` — a JSON endpoint, fetched with the element's `headers`.
|
|
2582
|
+
- `data-skills` — an inline JSON catalog.
|
|
2583
|
+
- `setSkills(skills)` — set the client catalog from JS.
|
|
2584
|
+
|
|
2585
|
+
```js
|
|
2586
|
+
chat.setSkills([
|
|
2587
|
+
// Server-resolved: no prompt here, so picking it sends the bare "/triage"
|
|
2588
|
+
// token and the agent decides what it means.
|
|
2589
|
+
{ name: "triage", title: "Triage this", chip: true },
|
|
2590
|
+
// Client-side: the page owns the wording and fills the placeholders.
|
|
2591
|
+
{ name: "summarize", title: "Summarize page", prompt: "Summarize {title}.", chip: true },
|
|
2592
|
+
]);
|
|
2593
|
+
```
|
|
2594
|
+
|
|
2595
|
+
**Prefer omitting `prompt` for anything internal.** A skill is often where a project's workflow is
|
|
2596
|
+
written down most plainly, and a catalog is either a plain `GET` or sits in the page source — so
|
|
2597
|
+
shipping the wording to the browser publishes it. Without a `prompt` the component sends `/name`
|
|
2598
|
+
and the agent expands it (from the harness `Skills` capability, or your own instructions); the text
|
|
2599
|
+
never leaves the server. `django-ag-ui`'s `SkillRegistry` supports this by leaving `prompt` unset.
|
|
2600
|
+
|
|
2601
|
+
A skill that *does* carry a `prompt` may use `{placeholder}` tokens; the `skillContext` property
|
|
2602
|
+
(`() => Record<string, unknown>`) supplies the values, filled in before send. A missing placeholder
|
|
2603
|
+
blocks the send and shows a hint instead.
|
|
2604
|
+
|
|
2605
|
+
```js
|
|
2606
|
+
chat.skillContext = () => ({ title: document.title });
|
|
2607
|
+
```
|
|
2608
|
+
|
|
2609
|
+
**Picking a skill sends it.** A chip that needs a second click to do anything is a two-step
|
|
2610
|
+
shortcut. Set `sendImmediately: false` on a prompt-carrying skill to pre-fill the composer instead —
|
|
2611
|
+
useful when the user is expected to edit before sending. A server-resolved skill always sends.
|
|
2612
|
+
|
|
2613
|
+
---
|
|
2614
|
+
|
|
2615
|
+
## MPA durability: surviving full page reloads
|
|
2616
|
+
|
|
2617
|
+
In a multi-page app, a tool that navigates reloads the whole page and destroys the in-memory run
|
|
2618
|
+
loop. The package keeps the conversation continuous across that boundary with three generic
|
|
2619
|
+
mechanisms.
|
|
2620
|
+
|
|
2621
|
+
**1. Thread identity.** AG-UI's `thread_id` is the conversation key. It is generated once and
|
|
2622
|
+
persisted (so the element reattaches after a reload) by the
|
|
2623
|
+
[`ClientConversationStore`](src/core/conversation_store.ts).
|
|
2624
|
+
|
|
2625
|
+
**2. Durable conversation.** A pluggable `ClientConversationStore` holds the message list. The
|
|
2626
|
+
default [`SessionStorageStore`](src/core/conversation_store.ts) keeps everything per-tab in
|
|
2627
|
+
`sessionStorage`, so the chat survives full page reloads and clears on tab close. `loadMessages`
|
|
2628
|
+
is async-friendly, so a host can inject a server-backed store (e.g. one that rehydrates from a
|
|
2629
|
+
history endpoint) for cross-tab/device durability:
|
|
2630
|
+
|
|
2631
|
+
```js
|
|
2632
|
+
chat.conversationStore = new MyServerBackedStore();
|
|
2633
|
+
```
|
|
2634
|
+
|
|
2635
|
+
On mount the element rehydrates the transcript from the store, so the chat looks continuous —
|
|
2636
|
+
including tool-call cards and their results (reconstructed from the persisted `toolCalls` and `tool`
|
|
2637
|
+
messages), not just the text turns.
|
|
2638
|
+
|
|
2639
|
+
A tool message is saved with an **`outcome`** in its `metadata` when the call did not simply
|
|
2640
|
+
succeed, in the same vocabulary the wire uses (see [A tool call that failed](#a-tool-call-that-failed)),
|
|
2641
|
+
so a failed or declined card replays as failed or declined instead of turning green on reload. A user
|
|
2642
|
+
message keeps the refs of its uploads the same way, under `attachments` in its `metadata`. Both
|
|
2643
|
+
round-trip through `JSON.stringify` in the default store. **A store that drops `metadata` loses only
|
|
2644
|
+
the distinction** — the card falls back to *done*, which is what it did before, and the bubble loses
|
|
2645
|
+
its chips.
|
|
2646
|
+
|
|
2647
|
+
What is saved is exactly what the next request sends, so both go back to the server on every run.
|
|
2648
|
+
That is the protocol's own behaviour for a declared field rather than a client-side addition: a server
|
|
2649
|
+
validates `metadata` as an open object, and one that does not read a key ignores it — django-ag-ui
|
|
2650
|
+
reads `attachments` and ignores `outcome`.
|
|
2651
|
+
|
|
2652
|
+
A conversation stored by an earlier release has both keys at the top level of the message instead.
|
|
2653
|
+
The history replay reads them from there when the metadata has none, and a client seeded from such a
|
|
2654
|
+
conversation moves them into `metadata` before its first request, because `@ag-ui/client` 1.0 would
|
|
2655
|
+
strip them from the request as it stands. The next save writes them in the new shape.
|
|
2656
|
+
|
|
2657
|
+
**3. Resumable loop (`x-navigates` + `navigationResult`).** A tool whose schema carries
|
|
2658
|
+
`x-navigates: true` (use `X_NAVIGATES_KEY`; read back by [`isNavigates`](src/tools/is_navigates.ts))
|
|
2659
|
+
triggers a full reload. Before the handler navigates, the element writes a checkpoint
|
|
2660
|
+
(`{ toolCallId }`) to the store. On the next page mount it:
|
|
2661
|
+
|
|
2662
|
+
1. restores the transcript,
|
|
2663
|
+
2. completes the dangling navigating tool call by supplying a result built from the landed page
|
|
2664
|
+
via the overridable **`navigationResult(checkpoint)`** callback (defaults to
|
|
2665
|
+
`{ navigated: true, url }`; a host can return a page snapshot or post-reload validation errors
|
|
2666
|
+
instead),
|
|
2667
|
+
3. and resumes the run loop from there.
|
|
2668
|
+
|
|
2669
|
+
The MPA round-trip becomes a clean observation point instead of a dropped conversation.
|
|
2670
|
+
|
|
2671
|
+
**A reload the run did not expect settles every open call as not finished.** A round's history is
|
|
2672
|
+
saved when its stream ends, before the element asks about a gated call, runs a frontend tool or
|
|
2673
|
+
collects a server-side approval, so a reload in that window finds the round's calls with no result
|
|
2674
|
+
and no run left to produce one. On restore, every call without a result (other than a checkpointed
|
|
2675
|
+
navigating call) is answered the way the client answers an open call before a request: its card
|
|
2676
|
+
settles as `interrupted` ("not finished"), and the restored conversation gains the `callNotFinished`
|
|
2677
|
+
result with the outcome `interrupted`, so the next request carries a result for every call and a
|
|
2678
|
+
later reload shows the same.
|
|
2679
|
+
|
|
2680
|
+
It is not declined, although Stop declines an open card. Pressing Stop is a person answering the
|
|
2681
|
+
question; a reload answers nothing. The saved history is the same whether the round was waiting on a
|
|
2682
|
+
person or on a frontend tool the reload killed, so a decline would be unproven for one and false for
|
|
2683
|
+
the other, and "not finished" is true of both. The same goes for a server-side approval left open.
|
|
2684
|
+
|
|
2685
|
+
### Who the stored conversation belongs to (`user-key`)
|
|
2686
|
+
|
|
2687
|
+
`sessionStorage` is scoped to a tab, not to a session. It survives every same-tab navigation,
|
|
2688
|
+
and a logout is a navigation — so on a shared workstation, one user's transcript is still sitting
|
|
2689
|
+
there when the next user signs in and the chat mounts again. Transcripts routinely quote record
|
|
2690
|
+
data, so treat that as the default and turn it off:
|
|
2691
|
+
|
|
2692
|
+
```html
|
|
2693
|
+
<ag-ui-chat endpoint="/agent/" user-key="{{ request.user.pk }}"></ag-ui-chat>
|
|
2694
|
+
```
|
|
2695
|
+
|
|
2696
|
+
The value is any string that identifies the principal — a user id, an account id, a hash of one.
|
|
2697
|
+
It joins the storage namespace, so two principals in the same tab cannot reach each other's
|
|
2698
|
+
conversation, and **changing it purges everything the previous principal stored**: transcript,
|
|
2699
|
+
history drawer index and navigation checkpoints, for this element's namespace only. The tools they
|
|
2700
|
+
waived with *Always allow* are forgotten with it, so the next principal is asked again, and so is
|
|
2701
|
+
[`sharedState`](#host-seams-the-spa-story): whatever the agent last wrote into it for them is cleared rather than
|
|
2702
|
+
sent on the next principal's first run, so a host that seeds shared state assigns it again for the
|
|
2703
|
+
principal who arrived.
|
|
2704
|
+
|
|
2705
|
+
A handover also ends what the previous principal had in flight. Their run is stopped, and nothing
|
|
2706
|
+
it does afterwards is written into the next principal's storage or shared state: not the save a
|
|
2707
|
+
stopped run makes once its request closes, not a tool result that comes back after the Stop, not
|
|
2708
|
+
state already on its way. A tool call still waiting on `confirmPredicate` or its confirmation card
|
|
2709
|
+
does not run, one whose handler is still running draws nothing when it returns, and a navigating
|
|
2710
|
+
tool's checkpoint is never written into the next principal's thread. What they had typed into the
|
|
2711
|
+
composer and not sent is cleared with the transcript, quotations and skill templates included. A
|
|
2712
|
+
voice recording in progress is stopped before it is transcribed, and a clip already sent for
|
|
2713
|
+
transcription is dropped when its transcript comes back.
|
|
2714
|
+
|
|
2715
|
+
Set it live, from script, as part of signing out or in:
|
|
2716
|
+
|
|
2717
|
+
```js
|
|
2718
|
+
chat.userKey = String(session.userId); // or "" on sign-out
|
|
2719
|
+
```
|
|
2720
|
+
|
|
2721
|
+
That is why it is a live attribute rather than a connect-time one. A single-page app signs a user
|
|
2722
|
+
out through its own router without remounting anything, so the host naming the new principal — or
|
|
2723
|
+
dropping the attribute — is the only signal the element will ever get. Removing the attribute
|
|
2724
|
+
purges too, so a sign-out that simply clears it is safe.
|
|
2725
|
+
|
|
2726
|
+
It holds for an element that is out of the document too, such as a view a router keeps alive.
|
|
2727
|
+
Nothing is purged while it is detached: the handover runs when it is inserted again, against the
|
|
2728
|
+
key it has by then, so a key that changes and changes back while detached hands nothing over. An
|
|
2729
|
+
element that is never inserted again leaves the previous principal's conversation in
|
|
2730
|
+
`sessionStorage` under their key, where only an element naming that principal again can reach it.
|
|
2731
|
+
|
|
2732
|
+
The **first** value to arrive is treated as a host naming the user who was already there, not as a
|
|
2733
|
+
handover: the conversation in progress moves into the principal's namespace instead of being
|
|
2734
|
+
destroyed. So an element configured by an async auth handshake — the shape described in
|
|
2735
|
+
[Framework hosts](#framework-hosts-configure-before-you-insert) — keeps what is on screen,
|
|
2736
|
+
including a run still streaming and whatever is in the composer.
|
|
2737
|
+
|
|
2738
|
+
Two things it deliberately does not do. It does not scope the panel's own collapsed / dragged-size
|
|
2739
|
+
/ theme preferences, which are this element's UI state and carry no conversation content. And it
|
|
2740
|
+
does not encrypt or hide anything from the page: any script on the origin can still read
|
|
2741
|
+
`sessionStorage`. It scopes and it purges.
|
|
2742
|
+
|
|
2743
|
+
**Without it, nothing changes** — including the carry-over above. A conversation is scoped to the
|
|
2744
|
+
element and to nobody in particular, and on a shared workstation it will be there for whoever signs
|
|
2745
|
+
in next in the same tab.
|
|
2746
|
+
|
|
2747
|
+
For a deployment that keeps history server-side, `data-threads-cache="false"` stops the local
|
|
2748
|
+
mirror of the message bodies as well, so choosing `data-threads-url` actually keeps transcripts off
|
|
2749
|
+
the client:
|
|
2750
|
+
|
|
2751
|
+
```html
|
|
2752
|
+
<ag-ui-chat endpoint="/agent/" data-threads-url="/agent/threads/" data-threads-cache="false">
|
|
2753
|
+
</ag-ui-chat>
|
|
2754
|
+
```
|
|
2755
|
+
|
|
2756
|
+
The client-only concerns (the active thread id, the navigation checkpoint) keep their local store
|
|
2757
|
+
either way, so reloads and navigating tools still work. What is lost is the offline fallback: when
|
|
2758
|
+
the thread endpoint is unreachable the transcript comes back empty rather than stale, and the
|
|
2759
|
+
drawer's offline list loses its previews — a preview being an excerpt of a message, which is the
|
|
2760
|
+
thing being kept off the client. Constructing the store yourself takes the same option:
|
|
2761
|
+
|
|
2762
|
+
```js
|
|
2763
|
+
chat.conversationStore = new RemoteConversationStore(
|
|
2764
|
+
"/agent/threads/",
|
|
2765
|
+
() => ({ "X-CSRFToken": token }),
|
|
2766
|
+
new SessionStorageStore(),
|
|
2767
|
+
() => "same-origin",
|
|
2768
|
+
false, // cacheMessages
|
|
2769
|
+
);
|
|
2770
|
+
```
|
|
2771
|
+
|
|
2772
|
+
A conversation store you assign yourself is never replaced by a change of `user-key`, whether you
|
|
2773
|
+
assign it before the element connects or after. The one exception is a plain `SessionStorageStore`
|
|
2774
|
+
assigned before the element connects: that is the same kind as the element's own default, which is
|
|
2775
|
+
all connecting can see, so the element namespaces it as its own and moves it on every key change.
|
|
2776
|
+
Assign it after connecting, or wrap it, to keep it yours. A store that keeps its data somewhere
|
|
2777
|
+
the element cannot see has to scope itself; the element purges only its own `sessionStorage`
|
|
2778
|
+
namespace and clears what is on screen. If your store wraps a `SessionStorageStore`, build a new
|
|
2779
|
+
one per principal.
|
|
2780
|
+
|
|
2781
|
+
`SessionStorageStore.purge(namespace)` is the same primitive the element uses, for a host driving
|
|
2782
|
+
its own store from its own sign-out path.
|
|
2783
|
+
|
|
2784
|
+
### Mounting more than one chat on a page
|
|
2785
|
+
|
|
2786
|
+
Give each `<ag-ui-chat>` its own `id`. The storage namespace is the element's `id`, falling back to
|
|
2787
|
+
its `endpoint` — so two elements with no `id` against the same agent mount (a docked support panel
|
|
2788
|
+
and an inline page assistant, say) would resolve to the same namespace and share a thread pointer,
|
|
2789
|
+
a history drawer and every message key.
|
|
2790
|
+
|
|
2791
|
+
They no longer do: the first element to mount keeps the namespace, and a second is given a
|
|
2792
|
+
throwaway one of its own plus a console warning. That keeps the two conversations apart, but the
|
|
2793
|
+
throwaway namespace is minted per mount, so the second element will not restore its conversation
|
|
2794
|
+
across a reload until it has an `id`.
|
|
2795
|
+
|
|
2796
|
+
---
|
|
2797
|
+
|
|
2798
|
+
## Host seams: the SPA story
|
|
2799
|
+
|
|
2800
|
+
`<ag-ui-chat>` is a generic embedding kit; these typed seams let a host feed the agent richer
|
|
2801
|
+
context up front so it explores less. All are framework-free and admin-agnostic.
|
|
2802
|
+
|
|
2803
|
+
**`routeMap: RouteMap`** — a manifest of navigable routes (`{ id, path, title?, group?,
|
|
2804
|
+
description? }`). When set, the element exposes two built-in tools so the agent navigates by intent
|
|
2805
|
+
rather than by exploring:
|
|
2806
|
+
|
|
2807
|
+
- `list_routes` — read-only; lists the routes.
|
|
2808
|
+
- `navigate_to_route(route_id, params?)` — resolves the id to a path and navigates.
|
|
2809
|
+
|
|
2810
|
+
A route `path` may contain `:param` segments — e.g. `/projects/:id/users/:userId/`.
|
|
2811
|
+
`navigate_to_route` substitutes the path params (URL-encoded) and sends any leftover params as a
|
|
2812
|
+
query string; a missing or empty required path param throws. The resolved shape is the exported
|
|
2813
|
+
`RouteWithParams` type.
|
|
2814
|
+
|
|
2815
|
+
```js
|
|
2816
|
+
chat.routeMap = [
|
|
2817
|
+
{ id: "users", path: "/users", title: "Users", description: "Manage user accounts" },
|
|
2818
|
+
{ id: "billing", path: "/billing", title: "Billing" },
|
|
2819
|
+
{ id: "user-detail", path: "/projects/:id/users/:userId/", title: "User detail" },
|
|
2820
|
+
];
|
|
2821
|
+
```
|
|
2822
|
+
|
|
2823
|
+
**`getPageMap(): PageMap`** — a per-run provider returning the current page's compact actionable
|
|
2824
|
+
surface (field names/types/labels, button labels+handles — *not* values). It is auto-injected into
|
|
2825
|
+
each run's `context` as a `page_map` entry (toggle with `autoInjectPageMap`):
|
|
2826
|
+
|
|
2827
|
+
```js
|
|
2828
|
+
chat.getPageMap = () => ({ fields: introspectForm(), buttons: visibleButtons() });
|
|
2829
|
+
```
|
|
2830
|
+
|
|
2831
|
+
It is recomputed at the top of **every tool round**, not once per `send()` — so after the agent
|
|
2832
|
+
acts, the next round already sees the resulting page. Within a round the agent can pull a fresh
|
|
2833
|
+
view at any time with the built-in `read_page` tool, which is registered whenever this provider is
|
|
2834
|
+
set.
|
|
2835
|
+
|
|
2836
|
+
**Check that your server reads `context` at all — pydantic-ai's AG-UI adapter does not.** The
|
|
2837
|
+
auto-injected `page_map` rides in `RunAgentInput.context`, and an adapter that ignores that field
|
|
2838
|
+
drops it silently: nothing errors, and the model simply never sees the page. On such a backend
|
|
2839
|
+
`read_page` is the channel that works, and it is the one to rely on. Nothing to configure — just do
|
|
2840
|
+
not assume the injected copy arrived, and if the page map matters to your prompt, put it there
|
|
2841
|
+
server-side or let the agent call `read_page`.
|
|
2842
|
+
|
|
2843
|
+
That leaves one window: the page can move *after* a round's context was built but *before* the
|
|
2844
|
+
agent's tool call arrives — the user clicks a link, or presses back. Calls landing in that window
|
|
2845
|
+
are **refused** with a result telling the agent to call `read_page` and retry. Most would have
|
|
2846
|
+
missed anyway; the guard exists for the case where a same-named control on the new page matches and
|
|
2847
|
+
the agent would otherwise act on the wrong page without either side noticing. `read_page` and tools
|
|
2848
|
+
marked `x-navigates` are exempt. The guard runs in any round that offered `read_page`, including
|
|
2849
|
+
one you registered yourself, and is inert in a round that did not (no `getPageMap`, or a
|
|
2850
|
+
[place the user withheld](#where-the-user-is-the-place-chip)), since its result would name a tool
|
|
2851
|
+
the agent does not have.
|
|
2852
|
+
|
|
2853
|
+
**`registerPageState({ name, read, write?, schema? })`** — ergonomic sugar over `registerTool` for
|
|
2854
|
+
SPA app state (Redux/Zustand/signals). It auto-generates a `read_<name>` (read-only) tool and, when
|
|
2855
|
+
`write` is supplied, a `set_<name>` tool stamped `x-destructive`:
|
|
2856
|
+
|
|
2857
|
+
```js
|
|
2858
|
+
chat.registerPageState({
|
|
2859
|
+
name: "cart",
|
|
2860
|
+
read: () => store.getState().cart,
|
|
2861
|
+
write: ({ items }) => store.dispatch(setCart(items)),
|
|
2862
|
+
schema: { type: "object", properties: { items: { type: "array" } } },
|
|
2863
|
+
});
|
|
2864
|
+
```
|
|
2865
|
+
|
|
2866
|
+
**This is not AG-UI shared state** — that is `sharedState`, below. `registerPageState` generates two
|
|
2867
|
+
ordinary client tools; the agent reads or writes your store by *calling a tool*.
|
|
2868
|
+
|
|
2869
|
+
**`sharedState`** *(property)* — AG-UI **shared state**: the protocol's own state channel, sent as
|
|
2870
|
+
`RunAgentInput.state` on every run and replaced in place when the server streams `STATE_SNAPSHOT` /
|
|
2871
|
+
`STATE_DELTA`. Assign to seed it, listen for `ag-ui-state` to react:
|
|
2872
|
+
|
|
2873
|
+
```js
|
|
2874
|
+
chat.sharedState = { document: "" };
|
|
2875
|
+
|
|
2876
|
+
chat.addEventListener("ag-ui-state", (e) => {
|
|
2877
|
+
editor.value = e.detail.state.document; // the agent rewrote it
|
|
2878
|
+
});
|
|
2879
|
+
```
|
|
2880
|
+
|
|
2881
|
+
Server-side, a tool mutates `ctx.deps.state` and returns the snapshot as `ToolReturn` metadata —
|
|
2882
|
+
pydantic-ai does not emit deltas for you:
|
|
2883
|
+
|
|
2884
|
+
```python
|
|
2885
|
+
@tool(registry)
|
|
2886
|
+
async def write_document(ctx: RunContext[AgentDeps], body: str) -> ToolReturn:
|
|
2887
|
+
"""Replace the shared document."""
|
|
2888
|
+
ctx.deps.state = {**(ctx.deps.state or {}), "document": body}
|
|
2889
|
+
return ToolReturn(
|
|
2890
|
+
return_value="written",
|
|
2891
|
+
metadata=[StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=ctx.deps.state)],
|
|
2892
|
+
)
|
|
2893
|
+
```
|
|
2894
|
+
|
|
2895
|
+
Use this when the agent and the page are editing **the same object** (a document, a form, a
|
|
2896
|
+
canvas). Because it is the page's object rather than the conversation's, **New chat keeps it**; a
|
|
2897
|
+
change of [`user-key`](#who-the-stored-conversation-belongs-to-user-key) clears it. Use
|
|
2898
|
+
`registerPageState` when the agent should *ask* for a value or *request* a change —
|
|
2899
|
+
the tool call is visible in the transcript and can be gated by a confirmation card, which state
|
|
2900
|
+
events cannot.
|
|
2901
|
+
|
|
2902
|
+
**`navigate(path): void`** *(optional)* — a host routing callback. **This single seam is what
|
|
2903
|
+
distinguishes an SPA from an MPA.** When set, `navigate_to_route` routes client-side (no reload) and
|
|
2904
|
+
the in-memory run loop simply continues — the whole resumable-loop / checkpoint machinery is
|
|
2905
|
+
bypassed. When unset, navigation falls back to `window.location` and the MPA reload model above
|
|
2906
|
+
applies.
|
|
2907
|
+
|
|
2908
|
+
```js
|
|
2909
|
+
chat.navigate = (path) => router.push(path); // SPA: in-page, no reload
|
|
2910
|
+
// leave unset for an MPA: window.location + checkpoint/resume
|
|
2911
|
+
```
|
|
2912
|
+
|
|
2913
|
+
Route map + `navigate()` and the reload model are the same feature seen from two ends.
|
|
2914
|
+
|
|
2915
|
+
**`ag-ui-run-finished`** *(event)* — an interaction has ended, and here is what ran in it.
|
|
2916
|
+
`detail: { threadId, tools: readonly ToolRun[], invalidated, outcome }` (typed `RunFinishedDetail`),
|
|
2917
|
+
where each `ToolRun` is `{ name, side: "server" | "client" }` in settle order. `threadId` is the
|
|
2918
|
+
thread the run belonged to, which is not always the one on screen: a run that a new chat or a
|
|
2919
|
+
thread switch stopped still reports once its request closes. `outcome` is how the interaction
|
|
2920
|
+
ended: `"completed"`, `"stopped"` (Stop, a new chat or a thread switch cut it off) or `"failed"`
|
|
2921
|
+
(the agent reported an error). Ignore an `outcome` you do not know: more may be added.
|
|
2922
|
+
|
|
2923
|
+
**This is the seam for a host that renders data the agent can change.** A server-side tool writes
|
|
2924
|
+
without your page's knowledge: nothing else the element dispatches implies "something may have moved
|
|
2925
|
+
underneath you", so a page that fetched its data on mount has no reason to refetch and quietly goes
|
|
2926
|
+
stale. Approve a server-side booking on a calendar and the row exists while the calendar keeps
|
|
2927
|
+
showing the week it loaded.
|
|
2928
|
+
|
|
2929
|
+
```js
|
|
2930
|
+
chat.addEventListener("ag-ui-run-finished", (e) => {
|
|
2931
|
+
// A "client" tool ran in your own handler, so you already know what it did.
|
|
2932
|
+
if (e.detail.tools.some((tool) => tool.side === "server")) {
|
|
2933
|
+
void refetchBoard();
|
|
2934
|
+
}
|
|
2935
|
+
});
|
|
2936
|
+
```
|
|
2937
|
+
|
|
2938
|
+
It fires **once per interaction**, not once per tool round, and it fires on completion, error and
|
|
2939
|
+
cancellation alike — a partial write is still a write. A capability load (an agent skill activating)
|
|
2940
|
+
is not counted: it moves nothing a host renders.
|
|
2941
|
+
|
|
2942
|
+
A run a switch stopped reports `outcome: "stopped"` whichever thread is on screen when it does,
|
|
2943
|
+
so a listener that marks attention or counts completions tells a cancelled run's late event from a
|
|
2944
|
+
finished one by `outcome`, not by `threadId`.
|
|
2945
|
+
|
|
2946
|
+
**Deferring a switch until the run is over** keys on `running` and `data-running`, never on this
|
|
2947
|
+
event: it fires for a stopped run too, late, and a stale one can arrive while the next run is live.
|
|
2948
|
+
`data-running` toggles only for the live run, and there is no running-change event, so watch the
|
|
2949
|
+
attribute:
|
|
2950
|
+
|
|
2951
|
+
```js
|
|
2952
|
+
let queued = null;
|
|
2953
|
+
const release = () => {
|
|
2954
|
+
if (!chat.running && queued !== null) {
|
|
2955
|
+
const id = queued;
|
|
2956
|
+
queued = null;
|
|
2957
|
+
void chat.switchThread(id);
|
|
2958
|
+
}
|
|
2959
|
+
};
|
|
2960
|
+
new MutationObserver(release).observe(chat, { attributes: true, attributeFilter: ["data-running"] });
|
|
2961
|
+
myBar.onPick = (id) => {
|
|
2962
|
+
if (chat.running) {
|
|
2963
|
+
queued = id;
|
|
2964
|
+
} else {
|
|
2965
|
+
void chat.switchThread(id);
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
```
|
|
2969
|
+
|
|
2970
|
+
`sharedState` above is the richer channel and this is not a replacement for it — but it is not a
|
|
2971
|
+
substitute the other way round either, because shared state requires the *agent* to emit
|
|
2972
|
+
`STATE_SNAPSHOT`, which is not the host's decision to make. Use state when the two ends edit one
|
|
2973
|
+
object; use this when your page owns the data and just needs to know it moved.
|
|
2974
|
+
|
|
2975
|
+
**`ag-ui-custom`** *(event)* — the agent sent an AG-UI `CUSTOM` event.
|
|
2976
|
+
`detail: { name, value }` (typed `CustomAgentDetail`), both verbatim and uninterpreted.
|
|
2977
|
+
|
|
2978
|
+
```js
|
|
2979
|
+
chat.addEventListener("ag-ui-custom", (e) => {
|
|
2980
|
+
if (e.detail.name === "invalidate") {
|
|
2981
|
+
void refetch(e.detail.value);
|
|
2982
|
+
}
|
|
2983
|
+
// Any other name: no listener, nothing happens. That is the intended outcome.
|
|
2984
|
+
});
|
|
2985
|
+
```
|
|
2986
|
+
|
|
2987
|
+
`CUSTOM` is one of exactly two AG-UI carriers whose payload name is an open
|
|
2988
|
+
string the protocol does not enumerate, and it is the **imperative** one:
|
|
2989
|
+
something for your page to *do*. Its sibling `ACTIVITY_SNAPSHOT` carries
|
|
2990
|
+
transcript **content**, which is why an activity is materialised into a message,
|
|
2991
|
+
persisted with the thread and replayed on restore, and this is not.
|
|
2992
|
+
|
|
2993
|
+
**That asymmetry is the rule for choosing between them.** Content has a place in
|
|
2994
|
+
the conversation and should replay. An imperative has no place and no meaning
|
|
2995
|
+
once acted on — replaying "refetch the board" on every thread load is a bug, not
|
|
2996
|
+
a feature. If it must survive a reload, it belongs on the other carrier.
|
|
2997
|
+
|
|
2998
|
+
The element takes no view of what a name means and forwards every one, so a name
|
|
2999
|
+
it has never heard of reaches you unchanged. A host with no listener for a name
|
|
3000
|
+
simply ignores it, which is the graceful outcome an open field exists for.
|
|
3001
|
+
|
|
3002
|
+
**`ag-ui-invalidate`** *(event)* — the agent named resources its write moved.
|
|
3003
|
+
`detail: { keys, reason }` (typed `InvalidateDetail`).
|
|
3004
|
+
|
|
3005
|
+
One `CUSTOM` name routed to its own event so you do not have to string-match;
|
|
3006
|
+
every other name still arrives as `ag-ui-custom`. It fires **as each
|
|
3007
|
+
announcement arrives**, during the run, and the same keys ride
|
|
3008
|
+
`ag-ui-run-finished` again at the end as `invalidated`, de-duplicated.
|
|
3009
|
+
|
|
3010
|
+
> **Do not reload the page on this.** The user was probably typing. An
|
|
3011
|
+
> agent-triggered reload or a blind refetch into a live form destroys unsaved
|
|
3012
|
+
> input, and from their side the page threw their work away on its own. Check
|
|
3013
|
+
> first, and offer rather than act:
|
|
3014
|
+
|
|
3015
|
+
```js
|
|
3016
|
+
chat.addEventListener("ag-ui-invalidate", (e) => {
|
|
3017
|
+
if (formIsDirty()) {
|
|
3018
|
+
showBanner("This data changed. Refresh when you're ready.", e.detail.keys);
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
refetch(e.detail.keys); // e.detail.keys → ["orders", "orders/42"]
|
|
3022
|
+
});
|
|
3023
|
+
```
|
|
3024
|
+
|
|
3025
|
+
**Keys are opaque and matching is exact.** `orders/42` does not imply `orders` —
|
|
3026
|
+
a prefix rule would be the component guessing at a scheme it does not own, and
|
|
3027
|
+
`orders/1` would match `orders/11`. A server that wants the collection refreshed
|
|
3028
|
+
names it. Your own matching may be hierarchical, because in your vocabulary the
|
|
3029
|
+
scheme is known; that is what TanStack query keys are built for.
|
|
3030
|
+
|
|
3031
|
+
Already listening on `ag-ui-run-finished`? Upgrading is one line, and the `else`
|
|
3032
|
+
is the whole compatibility story:
|
|
3033
|
+
|
|
3034
|
+
```js
|
|
3035
|
+
if (detail.invalidated.length > 0) refetchOnly(detail.invalidated);
|
|
3036
|
+
else if (detail.tools.some((t) => t.side === "server")) refetchEverything();
|
|
3037
|
+
```
|
|
3038
|
+
|
|
3039
|
+
| Server | Client | Result |
|
|
3040
|
+
| --- | --- | --- |
|
|
3041
|
+
| old | old | coarse refetch, as today |
|
|
3042
|
+
| new | old | the `CUSTOM` event is ignored; coarse refetch still fires |
|
|
3043
|
+
| old | new | `invalidated` is empty; the `else` branch runs |
|
|
3044
|
+
| new | new | precise, and live during the run |
|
|
3045
|
+
|
|
3046
|
+
Nothing negotiates and nothing handshakes, which is what makes this shippable
|
|
3047
|
+
across repos with independent release cadences.
|
|
3048
|
+
|
|
3049
|
+
**Note:** it reaches **the page that started the run**, during the run. There is one
|
|
3050
|
+
response stream per run and no channel to anybody else's browser.
|
|
3051
|
+
|
|
3052
|
+
### Where the user is: the place chip
|
|
3053
|
+
|
|
3054
|
+
The composer can say where in your app the user is writing from: a chip in its tool row, between the
|
|
3055
|
+
attach and voice controls and Send, naming the page, tab or record on screen. The same read is sent
|
|
3056
|
+
to the agent as one context entry, so the place the user sees is the place the agent is told.
|
|
3057
|
+
|
|
3058
|
+
**`getPlace: () => Place | null`** returns a `Place`: `{ label, detail?, icon?, href? }`. The chip
|
|
3059
|
+
draws the label, the detail after a dot, and the icon (an image URL; a pin when absent). The agent
|
|
3060
|
+
gets a `place` context entry whose value is the JSON of the label, detail and href; the icon is
|
|
3061
|
+
never sent. The href identifies the place; without one, the label and detail do.
|
|
3062
|
+
|
|
3063
|
+
```js
|
|
3064
|
+
chat.getPlace = () => ({
|
|
3065
|
+
label: `Order #${order.number}`,
|
|
3066
|
+
detail: "Orders",
|
|
3067
|
+
href: location.pathname,
|
|
3068
|
+
});
|
|
3069
|
+
```
|
|
3070
|
+
|
|
3071
|
+
- **With no `getPlace`, a route map stands in.** The `routeMap` route whose path matches
|
|
3072
|
+
`location.pathname` names the place: its `title` (else its `id`) as the label, its `group` as the
|
|
3073
|
+
detail, and the path as the href. A host with a route map gets the chip without writing anything.
|
|
3074
|
+
With neither, no chip is drawn and nothing is sent; set `getPlace = () => null` to opt a
|
|
3075
|
+
route-mapped host out.
|
|
3076
|
+
- **When it is read.** Before every tool round, so an agent that navigates mid-run is told the new
|
|
3077
|
+
page on its next round; and on connect, on expand, when the composer gains focus, on `popstate`
|
|
3078
|
+
and `hashchange`, and when the conversation on screen changes (`switchThread`, the drawer,
|
|
3079
|
+
`newChat`): the chip always names the live page, never the page a thread started on. Between runs
|
|
3080
|
+
it shows the last read. A single-page app calls **`refreshPlace()`** after its router moves,
|
|
3081
|
+
since `pushState` fires no event.
|
|
3082
|
+
- **The entry rides after your `getContext`.** It is appended to whatever `getContext` returns, so a
|
|
3083
|
+
host that replaces `getContext` still sends it.
|
|
3084
|
+
- **The eye toggle withholds it.** Pressing it leaves the chip in place, dashed and struck through
|
|
3085
|
+
(`data-excluded` on the `place` part), and the next round sends no `place` entry. It withholds the
|
|
3086
|
+
page too: that round also drops the `page_map` entry, whatever `getContext` returned, and does not
|
|
3087
|
+
offer `read_page`. Tools you register yourself, such as `registerPageState`'s, stay as you
|
|
3088
|
+
registered them. The choice holds while the same place is read, lapses when a different place is
|
|
3089
|
+
read, is not persisted, and cannot be lifted by the host.
|
|
3090
|
+
- **Placement.** The same position in every placement, on a phone and in the greeting layout; the
|
|
3091
|
+
label truncates with an ellipsis before it can push Send off the row, and the chip's title
|
|
3092
|
+
carries it in full. Nothing shows while the panel is collapsed. The chip is separate from the
|
|
3093
|
+
`above-composer` slot, and filling the slot never hides it.
|
|
3094
|
+
- **Styling and words.** Parts `place`, `place-icon`, `place-label`, `place-detail` and
|
|
3095
|
+
`place-toggle`; the colours come from the existing tokens. The tooltip and the toggle's labels are
|
|
3096
|
+
the `placeShared` and `placeWithheld` (token `{label}`), `placeStopSharing` and `placeShareAgain`
|
|
3097
|
+
strings.
|
|
3098
|
+
|
|
3099
|
+
## Resuming a run
|
|
3100
|
+
|
|
3101
|
+
When the server persists run checkpoints (django-ag-ui's `step_store`), a run
|
|
3102
|
+
that stopped part-way can be **continued** rather than restarted. Point the
|
|
3103
|
+
component at the run index and a checkpoints button (an arrow turning back) appears in the header:
|
|
3104
|
+
|
|
3105
|
+
```html
|
|
3106
|
+
<ag-ui-chat endpoint="/agent/" data-runs-url="/agent/runs/"></ag-ui-chat>
|
|
3107
|
+
```
|
|
3108
|
+
|
|
3109
|
+
The panel lists runs the server marked **continuable** — those with a saved
|
|
3110
|
+
snapshot to seed from. A run that never reached a provider-valid boundary has
|
|
3111
|
+
none, so it isn't offered: resuming it would start from nothing.
|
|
3112
|
+
|
|
3113
|
+
A row leads with the run's **first user message**, from the index's `preview`
|
|
3114
|
+
field, and shows when it started beside it. That is what makes the list a list of
|
|
3115
|
+
conversations: a time is not an identity, since two runs a minute apart both read
|
|
3116
|
+
"just now", and a run id is not something a person recognises. Where the server
|
|
3117
|
+
sends no preview — an older index, or a run that opened on an image with no
|
|
3118
|
+
caption — the row falls back to the time plus the first eight characters of the
|
|
3119
|
+
id (full id on hover, for correlating with server logs), and so does a row whose
|
|
3120
|
+
preview another row repeats: words shared with a second row identify neither, and
|
|
3121
|
+
one board tends to be asked about more than once. Either way a run that branched
|
|
3122
|
+
from another is marked, so a fork doesn't read as a duplicate of its parent.
|
|
3123
|
+
|
|
3124
|
+
Type the next turn in the composer, then pick a row:
|
|
3125
|
+
|
|
3126
|
+
- **Resume** — continue that run.
|
|
3127
|
+
- **Fork** — branch it, leaving the original untouched.
|
|
3128
|
+
|
|
3129
|
+
Both send to the matching server endpoint and stream into the same transcript. The
|
|
3130
|
+
turn you typed is drawn there ahead of the answer and announced with `ag-ui-submit`,
|
|
3131
|
+
exactly as a send is, so a host that keeps its own record of what the user said sees
|
|
3132
|
+
continued turns too.
|
|
3133
|
+
|
|
3134
|
+
Picking a row with an empty composer says so above the input and puts the caret
|
|
3135
|
+
there, rather than closing the panel over nothing: a continuation sends **only**
|
|
3136
|
+
the new turn, so with nothing typed there is nothing to send.
|
|
3137
|
+
|
|
3138
|
+
Picking a row while an answer is still streaming — the conversation's own, or
|
|
3139
|
+
another continuation's — is refused the same way: the hint asks you to wait or
|
|
3140
|
+
stop it, and what you typed stays in the composer for the pick after. The run in
|
|
3141
|
+
flight is not cancelled for you, since a continuation starting over it would
|
|
3142
|
+
leave two answers streaming into one transcript with Stop reaching only one.
|
|
3143
|
+
|
|
3144
|
+
Picking a row while a stored conversation is still loading is refused the same
|
|
3145
|
+
way, with a hint asking you to wait for the load: a continuation sends only the
|
|
3146
|
+
new turn, so it must not start before the history it continues is on screen. The
|
|
3147
|
+
wording is the `continueWhileLoading` string.
|
|
3148
|
+
|
|
3149
|
+
### One URL, three endpoints
|
|
3150
|
+
|
|
3151
|
+
`data-runs-url` is the only thing to configure. `resume/<id>/` and `fork/<id>/`
|
|
3152
|
+
are siblings of the index — django-ag-ui mounts all three under one prefix
|
|
3153
|
+
whenever a step store is set — so they're derived, and there's no way to end up
|
|
3154
|
+
with a half-configured set.
|
|
3155
|
+
|
|
3156
|
+
### The client contract, handled for you
|
|
3157
|
+
|
|
3158
|
+
Those endpoints expect a request carrying a **fresh run id** and **only the new
|
|
3159
|
+
turn**: the server supplies the prior turns from the snapshot, so re-sending
|
|
3160
|
+
them would duplicate the conversation.
|
|
3161
|
+
|
|
3162
|
+
The component satisfies that structurally rather than by remembering a rule. A
|
|
3163
|
+
continuation runs on its own short-lived agent, built pointing at the resume
|
|
3164
|
+
endpoint and seeded with **no** history — so "only the new turn" is the only
|
|
3165
|
+
thing it *can* send, and the fresh run id comes free because a new agent mints
|
|
3166
|
+
one. Nothing of the conversation's history goes out on its request.
|
|
3167
|
+
|
|
3168
|
+
A resumed run is a normal run in every other respect: frontend tools execute,
|
|
3169
|
+
approval interrupts render their card, `headers` are re-read per request so
|
|
3170
|
+
a rotated CSRF token or JWT still reaches the endpoint, it carries and updates
|
|
3171
|
+
[shared state](#host-seams-the-spa-story) like any other run, it is bounded by
|
|
3172
|
+
`data-max-tool-rounds`, and Stop ends it. Its client is built by the same
|
|
3173
|
+
construction as the conversation's own, differing only in the endpoint and the
|
|
3174
|
+
empty seed.
|
|
3175
|
+
|
|
3176
|
+
The continued exchange joins the conversation. Its agent holds only the new turn
|
|
3177
|
+
and the answer, and a store keeps one message list per thread, so each save
|
|
3178
|
+
writes the conversation as it stood when you picked the row with the exchange
|
|
3179
|
+
after it — through the same `saveMessages` as any other run, and as far as it
|
|
3180
|
+
got if it was stopped. A reload then replays the exchange, and the next message
|
|
3181
|
+
you send, now to `endpoint` again, carries it together with the shared state the
|
|
3182
|
+
continuation left. What is saved is what the transcript shows: a fork's exchange
|
|
3183
|
+
follows the conversation it was picked from, in that same thread.
|
|
3184
|
+
|
|
3185
|
+
If the index can't be reached, the panel shows its empty state rather than an
|
|
3186
|
+
error — a history affordance that fails is empty, not broken.
|
|
3187
|
+
|
|
3188
|
+
## File uploads
|
|
3189
|
+
|
|
3190
|
+
Set **`data-attachments-url`** (django-ag-ui's `AttachmentsView`) to let the user attach files
|
|
3191
|
+
to a message. A button, drag-and-drop, and **paste** appear on the composer; each picked file uploads
|
|
3192
|
+
out-of-band (multipart, with the element's `headers`) and shows a chip in a pending tray —
|
|
3193
|
+
`uploading` (with a progress bar) → `ready`, or `error` with a retry. On send, the ready files'
|
|
3194
|
+
**refs** ride on the user bubble as read-only chips and the agent reads their contents
|
|
3195
|
+
server-side via the `read_attachment` tool. The wire stays vanilla AG-UI: only lightweight refs
|
|
3196
|
+
(`{ id, name, mime, size }`) travel, never the bytes, in the user message's `metadata` under
|
|
3197
|
+
`attachments` — the one place on a message `@ag-ui/client` 1.0 does not strip an unknown key from.
|
|
3198
|
+
django-ag-ui reads them from there as of 0.63.0; an older one reads only the top-level key 1.0
|
|
3199
|
+
removes, so against it the agent is never told a file was attached.
|
|
3200
|
+
|
|
3201
|
+
### Pasting
|
|
3202
|
+
|
|
3203
|
+
A paste carrying files puts them on the tray, the same way the picker and a drop
|
|
3204
|
+
do — a screenshot straight from the clipboard, or a file copied in the file
|
|
3205
|
+
manager.
|
|
3206
|
+
|
|
3207
|
+
Two rules keep it from stealing a paste that was never about files. Text pastes
|
|
3208
|
+
carry no files at all, so ordinary pasting is untouched. And the default is
|
|
3209
|
+
prevented **only when the clipboard carries no text**: copying a rich selection
|
|
3210
|
+
that happens to contain an image puts both on the clipboard, and swallowing the
|
|
3211
|
+
words someone meant to paste in order to attach a picture they did not is the
|
|
3212
|
+
worse of the two failures.
|
|
3213
|
+
|
|
3214
|
+
A pasted blob that arrives with no filename — some engines hand one over that
|
|
3215
|
+
way — is given one, rather than reaching the server as an empty `filename` and
|
|
3216
|
+
showing in the tray as a chip with no label.
|
|
3217
|
+
|
|
3218
|
+
```html
|
|
3219
|
+
<ag-ui-chat
|
|
3220
|
+
endpoint="/agent/"
|
|
3221
|
+
data-attachments-url="/agent/attachments/"
|
|
3222
|
+
data-attachment-accept="image/*,application/pdf,text/plain"
|
|
3223
|
+
data-attachment-max-bytes="10485760"
|
|
3224
|
+
></ag-ui-chat>
|
|
3225
|
+
```
|
|
3226
|
+
|
|
3227
|
+
Client-side `accept` / size checks are an instant-feedback nicety — **the server is
|
|
3228
|
+
authoritative**. Refs persist on the message, so a restored conversation re-renders its chips.
|
|
3229
|
+
Without the attribute the affordance stays hidden and the chat is text-only.
|
|
3230
|
+
|
|
3231
|
+
The built-in handler sends the element's `headers` / `getHeaders` with every upload, and honours
|
|
3232
|
+
`credentials="include"` — with the caveat that it is an `XMLHttpRequest` (for real progress
|
|
3233
|
+
events), whose cookie switch is two-state: `include` turns it on, every other value leaves it off.
|
|
3234
|
+
See [Authenticating requests](#authenticating-requests).
|
|
3235
|
+
|
|
3236
|
+
**Swapping the upload transport.** The built-in multipart `POST` is just the default
|
|
3237
|
+
`uploadHandler`. Set your own to use a different transport — a resumable
|
|
3238
|
+
[`tus-js-client`](https://github.com/tus/tus-js-client) adapter, direct-to-S3 multipart, etc.
|
|
3239
|
+
— without touching the tray, the chips, or the AG-UI wire (refs are transport-agnostic). The
|
|
3240
|
+
handler is `(file, onProgress) => Promise<AttachmentRef>`; when set, the affordance appears
|
|
3241
|
+
even with no `data-attachments-url`, and your handler owns its own endpoint and headers:
|
|
3242
|
+
|
|
3243
|
+
```js
|
|
3244
|
+
import { Upload } from "tus-js-client";
|
|
3245
|
+
|
|
3246
|
+
chat.uploadHandler = (file, onProgress) =>
|
|
3247
|
+
new Promise((resolve, reject) => {
|
|
3248
|
+
const up = new Upload(file, {
|
|
3249
|
+
endpoint: "/tus/",
|
|
3250
|
+
headers: chat.headers,
|
|
3251
|
+
onProgress: (sent, total) => onProgress(sent / total),
|
|
3252
|
+
onError: reject,
|
|
3253
|
+
onSuccess: () =>
|
|
3254
|
+
resolve({ id: up.url.split("/").pop(), name: file.name, mime: file.type, size: file.size }),
|
|
3255
|
+
});
|
|
3256
|
+
up.start();
|
|
3257
|
+
});
|
|
3258
|
+
```
|
|
3259
|
+
|
|
3260
|
+
The server side is the matching half: the agent reads bytes by ref id, so point the
|
|
3261
|
+
`read_attachment` store at wherever your transport persisted them (django-ag-ui's
|
|
3262
|
+
`AttachmentStore` is the seam). The refs themselves never change shape.
|
|
3263
|
+
|
|
3264
|
+
---
|
|
3265
|
+
|
|
3266
|
+
## Public API surface
|
|
3267
|
+
|
|
3268
|
+
Everything below is re-exported from the package root ([`src/index.ts`](src/index.ts)), except
|
|
3269
|
+
`AgUiChat` and `defineChat`, which are the `./chat` entry. The
|
|
3270
|
+
vendored bundle ([`src/bundle.ts`](src/bundle.ts)) carries both. Internal modules import from leaf
|
|
3271
|
+
paths.
|
|
3272
|
+
|
|
3273
|
+
### Element & registration
|
|
3274
|
+
|
|
3275
|
+
| Export | Kind | Summary |
|
|
3276
|
+
| --- | --- | --- |
|
|
3277
|
+
| `AgUiChat` | class | The `<ag-ui-chat>` Custom Element (`./chat`). |
|
|
3278
|
+
| `defineChat()` | function | Register the element (`./chat`); a no-op if it is already registered, and throws if another class holds the tag. |
|
|
3279
|
+
| `MessageRole` | type | Role of a rendered chat message. |
|
|
3280
|
+
| `SubmitDetail` | type | `detail` shape of the submit event. |
|
|
3281
|
+
| `ThreadDetail` | type | `detail` shape of the `ag-ui-thread` event (`{ threadId, previousThreadId, reason }`). |
|
|
3282
|
+
| `ToggleDetail` | type | `detail` shape of the `ag-ui-toggle` event (`{ collapsed }`). |
|
|
3283
|
+
| `MaximizeDetail` | type | `detail` shape of the `ag-ui-maximize` event (`{ maximized }`). |
|
|
3284
|
+
| `BarHeightDetail` | type | `detail` shape of the `ag-ui-bar-height` event (`{ height }`). |
|
|
3285
|
+
| `DockWidthDetail` | type | `detail` shape of the `ag-ui-dock-width` event (`{ width, side }`). |
|
|
3286
|
+
| `ThreadTitleDetail` | type | `detail` shape of the `ag-ui-thread-title` event (`{ threadId, title }`). |
|
|
3287
|
+
| `UnreadDetail` | type | `detail` shape of the `ag-ui-unread` event (`{ unread, threadIds }`). |
|
|
3288
|
+
| `AttachmentsDetail` | type | `detail` shape of the `ag-ui-attachments` event (`{ attachments, pending }`). |
|
|
3289
|
+
| `StateDetail` | type | `detail` shape of the `ag-ui-state` event (`{ state }`). |
|
|
3290
|
+
|
|
3291
|
+
### AG-UI client & agent
|
|
3292
|
+
|
|
3293
|
+
| Export | Kind | Summary |
|
|
3294
|
+
| --- | --- | --- |
|
|
3295
|
+
| `AgUiClient` | class | Orchestration layer over an AG-UI `AbstractAgent`. |
|
|
3296
|
+
| `AgUiClientConfig` / `AgUiClientHandlers` / `AgUiRunInputs` | type | Client config, lifecycle handlers, per-run input providers. |
|
|
3297
|
+
| `AgUiToolCall` / `ToolExecution` / `ExecuteTool` | type | Tool-call shape, execution result, executor signature. |
|
|
3298
|
+
| `AgUiMessageIds` | type | Which message `AgUiClientHandlers.onTextEnd` completed: its `messageId`, and the `runId` its `RUN_STARTED` named. |
|
|
3299
|
+
| `ToolOutcome` | type | The `TOOL_OUTCOME` values as a union. |
|
|
3300
|
+
| `toolStatusFromOutcome` | function | Map a wire outcome to the card status it settles into; anything absent or unrecognised is a success. |
|
|
3301
|
+
| `ConnectionLostError` | class | Raised (→ `onError`) when a run's stream closes with no terminal AG-UI event. |
|
|
3302
|
+
| `createHttpAgent(options)` | function | Default agent factory (wraps `HttpAgent`). |
|
|
3303
|
+
| `AgentFactory` / `HttpAgentOptions` | type | Factory signature and its options. |
|
|
3304
|
+
| `ResolveInterrupts` | type | Resolver for server-side-tool approval interrupts (one decision per interrupt). |
|
|
3305
|
+
| `InterruptResponse` | type | One interrupt's answer: `resolved` (with an optional payload) or `cancelled`. |
|
|
3306
|
+
|
|
3307
|
+
### Tools & flags
|
|
3308
|
+
|
|
3309
|
+
| Export | Kind | Summary |
|
|
3310
|
+
| --- | --- | --- |
|
|
3311
|
+
| `ClientToolRegistry` | class | Per-element tool registry. |
|
|
3312
|
+
| `ClientTool` | type | A frontend tool declaration. |
|
|
3313
|
+
| `isDestructive(parameters)` | function | Read the `x-destructive` flag. |
|
|
3314
|
+
| `isNavigates(parameters)` | function | Read the `x-navigates` flag. |
|
|
3315
|
+
| `createPageActionTools(enabled, resolveTarget)` | function | Build the opt-in `scroll_to` / `drag_and_drop` tools. |
|
|
3316
|
+
| `PAGE_ACTIONS` | const | The page-action opt-in tokens (`scroll` / `drag`). |
|
|
3317
|
+
| `createChatSurfaceTools(surface)` | function | Build the opt-in `read_chat_surface` / `move_chat` / `minimise_chat` / `restore_chat` tools, which let the agent move the panel it is speaking from. |
|
|
3318
|
+
| `ChatSurface` | type | The narrow port those tools drive — `describeSurface` / `moveTo` / `setCollapsed`. The element satisfies it. |
|
|
3319
|
+
| `ChatSurfaceReport` | type | What `describeSurface()` answers: placement, collapsed, collapseTo, collapsible, movable, draggable, fullBleed, and the panel's box against the viewport it sits in. |
|
|
3320
|
+
| `ChatCorner` | type | `"top-left"` / `"top-right"` / `"bottom-left"` / `"bottom-right"` — the argument to `moveTo`. |
|
|
3321
|
+
| `CHAT_CORNERS` | const | Those four, as a list. |
|
|
3322
|
+
| `isChatCorner(value)` | function | Whether a string names one of them. |
|
|
3323
|
+
| `ResolvePageTarget` | type | `(target) => HTMLElement \| null` — the page-target resolver. |
|
|
3324
|
+
| `X_DESTRUCTIVE_KEY` / `X_NAVIGATES_KEY` | const | The JSON-Schema extension keys. |
|
|
3325
|
+
| `parseToolCatalog(data)` | function | Parse a fetched `data-tools-url` catalog into a `Record<string, ToolCatalogEntry>` — whole entries, not bare summaries, so a caller can reach `description` too. Malformed input yields an empty map rather than throwing. |
|
|
3326
|
+
| `ToolCatalogEntry` | type | One row of that catalog. |
|
|
3327
|
+
| `prettifyToolName(name)` | function | Last fallback of the tool-card label chain (`delete_record` reads as *Delete record*). |
|
|
3328
|
+
|
|
3329
|
+
### Host seams
|
|
3330
|
+
|
|
3331
|
+
| Export | Kind | Summary |
|
|
3332
|
+
| --- | --- | --- |
|
|
3333
|
+
| `createRouteTools(...)` | function | Build the built-in `route.*` tools. |
|
|
3334
|
+
| `Route` / `RouteMap` | type | Navigable-route shapes. |
|
|
3335
|
+
| `RouteWithParams` | type | A route resolved with `:param` path segments + leftover query params. |
|
|
3336
|
+
| `createPageMapContext(...)` | function | Build the per-run `page_map` context entry. |
|
|
3337
|
+
| `PageMap` | type | The compact page-surface shape. |
|
|
3338
|
+
| `Place` | type | Where the user is: what `getPlace` returns and the chip draws. |
|
|
3339
|
+
| `createPageStateTools(binding)` | function | Build `read_<name>` / `set_<name>` tools. |
|
|
3340
|
+
| `PageState` | type | A page-state binding declaration. |
|
|
3341
|
+
| `Skill` | type | A launchable prompt (chip / `/`-command). |
|
|
3342
|
+
| `RunFinishedDetail` / `ToolRun` | type | `ag-ui-run-finished` detail: the tools an interaction ran, which side ran them, and how it ended. |
|
|
3343
|
+
| `CustomAgentDetail` | type | `ag-ui-custom` detail: an AG-UI `CUSTOM` event's `name` and `value`, verbatim. |
|
|
3344
|
+
| `InvalidateDetail` | type | `ag-ui-invalidate` detail: the resource `keys` that moved, and the `reason`. |
|
|
3345
|
+
| `FeedbackDetail` | type | `ag-ui-feedback` detail: the rated message's `messageId`, its `threadId`, the `runId` that streamed it (live only), its `content` and the `rating`. See [Storing a rating](#storing-a-rating). |
|
|
3346
|
+
| `relativeTime` | function | The built-in locale-neutral timestamp formatter (`"5m ago"`), for a host building on it. |
|
|
3347
|
+
| `RelativeTimeFormatter` | type | What `formatRelativeTime` takes: an epoch-ms timestamp in, row text out. |
|
|
3348
|
+
| `renderSuggestionChips` | function | Draw a `suggestions` activity as chips that send themselves; `null` when nothing survives. |
|
|
3349
|
+
| `suggestionPrompts` | function | The usable prompts in a `suggestions` activity's content, bounded and trimmed. |
|
|
3350
|
+
| `attachMessageActions` | function | Give a finished bubble its action row (copy, and feedback when a handler is passed). |
|
|
3351
|
+
| `messageActionBar` | function | The empty action row for a bubble, created if it has none — the shared shell both callers use. |
|
|
3352
|
+
| `MessageActionsOptions` | type | What `attachMessageActions` takes: `strings`, a `text` source, an optional `html` source for the rich clipboard flavour, an optional `onFeedback`. |
|
|
3353
|
+
| `ActivityRenderer` | type | Draws one activity from its `content`. Pure: it runs again on every restore. |
|
|
3354
|
+
| `ActivityRegistration` | type | What `registerActivityRenderer` takes: `type`, `render`, and an optional `removedNotice`. |
|
|
3355
|
+
|
|
3356
|
+
### Durability
|
|
3357
|
+
|
|
3358
|
+
| Export | Kind | Summary |
|
|
3359
|
+
| --- | --- | --- |
|
|
3360
|
+
| `SessionStorageStore` | class | Default per-tab conversation store. |
|
|
3361
|
+
| `RemoteConversationStore` | class | Server-backed store over a `data-threads-url` endpoint. |
|
|
3362
|
+
| `ClientConversationStore` | type | The persistence seam. |
|
|
3363
|
+
| `ThreadMeta` | type | A thread-drawer row (`{ threadId, title, updatedAt, preview, unread? }`). |
|
|
3364
|
+
| `NavigationCheckpoint` | type | The pre-reload checkpoint marker. |
|
|
3365
|
+
| `RunIndex` | class | Reads a `data-runs-url` run index and derives its resume / fork endpoints. |
|
|
3366
|
+
| `RunRow` | type | One run index row (`{ run_id, thread_id, parent_run_id, started_at, continuable, preview? }`). |
|
|
3367
|
+
| `CheckpointMenu` | class | The *Continue a run* panel. |
|
|
3368
|
+
| `CheckpointVerb` | type | `"resume" \| "fork"`. |
|
|
3369
|
+
|
|
3370
|
+
### Attachments
|
|
3371
|
+
|
|
3372
|
+
| Export | Kind | Summary |
|
|
3373
|
+
| --- | --- | --- |
|
|
3374
|
+
| `uploadAttachment(file, options)` | function | The built-in upload (multipart, progress) → `AttachmentRef`. |
|
|
3375
|
+
| `UploadOptions` | type | `{ url, headers?, credentials?, onProgress?, signal? }`. `credentials` is spelled as a fetch mode but carried by `XMLHttpRequest.withCredentials`, so only `"include"` is distinguishable. |
|
|
3376
|
+
| `UploadHandler` | type | `(file, onProgress, signal?) => Promise<AttachmentRef>` — the `uploadHandler` swap seam (tus / S3). The signal fires when the tray removes a chip or the element is torn down; a handler that honours it aborts its own transport, so a cancelled upload leaves no orphaned file on the server. |
|
|
3377
|
+
| `AttachmentRef` | type | The durable upload ref (`{ id, name, mime, size, url? }`). |
|
|
3378
|
+
| `messageAttachments(message)` | function | Read the refs a restored user message carries: from its `metadata`, or from the top level where an earlier release stored them. |
|
|
3379
|
+
|
|
3380
|
+
### Voice input
|
|
3381
|
+
|
|
3382
|
+
| Export | Kind | Summary |
|
|
3383
|
+
| --- | --- | --- |
|
|
3384
|
+
| `transcribeAudio(audio, options)` | function | The built-in transcription POST (multipart) → the transcript text. |
|
|
3385
|
+
| `TranscribeOptions` | type | `{ url, headers?, credentials? }` — `credentials` as fetch's own cookie mode. |
|
|
3386
|
+
| `TranscribeHandler` | type | `(audio) => Promise<string>` — the `transcribeHandler` swap seam (Web Speech, direct-to-provider). |
|
|
3387
|
+
|
|
3388
|
+
### UI & DOM primitives
|
|
3389
|
+
|
|
3390
|
+
| Export | Kind | Summary |
|
|
3391
|
+
| --- | --- | --- |
|
|
3392
|
+
| `ToolCallCard` | class | A live tool-call card for the transcript. |
|
|
3393
|
+
| `ToolCallStatus` / `SettledStatus` / `ToolDisplayMode` | type | Card lifecycle states + display mode. |
|
|
3394
|
+
| `ToolGroupDisplayMode` | type | How an opened step group draws its calls (`TOOL_GROUP_DISPLAY`'s values). |
|
|
3395
|
+
| `ToolPayloadFormatter` | type | Draws one region of a card's body (`AgUiChat.formatToolPayload`); `null` falls through to the built-in pretty-print. |
|
|
3396
|
+
| `ToolPayload` | type | The region being drawn: `arguments` (the parsed record) or `result` (the raw string and its outcome). |
|
|
3397
|
+
| `ToolCallCardOptions` | type | Per-card wiring beyond name / args / label / strings — currently `formatPayload`. |
|
|
3398
|
+
| `requestConfirmation(host, request, options?)` | function | Append the inline confirmation card to the transcript. |
|
|
3399
|
+
| `ConfirmationRequest` | type | What the card displays. |
|
|
3400
|
+
| `ConfirmationOptions` | type | `{ signal?, strings?, onAlwaysAllow? }` — abort resolves the card as declined; `strings` localizes it; passing `onAlwaysAllow` is what adds the third button. |
|
|
3401
|
+
| `UiStrings` | type | The flat table of every user-facing string. |
|
|
3402
|
+
| `DEFAULT_UI_STRINGS` | const | The English defaults (the override floor). |
|
|
3403
|
+
| `mergeUiStrings(overrides)` | function | Merge a partial override over the defaults. |
|
|
3404
|
+
| `renderMarkdown(text, options?)` | function | Render sanitized markdown/HTML (marked + DOMPurify). |
|
|
3405
|
+
| `RenderMarkdownOptions` | type | `{ allowImages? }` — opt `<img>` back into the sanitized output. |
|
|
3406
|
+
| `requestApproval(host, request, options?)` | function | Append the inline approval card that gates a server-side tool. |
|
|
3407
|
+
| `ApprovalRequest` | type | What that card displays (`{ message?, toolName?, args? }`). |
|
|
3408
|
+
| `ApprovalOptions` | type | `{ signal?, strings?, onEdit? }` — abort resolves the card as denied; `strings` localizes it; passing `onEdit` offers the call's arguments for editing and is called only when they actually changed. |
|
|
3409
|
+
| `ApprovalRenderer` | type | Replace the built-in approval card outright (`AgUiChat.approvalRenderer`). |
|
|
3410
|
+
| `requestQuestion(host, request, options?)` | function | Append the inline `ask_user` card (radios and/or free text). |
|
|
3411
|
+
| `QuestionRequest` | type | What that card asks. |
|
|
3412
|
+
| `QuestionOptions` | type | `{ signal?, strings? }` — abort resolves it with an empty answer. |
|
|
3413
|
+
| `QuestionRenderer` | type | Replace the built-in question card outright (`AgUiChat.askUserRenderer`). |
|
|
3414
|
+
| `renderChart(spec)` | function | Draw one spec as a self-contained block, or `null` when it says nothing. |
|
|
3415
|
+
| `chartSpecFrom(value)` | function | Read an arbitrary payload into a `ChartSpec`, or `null` if it cannot be drawn honestly. |
|
|
3416
|
+
| `ChartSpec` / `ChartSeries` / `ChartKind` | type | A chart as data, one named series, and how it is drawn. |
|
|
3417
|
+
| `attachQuoteOffer(options)` | function | The page-side select-then-quote offer, with its guards. `AgUiChat.offerQuoteInPage()` is the one-line form. |
|
|
3418
|
+
| `PageQuoteOffer` / `PageQuoteOfferOptions` | type | The live offer (`{ element, detach }`) and what it takes. |
|
|
3419
|
+
| `quotableSelection(container, roots, near?)` | function | The current selection when it lies inside `container`, read through the shadow-aware API where the engine has one. `near` is where the gesture ended, used to pick the line the offer hangs from. |
|
|
3420
|
+
| `QuotableSelection` | type | `{ text, rect }` — what was selected, and where it sits. |
|
|
3421
|
+
| `asQuote(text)` | function | Shape text as a markdown blockquote with a blank line after it. |
|
|
3422
|
+
| `MAX_QUOTE_CHARS` | const | The cap a quotation is truncated to (500). |
|
|
3423
|
+
| `typeInto` / `highlightThenClick` / `pressThenClick` / `selectOption` / `toggleControl` / `scrollIntoCenterView` / `flash` / `focusWithFlash` / `prefersReducedMotion` | function | Animation primitives. |
|
|
3424
|
+
| `showHighlightOverlay` | function | Ring a host-page element from an overlay drawn outside it, optionally dimming everything else or flowing a gradient round it. Returns a function that removes it. |
|
|
3425
|
+
| `HighlightOverlayOptions` | type | Options for `showHighlightOverlay`. |
|
|
3426
|
+
| `fillField` / `clickElement` / `pressButton` / `selectControl` / `setControlValue` / `toggleCheckbox` | function | DOM-driver primitives. |
|
|
3427
|
+
| `setNativeValue` / `setNativeChecked` | function | Set a control via its native prototype setter (React-controlled inputs). |
|
|
3428
|
+
| `TypeOptions` / `HighlightClickOptions` / `PressOptions` / `SelectOptions` / `ToggleOptions` / `FlashOptions` / `ScrollOptions` / `FillFieldOptions` / `TextLikeElement` / `RingOwnerOptions` | type | Primitive option shapes. `RingOwnerOptions` is the `chat` the ring-drawing ones share; see [Ring colour](#ring-colour). |
|
|
3429
|
+
|
|
3430
|
+
### Constants
|
|
3431
|
+
|
|
3432
|
+
| Export | Summary |
|
|
3433
|
+
| --- | --- |
|
|
3434
|
+
| `ELEMENT_TAG` | The registered tag name (`ag-ui-chat`). |
|
|
3435
|
+
| `SUBMIT_EVENT` | The submit CustomEvent name. |
|
|
3436
|
+
| `THREAD_EVENT` | The conversation-changed CustomEvent name (`ag-ui-thread`). |
|
|
3437
|
+
| `THREAD_TITLE_EVENT` | The thread-title CustomEvent name (`ag-ui-thread-title`). |
|
|
3438
|
+
| `TOGGLE_EVENT` | The collapse-toggle CustomEvent name (`ag-ui-toggle`). |
|
|
3439
|
+
| `MAXIMIZE_EVENT` | The maximized-state CustomEvent name (`ag-ui-maximize`). |
|
|
3440
|
+
| `BAR_HEIGHT_EVENT` | The bottom bar's height CustomEvent name (`ag-ui-bar-height`). |
|
|
3441
|
+
| `DOCK_WIDTH_EVENT` | The docked panel's width CustomEvent name (`ag-ui-dock-width`). |
|
|
3442
|
+
| `UNREAD_EVENT` | The unread-count CustomEvent name (`ag-ui-unread`). |
|
|
3443
|
+
| `RUN_FINISHED_EVENT` | The interaction-finished CustomEvent name (`ag-ui-run-finished`). |
|
|
3444
|
+
| `CUSTOM_AGENT_EVENT` | The agent-`CUSTOM` CustomEvent name (`ag-ui-custom`). |
|
|
3445
|
+
| `INVALIDATE_EVENT` | The resource-invalidation CustomEvent name (`ag-ui-invalidate`). |
|
|
3446
|
+
| `FEEDBACK_EVENT` | The message-rating CustomEvent name (`ag-ui-feedback`). |
|
|
3447
|
+
| `SUGGESTIONS_ACTIVITY_TYPE` | The `activity_type` carrying follow-up prompts (`suggestions`). |
|
|
3448
|
+
| `MAX_SUGGESTIONS` | Most prompts one push draws (4). Mirrors the server's cap. |
|
|
3449
|
+
| `MAX_SUGGESTION_CHARS` | Longest one prompt may be (120). Mirrors the server's cap. |
|
|
3450
|
+
| `INVALIDATE_CUSTOM_NAME` | The AG-UI `CUSTOM` `name` that carries one (`ag_ui.invalidate`). |
|
|
3451
|
+
| `ATTACHMENT_EVENT` | The attachments-changed CustomEvent name (`ag-ui-attachments`). |
|
|
3452
|
+
| `STATE_EVENT` | The shared-state CustomEvent name (`ag-ui-state`). |
|
|
3453
|
+
| `CHART_ACTIVITY_TYPE` | The `ACTIVITY_SNAPSHOT` type a server sets to push a chart. |
|
|
3454
|
+
| `CHART_TOOL_NAME` | The name the built-in chart tool registers under (`render_chart`). |
|
|
3455
|
+
| `COMPACTION_ACTIVITY_TYPE` | The `ACTIVITY_SNAPSHOT` type reporting a trimmed history. |
|
|
3456
|
+
| `LOAD_CAPABILITY_TOOL` | The agent-side capability-loading tool's name. |
|
|
3457
|
+
| `MESSAGE_ROLE` | Message role constants. |
|
|
3458
|
+
| `MESSAGE_ACTIONS` | The message-action tokens `data-message-actions` selects by (`copy` / `retry` / `feedback`). |
|
|
3459
|
+
| `TOOL_CALL_STATUS` | Tool-call card status constants. |
|
|
3460
|
+
| `TOOL_DISPLAY` | Tool-call display-mode constants (`inline` / `minimal` / `compact` / `full`). |
|
|
3461
|
+
| `TOOL_GROUP_DISPLAY` | How an opened step group draws its calls under `grouped` (`inline` / `minimal` / `compact` / `full`). |
|
|
3462
|
+
| `TOOL_OUTCOME` | How a tool call ended, as a server states it on `TOOL_CALL_RESULT` (`success` / `failed` / `denied`). |
|
|
3463
|
+
| `X_CONFIRM_KEY` | Confirmation-prompt key: on a tool's JSON Schema for a client-side confirmation, and in an AG-UI interrupt's `metadata` for a server-side approval. |
|
|
3464
|
+
| `X_SUMMARY_KEY` | JSON-Schema key carrying a short tool-card label. |
|
|
3465
|
+
| `MAX_TOOL_ROUNDS` | Upper bound on tool-call → re-run rounds per send. |
|
|
3466
|
+
| `VERSION` | The package version string. |
|
|
3467
|
+
|
|
3468
|
+
---
|
|
3469
|
+
|
|
3470
|
+
## Theming, density, and placement
|
|
3471
|
+
|
|
3472
|
+
The chat shell is styled inside its Shadow DOM and exposes a large set of `--ag-ui-*` CSS custom
|
|
3473
|
+
properties (colors, status, surface, spacing, layout), so you theme it from outside without
|
|
3474
|
+
piercing the shadow boundary. Set them anywhere above the element and they inherit in — on the
|
|
3475
|
+
element itself, on a wrapper, or on `:root` for a whole page. The closest declaration wins, the
|
|
3476
|
+
way any inherited CSS property behaves:
|
|
3477
|
+
|
|
3478
|
+
```css
|
|
3479
|
+
/* All three work. The most specific one that applies wins. */
|
|
3480
|
+
:root { --ag-ui-accent: #4f46e5; } /* whole page */
|
|
3481
|
+
.chat-dock { --ag-ui-accent: #0f766e; } /* one region */
|
|
3482
|
+
ag-ui-chat { --ag-ui-accent: #b91c1c; } /* one widget */
|
|
3483
|
+
```
|
|
3484
|
+
|
|
3485
|
+
> Until 0.20.x the defaults were declared on `:host`, which set them *on the element* — and an
|
|
3486
|
+
> element's own value beats anything inherited from an ancestor, so only the `ag-ui-chat { … }`
|
|
3487
|
+
> form did anything and the wrapper form silently did nothing. The defaults now sit behind an
|
|
3488
|
+
> internal alias, so all three forms work.
|
|
3489
|
+
|
|
3490
|
+
A few of the knobs, at Quiet's light values:
|
|
3491
|
+
|
|
3492
|
+
```css
|
|
3493
|
+
ag-ui-chat {
|
|
3494
|
+
--ag-ui-accent: #232326; /* #f0f0f2 in the dark scheme */
|
|
3495
|
+
--ag-ui-bg: #ffffff; /* #161618 */
|
|
3496
|
+
--ag-ui-fg: #1b1b1d; /* #ececee */
|
|
3497
|
+
--ag-ui-radius: 12px;
|
|
3498
|
+
--ag-ui-header-btn-size: 30px; /* the header's controls; 44px on touch */
|
|
3499
|
+
--ag-ui-header-gap: 4px;
|
|
3500
|
+
|
|
3501
|
+
/* What is drawn on top of the accent and danger fills. Unset, the one on
|
|
3502
|
+
the accent is white or black by the accent's luminance (see Text on the
|
|
3503
|
+
accent below); set it to pin a colour of your own. */
|
|
3504
|
+
--ag-ui-on-accent: #ffffff;
|
|
3505
|
+
--ag-ui-on-danger: #ffffff;
|
|
3506
|
+
|
|
3507
|
+
/* Layout */
|
|
3508
|
+
--ag-ui-width: 380px;
|
|
3509
|
+
--ag-ui-height: 560px;
|
|
3510
|
+
--ag-ui-inset: auto 24px 24px auto;
|
|
3511
|
+
--ag-ui-shadow: 0 1px 2px rgb(0 0 0 / 0.05), 0 12px 32px -16px rgb(0 0 0 / 0.22);
|
|
3512
|
+
}
|
|
3513
|
+
```
|
|
3514
|
+
|
|
3515
|
+
`--ag-ui-radius` is the theme's corner radius. It rounds the panel's frame and the corners inside
|
|
3516
|
+
the panel that have no radius of their own: the answer well, the conversation list's *New chat*
|
|
3517
|
+
button and filter field, and the *Undo* on a run notice. The frame is the exception where it meets
|
|
3518
|
+
the edge of the window. `page` and `sidebar` draw it square, and so does every placement but
|
|
3519
|
+
`embedded` on a small viewport, while the corners inside keep the theme's radius, since none of them
|
|
3520
|
+
meets that edge. `--ag-ui-panel-radius` sets the frame on its own, in any placement. Each theme
|
|
3521
|
+
gives the floating frame a radius of its own (`--ag-ui-frame-radius`: 14px in Quiet against 12px
|
|
3522
|
+
inside), and a host's `--ag-ui-radius` still sets both.
|
|
3523
|
+
|
|
3524
|
+
### Themes
|
|
3525
|
+
|
|
3526
|
+
`theme` is up to two words, in any order, matched as words (`~=`):
|
|
3527
|
+
|
|
3528
|
+
```
|
|
3529
|
+
theme = [name] [scheme]
|
|
3530
|
+
name = quiet | soft | ledger | classic | <a word of your own>
|
|
3531
|
+
scheme = light | dark | auto default: light
|
|
3532
|
+
```
|
|
3533
|
+
|
|
3534
|
+
| Value | Result |
|
|
3535
|
+
| --- | --- |
|
|
3536
|
+
| *(absent)* | Quiet, light |
|
|
3537
|
+
| `dark` | Quiet, dark |
|
|
3538
|
+
| `auto` | Quiet, following the system's `prefers-color-scheme` |
|
|
3539
|
+
| `soft`, `soft dark`, `ledger auto` | that theme in that scheme |
|
|
3540
|
+
| `classic`, `classic dark` | the look from before themes, for a host that pins it |
|
|
3541
|
+
| `acme` | a word the component does not ship adds no rules, so this is Quiet, ready for your `[theme~="acme"]` CSS |
|
|
3542
|
+
| `soft acme` | starts from Soft; your `[theme~="acme"]` rules layer on top |
|
|
3543
|
+
|
|
3544
|
+
The built-in themes:
|
|
3545
|
+
|
|
3546
|
+
| Theme | Look |
|
|
3547
|
+
| --- | --- |
|
|
3548
|
+
| `quiet` | The default. Neutral graphite on white, no bubble for the agent's turn, hairlines, status as an icon. Set one brand accent and the user's turn, the send button and the links follow it. |
|
|
3549
|
+
| `soft` | Rounded and tinted: surfaces mixed from the accent, pill chips, a deep frame shadow. Violet by default. |
|
|
3550
|
+
| `ledger` | Dense and editorial: paper and ink, square corners, serif for what the agent says and monospace for the chrome. |
|
|
3551
|
+
| `classic` | The look before themes: the indigo header, the accent user bubble, the status pill and the round launcher. Pin it to keep that look. |
|
|
3552
|
+
|
|
3553
|
+
**The default scheme is `light`, not `auto`**, because the widget sits on your page and only you
|
|
3554
|
+
know that page's scheme. A light admin screen on a machine in dark mode would otherwise get a dark
|
|
3555
|
+
panel on a white page. If your page follows the system, say `auto`.
|
|
3556
|
+
|
|
3557
|
+
Each theme is three seeds, `--ag-ui-bg`, `--ag-ui-fg` and `--ag-ui-accent`, each declared with
|
|
3558
|
+
`light-dark()` so the scheme word picks the half. Every other colour is mixed from them with
|
|
3559
|
+
`color-mix(in oklch, ...)` -- hover, borders, the secondary text inks, washes, the user's turn (the
|
|
3560
|
+
accent at 12% over the background) -- and every derived value still reads its own public token
|
|
3561
|
+
first, so each stays an override point. The tokens the themes add:
|
|
3562
|
+
|
|
3563
|
+
- **Inks and lines:** `--ag-ui-ink-2`, `--ag-ui-ink-3`, `--ag-ui-line`, `--ag-ui-line-strong`.
|
|
3564
|
+
- **Surfaces:** `--ag-ui-wash`, `--ag-ui-accent-ink`, `--ag-ui-tint`, `--ag-ui-tint-2`.
|
|
3565
|
+
- **Type:** `--ag-ui-text-xs`, `--ag-ui-text-sm`, `--ag-ui-text-md`, `--ag-ui-text-lg`,
|
|
3566
|
+
`--ag-ui-leading`, `--ag-ui-serif`.
|
|
3567
|
+
- **Frame:** `--ag-ui-frame-radius`, `--ag-ui-frame-shadow`. These are what a theme sets for the
|
|
3568
|
+
frame; `--ag-ui-panel-radius` and `--ag-ui-shadow` stay the final say, and are what a docked
|
|
3569
|
+
placement squares or flattens.
|
|
3570
|
+
- **Charts:** `--ag-ui-chart-1` to `--ag-ui-chart-6`. A host's value beats the theme's.
|
|
3571
|
+
- **Status marks:** `--ag-ui-ico-check`, `--ag-ui-ico-error`, `--ag-ui-ico-declined`, mask images
|
|
3572
|
+
drawn in the state's colour, and `--ag-ui-ico-chevron`, the step group's disclosure chevron.
|
|
3573
|
+
- **Focus:** `--ag-ui-ring`, the step group head's focus outline. Unset, it is two parts accent
|
|
3574
|
+
to three parts ink (`--ag-ui-fg`), which holds 3:1 against the panel for any accent.
|
|
3575
|
+
|
|
3576
|
+
The font is part of the theme: Quiet, Soft and Ledger set their own stack (Inter, then the system
|
|
3577
|
+
UI font), and Classic inherits the page's font as the component always did. To take the page's font
|
|
3578
|
+
under any theme, set `--ag-ui-font` to the page's family, or to the page's own font token:
|
|
3579
|
+
|
|
3580
|
+
```css
|
|
3581
|
+
ag-ui-chat { --ag-ui-font: var(--body-font); } /* or the family itself */
|
|
3582
|
+
```
|
|
3583
|
+
|
|
3584
|
+
`--ag-ui-font: inherit` does not do this. A CSS-wide keyword in a custom property applies to the
|
|
3585
|
+
custom property itself, so it takes the `--ag-ui-font` of the parent, which nothing sets, and the
|
|
3586
|
+
theme's stack wins.
|
|
3587
|
+
|
|
3588
|
+
#### Text on the accent
|
|
3589
|
+
|
|
3590
|
+
`--ag-ui-on-accent`, unless you set it, is picked from the accent's own luminance: white on an
|
|
3591
|
+
accent darker than WCAG relative luminance 0.179, black on a lighter one, which is the better of
|
|
3592
|
+
the two on every colour (never under 4.58:1). It is computed with relative colour syntax with
|
|
3593
|
+
channel arithmetic, behind `@supports` for that exact expression: Chrome 119, Firefox 128, Safari
|
|
3594
|
+
18.
|
|
3595
|
+
|
|
3596
|
+
**The fallback.** Two browsers at or above the floor cannot compute it: Firefox 120 to 127, which
|
|
3597
|
+
lack relative colour syntax, and Safari 17.5 and 17.6, which shipped an earlier draft that rejects
|
|
3598
|
+
this expression. They keep a fixed colour per theme instead:
|
|
3599
|
+
|
|
3600
|
+
| Theme | Without relative colour syntax |
|
|
3601
|
+
| --- | --- |
|
|
3602
|
+
| `quiet`, `soft`, `ledger` | the scheme's: white in the light scheme, near-black (`#111111`) in the dark one |
|
|
3603
|
+
| `classic`, `code` | white in both schemes, the colour they always drew |
|
|
3604
|
+
|
|
3605
|
+
On every built-in accent but code's the fixed colour is on the same side as the computed one, so
|
|
3606
|
+
those browsers see the same contrast. On code's green the computed pick is black (8.3:1) and the
|
|
3607
|
+
fallback white (2.5:1), as it always was. What they miss is the correction for a host's accent: a
|
|
3608
|
+
mid-tone brand accent can fall under 4.5:1 there, under any theme. If you support those browsers
|
|
3609
|
+
and set a mid-tone accent, set `--ag-ui-on-accent` yourself. There is no script path.
|
|
3610
|
+
|
|
3611
|
+
#### Keeping a brand accent on the header
|
|
3612
|
+
|
|
3613
|
+
Quiet draws the header, the launcher and the tool names in the panel's own colours, so a host that
|
|
3614
|
+
sets only `--ag-ui-accent` gets a neutral header. To put the accent back without leaving Quiet, set
|
|
3615
|
+
the surfaces to the accent and the text on them to a colour that reads on it. The same recipe holds
|
|
3616
|
+
under every other theme:
|
|
3617
|
+
|
|
3618
|
+
```css
|
|
3619
|
+
ag-ui-chat {
|
|
3620
|
+
--ag-ui-header-bg: #3d5afe;
|
|
3621
|
+
--ag-ui-header-fg: #ffffff;
|
|
3622
|
+
--ag-ui-launcher-bg: #3d5afe;
|
|
3623
|
+
--ag-ui-launcher-fg: #ffffff;
|
|
3624
|
+
--ag-ui-tool-fg: #3d5afe;
|
|
3625
|
+
}
|
|
3626
|
+
```
|
|
3627
|
+
|
|
3628
|
+
- **The header buttons follow `--ag-ui-header-fg`, under every theme.** Unset, each theme draws
|
|
3629
|
+
them its own way at 0.85 opacity: Quiet, Soft and Ledger in their third ink, Classic and Code in
|
|
3630
|
+
the header text, each on its own hover square. Once you set the header text they draw in it at
|
|
3631
|
+
full opacity, and the hover square is the opposite of that text at about a fifth (a darker square
|
|
3632
|
+
under white text, a lighter one under dark text), so a hovered button reads at least what your
|
|
3633
|
+
header pair reads. A theme word of your own starts from Quiet, so it holds there too. No
|
|
3634
|
+
`::part(header-button)` rule is needed. The opposite is computed with relative
|
|
3635
|
+
colour syntax; the browsers without it (see the fallback above) mix the square from the text
|
|
3636
|
+
itself, which on a mid-tone header can take the hovered glyph under your pair (white on
|
|
3637
|
+
`#3d5afe`: 3.75:1).
|
|
3638
|
+
- **`--ag-ui-header-btn-hover` sets the hover square outright** under every theme, whether or not
|
|
3639
|
+
the header text is set, and is drawn exactly as given. For a square that lightens under white
|
|
3640
|
+
text instead, at the cost of contrast on a mid-tone header:
|
|
3641
|
+
|
|
3642
|
+
```css
|
|
3643
|
+
ag-ui-chat { --ag-ui-header-btn-hover: color-mix(in srgb, #ffffff 18%, transparent); }
|
|
3644
|
+
```
|
|
3645
|
+
- **`--ag-ui-tool-fg` colours every tool call**, in a step group as well as in the card displays.
|
|
3646
|
+
Unset, a grouped call is drawn in the second ink (`--ag-ui-ink-2`) under every theme.
|
|
3647
|
+
- Pick the pair yourself: `--ag-ui-header-fg` is not derived from the header colour the way
|
|
3648
|
+
`--ag-ui-on-accent` is from the accent.
|
|
3649
|
+
|
|
3650
|
+
#### Overriding a theme, lightest layer first
|
|
3651
|
+
|
|
3652
|
+
Four layers, each documented and each lighter than the next. Reach for the first one that does
|
|
3653
|
+
the job.
|
|
3654
|
+
|
|
3655
|
+
##### (a) Tokens
|
|
3656
|
+
|
|
3657
|
+
Any `--ag-ui-*` token, on the element or any ancestor, as above. Per theme, from your own CSS:
|
|
3658
|
+
|
|
3659
|
+
```css
|
|
3660
|
+
body { --ag-ui-accent: #0f766e; } /* accent only: the rest follows */
|
|
3661
|
+
ag-ui-chat[theme~="soft"] { --ag-ui-accent: #c2410c; --ag-ui-frame-radius: 12px; }
|
|
3662
|
+
```
|
|
3663
|
+
|
|
3664
|
+
A page rule always beats the theme's own value, because the theme only ever sets the private
|
|
3665
|
+
alias behind the public token.
|
|
3666
|
+
|
|
3667
|
+
##### (b) Parts
|
|
3668
|
+
|
|
3669
|
+
`::part()` reaches the structural elements, as listed in [Parts and slots](#parts-and-slots):
|
|
3670
|
+
|
|
3671
|
+
```css
|
|
3672
|
+
ag-ui-chat::part(header) { background: linear-gradient(#f2f7f6, transparent); }
|
|
3673
|
+
```
|
|
3674
|
+
|
|
3675
|
+
##### (c) A theme of your own
|
|
3676
|
+
|
|
3677
|
+
Name it, and style the name. An unknown word adds no built-in rules, so it starts from Quiet (or
|
|
3678
|
+
from the built-in theme beside it):
|
|
3679
|
+
|
|
3680
|
+
```html
|
|
3681
|
+
<ag-ui-chat theme="acme dark"></ag-ui-chat>
|
|
3682
|
+
```
|
|
3683
|
+
|
|
3684
|
+
```css
|
|
3685
|
+
ag-ui-chat[theme~="acme"] {
|
|
3686
|
+
--ag-ui-bg: light-dark(#fbfaf7, #10161c);
|
|
3687
|
+
--ag-ui-fg: light-dark(#14212b, #e4ebf0);
|
|
3688
|
+
--ag-ui-accent: light-dark(#0b5cad, #6cb2ff);
|
|
3689
|
+
--ag-ui-frame-radius: 4px;
|
|
3690
|
+
}
|
|
3691
|
+
```
|
|
3692
|
+
|
|
3693
|
+
Use `light-dark()` in the seeds and the scheme word, and the theme toggle, work for your theme
|
|
3694
|
+
too.
|
|
3695
|
+
|
|
3696
|
+
##### (d) `extraStyles`: the last mile
|
|
3697
|
+
|
|
3698
|
+
> **Unstable.** `extraStyles` reaches the class names inside the shadow root, and those are not a
|
|
3699
|
+
> public API: they may change in any minor release. Layers (a) to (c) are the supported ones; use
|
|
3700
|
+
> this for what they cannot reach, and expect to revisit it on upgrade.
|
|
3701
|
+
|
|
3702
|
+
```ts
|
|
3703
|
+
const sheet = new CSSStyleSheet();
|
|
3704
|
+
sheet.replaceSync(`.message--assistant { padding: 10px 12px; border-radius: 10px; }`);
|
|
3705
|
+
chat.extraStyles = [sheet];
|
|
3706
|
+
```
|
|
3707
|
+
|
|
3708
|
+
- `extraStyles: readonly CSSStyleSheet[]`, per element, default `[]`. Two elements can carry
|
|
3709
|
+
different sheets.
|
|
3710
|
+
- Adopted after the built-in sheet, so a rule of equal specificity wins.
|
|
3711
|
+
- Setting it re-adopts at once. The array is copied, so changing the one you passed does nothing
|
|
3712
|
+
until you set it again; changing a sheet's rules does apply, since the sheet itself is shared.
|
|
3713
|
+
- Constructed sheets are not subject to `style-src`, so this works under the same strict CSP as
|
|
3714
|
+
the built-in sheet. There is no string form and no `<style>` injection.
|
|
3715
|
+
|
|
3716
|
+
### Small viewports
|
|
3717
|
+
|
|
3718
|
+
At **600px wide and below** every placement but `embedded` becomes one full-bleed
|
|
3719
|
+
shape: edge to edge, no radius, no shadow, no resize grips. A phone is not an
|
|
3720
|
+
eighth placement — it is an override that collapses the others onto one of them.
|
|
3721
|
+
The host picked a placement for the desktop it was designing, and a 380x560
|
|
3722
|
+
panel with a 24px margin is not a smaller version of that decision, it is most
|
|
3723
|
+
of the screen with a frame drawn round it.
|
|
3724
|
+
|
|
3725
|
+
`embedded` is left alone deliberately: it sits in a box you sized and placed, and
|
|
3726
|
+
only you know whether that column should become the whole screen.
|
|
3727
|
+
|
|
3728
|
+
The [greeting layout](#the-greeting-on-an-empty-page) changes shape here too, and
|
|
3729
|
+
it is the one part of this that `embedded` gets as well, because it moves nothing
|
|
3730
|
+
about the box you placed: an empty conversation keeps its composer at the foot of
|
|
3731
|
+
the panel, with the starters against it and the greeting over the space above
|
|
3732
|
+
them, instead of centring all three together.
|
|
3733
|
+
|
|
3734
|
+
The corner placements still rest at their launcher, so a full-bleed panel is
|
|
3735
|
+
something the user opens rather than something they are given.
|
|
3736
|
+
|
|
3737
|
+
**To keep your desktop layout at every width**, set `data-small-viewport="off"`.
|
|
3738
|
+
That exists because the *trigger* is the one part of this you cannot reach: every
|
|
3739
|
+
value the override sets is a `--ag-ui-*` token you can re-state, but a media
|
|
3740
|
+
query cannot read a custom property, so the breakpoint itself is a literal.
|
|
3741
|
+
|
|
3742
|
+
**A size or inset token set at every width restates the phone shape too.** The phone
|
|
3743
|
+
block reads `--ag-ui-inset`, `--ag-ui-width`, `--ag-ui-height` and `--ag-ui-max-height`
|
|
3744
|
+
before it falls back to the full-bleed values, so a host rule such as the one below keeps
|
|
3745
|
+
its 420x640 panel with a 24px margin on a phone, and the panel runs off the screen's left
|
|
3746
|
+
edge. Put the desktop sizes above the breakpoint and the phone shape needs nothing:
|
|
3747
|
+
|
|
3748
|
+
```css
|
|
3749
|
+
/* Wrong on a phone: the tokens apply at every width. */
|
|
3750
|
+
ag-ui-chat {
|
|
3751
|
+
--ag-ui-inset: auto 24px 24px auto;
|
|
3752
|
+
--ag-ui-width: 420px;
|
|
3753
|
+
--ag-ui-height: 640px;
|
|
3754
|
+
--ag-ui-max-height: 80vh;
|
|
3755
|
+
}
|
|
3756
|
+
|
|
3757
|
+
/* Right: desktop only. At 600px and below the panel is full-bleed with no reset. */
|
|
3758
|
+
@media (min-width: 601px) {
|
|
3759
|
+
ag-ui-chat {
|
|
3760
|
+
--ag-ui-inset: auto 24px 24px auto;
|
|
3761
|
+
--ag-ui-width: 420px;
|
|
3762
|
+
--ag-ui-height: 640px;
|
|
3763
|
+
--ag-ui-max-height: 80vh;
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
```
|
|
3767
|
+
|
|
3768
|
+
Setting the tokens at every width stays right for a host that wants a non-full-bleed phone
|
|
3769
|
+
panel, a bottom sheet for example. The size a user dragged is a separate matter; see
|
|
3770
|
+
[Sizes, keyboard, and parts](#sizes-keyboard-and-parts) for its precedence.
|
|
3771
|
+
|
|
3772
|
+
The breakpoint is a width rather than a pointer test, and that is on purpose: a
|
|
3773
|
+
touch laptop is coarse-pointered and wide, a narrow desktop window is
|
|
3774
|
+
fine-pointered and small. Width decides the layout; the pointer decides which
|
|
3775
|
+
controls make sense.
|
|
3776
|
+
|
|
3777
|
+
### The conversation list
|
|
3778
|
+
|
|
3779
|
+
The chat-history list slides over the transcript at every width and under every
|
|
3780
|
+
placement, a full page included. Opening it moves nothing underneath: the
|
|
3781
|
+
conversation, the centred greeting and the composer are exactly where they were.
|
|
3782
|
+
While it is open it is a modal dialog: focus moves into it, Tab stays inside it,
|
|
3783
|
+
and Escape, its close control or a click on the backdrop beside it closes it and
|
|
3784
|
+
returns focus to where it was. `openThreads()` and `closeThreads()` do the same
|
|
3785
|
+
from your own chrome.
|
|
3786
|
+
|
|
3787
|
+
The list also grows a filter once there are eight or more conversations in it
|
|
3788
|
+
— above that a search box is worth having, below it it is a control asking to be
|
|
3789
|
+
used on a list you can already read in one glance. It matches the title **and**
|
|
3790
|
+
the preview, because the title is often the model's one-line summary and the
|
|
3791
|
+
phrase you remember is as likely to be inside the conversation as on it, and it
|
|
3792
|
+
filters what the drawer already holds rather than going back to the server for a
|
|
3793
|
+
list that is already in memory. `data-threads-search="always"` shows it over any
|
|
3794
|
+
list and `"never"` hides it, for a host whose lists are always short or always long.
|
|
3795
|
+
|
|
3796
|
+
The rows are grouped by when they were last active: **Today**, **Previous 7 days**
|
|
3797
|
+
and **Older**, counted from local midnight. A group with no rows is not drawn,
|
|
3798
|
+
and a list that falls in one group draws no heading at all. A conversation with no
|
|
3799
|
+
readable date goes under Older. A filtered list keeps its groups. The headings
|
|
3800
|
+
are not in the Tab order, are styled as `::part(drawer-group-label)`, and take
|
|
3801
|
+
their text from the `threadsToday`, `threadsPreviousWeek` and `threadsOlder`
|
|
3802
|
+
strings. Every drawer has them: the slide-over, the bar's popover and the popover
|
|
3803
|
+
beside your own button.
|
|
3804
|
+
|
|
3805
|
+
### Reserving the space your own chrome occupies
|
|
3806
|
+
|
|
3807
|
+
A fixed placement covers the viewport it is given, and it does not know about
|
|
3808
|
+
your sticky header. Tell it which edges are already spent and every placement
|
|
3809
|
+
does its own arithmetic:
|
|
3810
|
+
|
|
3811
|
+
```css
|
|
3812
|
+
ag-ui-chat {
|
|
3813
|
+
--ag-ui-viewport-inset-top: 64px; /* your nav bar */
|
|
3814
|
+
}
|
|
3815
|
+
```
|
|
3816
|
+
|
|
3817
|
+
`page` and `full` inset by all four edges; `sidebar` and `side` by three, leaving
|
|
3818
|
+
the docked edge free; `floating` adds them to its own margins.
|
|
3819
|
+
**The heights follow on their own** — that is the point of these rather than
|
|
3820
|
+
restating `--ag-ui-inset` per placement, which leaves you to keep
|
|
3821
|
+
`--ag-ui-height` and `--ag-ui-max-height` in step by hand and overflows the panel
|
|
3822
|
+
off the bottom of the screen the one time you forget.
|
|
3823
|
+
|
|
3824
|
+
Four longhands rather than one shorthand because a custom property is a token
|
|
3825
|
+
stream and CSS cannot index one; the height arithmetic needs the vertical pair on
|
|
3826
|
+
its own. Any CSS length works — `px`, `rem`, `env(safe-area-inset-*)`, or a
|
|
3827
|
+
`calc()` combining them; the widget reads the resolved value rather than the text
|
|
3828
|
+
you wrote, so the number it clamps against is the one the stylesheet uses:
|
|
3829
|
+
|
|
3830
|
+
```css
|
|
3831
|
+
ag-ui-chat {
|
|
3832
|
+
--ag-ui-viewport-inset-top: env(safe-area-inset-top);
|
|
3833
|
+
--ag-ui-viewport-inset-bottom: env(safe-area-inset-bottom);
|
|
3834
|
+
}
|
|
3835
|
+
```
|
|
3836
|
+
|
|
3837
|
+
If your chrome changes height — a bar that wraps at narrow widths — measure it
|
|
3838
|
+
and publish the value, since no CSS length tracks it:
|
|
3839
|
+
|
|
3840
|
+
```js
|
|
3841
|
+
new ResizeObserver(() => {
|
|
3842
|
+
document.documentElement.style.setProperty("--bar-h", `${bar.offsetHeight}px`);
|
|
3843
|
+
}).observe(bar);
|
|
3844
|
+
```
|
|
3845
|
+
|
|
3846
|
+
```css
|
|
3847
|
+
ag-ui-chat { --ag-ui-viewport-inset-top: var(--bar-h, 0px); }
|
|
3848
|
+
```
|
|
3849
|
+
|
|
3850
|
+
`--ag-ui-edge-gutter` (default `24px`) is the gap a resting `floating` panel
|
|
3851
|
+
keeps between itself and that box. The size cap subtracts the same one, so a
|
|
3852
|
+
panel grown to its limit reaches the far edge of the usable box and no further —
|
|
3853
|
+
set it to `0` for a panel flush against the corner.
|
|
3854
|
+
|
|
3855
|
+
`--ag-ui-anchor-gap` (default `8px`) is the gap between the panel and the button
|
|
3856
|
+
it rises from under `collapse-to="none"`; see [Collapsing to a bottom
|
|
3857
|
+
bar](#collapsing-to-a-bottom-bar).
|
|
3858
|
+
|
|
3859
|
+
`--ag-ui-keyboard-inset` overrides the lift an on-screen keyboard earns. The
|
|
3860
|
+
widget measures the hidden band and publishes it as
|
|
3861
|
+
`--ag-ui-visual-viewport-inset-bottom`; state this one instead to outrank that
|
|
3862
|
+
measurement, or set it to `0px` to opt out of the lift entirely.
|
|
3863
|
+
|
|
3864
|
+
`--ag-ui-keyboard-inset-top` does the same at the top. To show a field a
|
|
3865
|
+
keyboard would cover, a mobile browser pans the visible area down the page, and
|
|
3866
|
+
a panel anchored to the screen (`page`, `full`, `side`, `sidebar`, and at phone
|
|
3867
|
+
width the corner placements too) moves down with it. An `embedded` panel renders
|
|
3868
|
+
in your own box rather than against the screen, so it does not move at any width.
|
|
3869
|
+
The widget publishes the pan as `--ag-ui-visual-viewport-inset-top`, and the
|
|
3870
|
+
panel moves by as much of it as goes past your `--ag-ui-viewport-inset-top`,
|
|
3871
|
+
since the pan scrolls a reserved bar away with the page. State
|
|
3872
|
+
`--ag-ui-keyboard-inset-top` to outrank that distance, or `0px` to keep the
|
|
3873
|
+
panel below your reserved top.
|
|
3874
|
+
|
|
3875
|
+
`--ag-ui-viewport-height` and `--ag-ui-viewport-width` state the usable box
|
|
3876
|
+
outright, for the case where no viewport-percentage length describes it. An
|
|
3877
|
+
on-screen keyboard is the one that matters: it changes neither `vh` nor `dvh` nor
|
|
3878
|
+
`svh` on any current mobile browser, so a full-bleed panel has to be told the
|
|
3879
|
+
visual viewport's height rather than deriving it. The widget does that itself;
|
|
3880
|
+
where it has nothing to report, the panel falls back to `100dvh` rather than
|
|
3881
|
+
`100vh`, because iOS Safari resolves `vh` to the screen with its bars collapsed
|
|
3882
|
+
and a panel sized from it runs under them.
|
|
3883
|
+
|
|
3884
|
+
Marks are variables too, so one vocabulary covers a re-theme rather than
|
|
3885
|
+
leaving half the transcript in the built-in set: `--ag-ui-tool-icon-done` /
|
|
3886
|
+
`-error` / `-declined` for tool status, and `--ag-ui-disclosure-collapsed` /
|
|
3887
|
+
`--ag-ui-disclosure-expanded` for every expandable row.
|
|
3888
|
+
|
|
3889
|
+
```css
|
|
3890
|
+
ag-ui-chat {
|
|
3891
|
+
--ag-ui-disclosure-collapsed: "+";
|
|
3892
|
+
--ag-ui-disclosure-expanded: "-";
|
|
3893
|
+
}
|
|
3894
|
+
```
|
|
3895
|
+
|
|
3896
|
+
### Where to put the variables
|
|
3897
|
+
|
|
3898
|
+
There is one vocabulary — the `--ag-ui-*` names above — and it works from any ancestor. What
|
|
3899
|
+
differs is only *which* declaration wins, and that is ordinary CSS inheritance:
|
|
3900
|
+
|
|
3901
|
+
```css
|
|
3902
|
+
/* A whole page or design-system scope. */
|
|
3903
|
+
:root { --ag-ui-accent: var(--brand-600); --ag-ui-radius: 4px; }
|
|
3904
|
+
|
|
3905
|
+
/* One region — the widget picks this up through the wrapper. */
|
|
3906
|
+
aside.support-dock { --ag-ui-accent: #0f766e; }
|
|
3907
|
+
|
|
3908
|
+
/* One widget. Beats both of the above, because it targets the element. */
|
|
3909
|
+
ag-ui-chat#support { --ag-ui-accent: #b91c1c; }
|
|
3910
|
+
|
|
3911
|
+
/* Set at runtime with el.style.setProperty(...) — an inline style beats all of the above. */
|
|
3912
|
+
```
|
|
3913
|
+
|
|
3914
|
+
Two that do not work:
|
|
3915
|
+
|
|
3916
|
+
```css
|
|
3917
|
+
/* ::part() reaches structural elements, not variables — a custom property set
|
|
3918
|
+
here applies to that part's own subtree, not to the whole shell. */
|
|
3919
|
+
ag-ui-chat::part(panel) { --ag-ui-accent: #b91c1c; }
|
|
3920
|
+
|
|
3921
|
+
/* The internal --_* aliases are private and unversioned; they are renamed
|
|
3922
|
+
without notice. Always set the public --ag-ui-* name. */
|
|
3923
|
+
ag-ui-chat { --_accent: #b91c1c; }
|
|
3924
|
+
```
|
|
3925
|
+
|
|
3926
|
+
The preset attributes below sit *underneath* anything you declare: a `theme="dark"` widget still
|
|
3927
|
+
honours an explicit `--ag-ui-bg` from your CSS, so you can adopt a preset and correct one token
|
|
3928
|
+
rather than re-declaring the whole palette.
|
|
3929
|
+
|
|
3930
|
+
`--ag-ui-accent` reaches further than the widget: it also colours the rings the DOM driver draws
|
|
3931
|
+
on **your** page. Each primitive reads it from the computed style of the element it is about to
|
|
3932
|
+
touch, so setting it on `:root` (or on any ancestor of the elements the agent drives) themes the
|
|
3933
|
+
highlights too. Without it they fall back to the package indigo.
|
|
3934
|
+
|
|
3935
|
+
For the common cases there are three CSS-reactive **preset attributes** (no JS API), so you don't
|
|
3936
|
+
have to hand-tune the variables:
|
|
3937
|
+
|
|
3938
|
+
- `theme` — a theme name and a scheme; see [Themes](#themes).
|
|
3939
|
+
- `density` — `comfortable` (default) / `compact`.
|
|
3940
|
+
- `placement` — `floating` (default) / `sidebar` / `page` / `embedded`. `embedded` drops the
|
|
3941
|
+
fixed positioning and z-index so the widget sits in normal document flow; `page` is a
|
|
3942
|
+
full-screen [centred reading column](#page-placement) for a route of its own.
|
|
3943
|
+
|
|
3944
|
+
Four, because those are the four shapes that differ structurally: a corner panel, a docked
|
|
3945
|
+
rail, a surface that owns the screen, and a thing in your layout. Three older values --
|
|
3946
|
+
`bottom-left`, `side` and `full` -- still parse and still work, and are no longer documented:
|
|
3947
|
+
each is a variant of one of the four rather than a shape of its own. `full` is `page` with
|
|
3948
|
+
`--ag-ui-content-max-width: none`, `bottom-left` is `floating` with a different
|
|
3949
|
+
`--ag-ui-inset`, and `side` is `sidebar` that collapses to the floating launcher instead of
|
|
3950
|
+
an edge rail. Nothing warns and nothing breaks; there is simply less to choose between.
|
|
3951
|
+
|
|
3952
|
+
**`embedded` fills the box your page gives it, so give it one.** It is the placement app-shell
|
|
3953
|
+
layouts reach for, and a grid or flex item defaults to `min-height: auto` — which lets a growing
|
|
3954
|
+
transcript push the composer off the bottom of the window instead of scrolling inside the panel. The
|
|
3955
|
+
fix belongs to the containing element, not to the widget:
|
|
3956
|
+
|
|
3957
|
+
```css
|
|
3958
|
+
.assistant-pane { min-height: 0; overflow: hidden; } /* the box the element is given */
|
|
3959
|
+
```
|
|
3960
|
+
|
|
3961
|
+
```html
|
|
3962
|
+
<ag-ui-chat endpoint="/agent/" theme="dark" density="compact" placement="sidebar"></ag-ui-chat>
|
|
3963
|
+
```
|
|
3964
|
+
|
|
3965
|
+
See [`src/ui/shell/token_styles.ts`](src/ui/shell/token_styles.ts) for the full variable list and
|
|
3966
|
+
[`src/ui/placement/`](src/ui/placement/) for the placement presets. The
|
|
3967
|
+
[`demo/`](demo/) live playground (`node demo/mock-server.mjs`) flips theme, density, placement,
|
|
3968
|
+
text-animation, tool-display, and the answer well live from a single page, and demos the
|
|
3969
|
+
streamed thoughts region, the mic, and the header theme toggle.
|
|
3970
|
+
|
|
3971
|
+
It binds every interface and prints the addresses this machine can be reached on, so you can open the playground on a phone — which is the only way to see the small-viewport layout with a real on-screen keyboard rather than a resized desktop window. `HOST=127.0.0.1` keeps it to this machine.
|
|
3972
|
+
|
|
3973
|
+
### Parts and slots
|
|
3974
|
+
|
|
3975
|
+
For styling beyond the `--ag-ui-*` variables, every structural element exposes a `part` so you can
|
|
3976
|
+
reach it from outside the Shadow DOM with `::part()` — no shadow piercing. The part names are
|
|
3977
|
+
**public API** (additions are non-breaking; renames are breaking):
|
|
3978
|
+
|
|
3979
|
+
```css
|
|
3980
|
+
ag-ui-chat::part(panel) { border-radius: 0; }
|
|
3981
|
+
ag-ui-chat::part(header) { background: #111; }
|
|
3982
|
+
ag-ui-chat::part(send) { text-transform: uppercase; }
|
|
3983
|
+
ag-ui-chat::part(tool-card) { font-family: var(--my-mono); }
|
|
3984
|
+
```
|
|
3985
|
+
|
|
3986
|
+
Every part, by the feature it belongs to. Spelled out rather than abbreviated: the list used to
|
|
3987
|
+
read `tool-card` *plus* `-icon` / `-name`, which is compact and is also how an entire feature went
|
|
3988
|
+
missing from it for two releases. A test reads this table and compares it with the parts the
|
|
3989
|
+
component sets, so a new one cannot ship undocumented.
|
|
3990
|
+
|
|
3991
|
+
| Feature | Parts |
|
|
3992
|
+
| --- | --- |
|
|
3993
|
+
| Shell | `panel`, `header`, `title`, `icon`, `header-controls`, `messages`, `empty`, `greeting`, `pending`, `stopped`, `jump-latest`, and one per resize grip: `resize-handle` plus `resize-handle-top`, `resize-handle-bottom`, `resize-handle-left`, `resize-handle-right`, `resize-handle-top-left`, `resize-handle-top-right`, `resize-handle-bottom-left`, `resize-handle-bottom-right` |
|
|
3994
|
+
| Header buttons | `header-button` on each, plus `history-button`, `checkpoints-button`, `new-button`, `maximize-button`, `collapse-button`, `theme-toggle` |
|
|
3995
|
+
| Collapsed widget | `launcher`, `launcher-icon`, `launcher-badge`, `rail-label`, `bar` (the [bottom bar](#collapsing-to-a-bottom-bar)'s wrapper, around the launcher), `bar-place` (the place beside the bar's title; its text carries `place-label` and `place-detail`), `bar-history` |
|
|
3996
|
+
| Answers | `answer` (the per-turn group), `message` (plus `message-user`, `message-assistant`), `code-copy` |
|
|
3997
|
+
| Reasoning | `thoughts`, `thoughts-toggle`, `thoughts-body`, `thoughts-label` |
|
|
3998
|
+
| Follow-up suggestions | `suggestions`, `suggestion-chip` |
|
|
3999
|
+
| Message actions | `message-actions`, `message-action` (plus `message-action-retry`, `message-action-copy`, `message-action-up`, `message-action-down`), and the icon holder inside each: `message-action-icon` (plus `message-action-icon-retry`, `message-action-icon-copy`, `message-action-icon-up`, `message-action-icon-down`) |
|
|
4000
|
+
| Queued messages | `queued`, `queued-chip` |
|
|
4001
|
+
| Run notices | `run-notice` (plus `run-notice-interrupted`, `run-notice-attachment-pending`, `run-notice-compaction`, `run-notice-skill`, `run-notice-history-replaced`, `run-notice-chart-undrawable`, `run-notice-surface`, `run-notice-not-connected`), `run-notice-icon`, `run-notice-text`, `run-notice-undo` |
|
|
4002
|
+
| Tool cards | `tool-card` (plus one status modifier: `tool-card-pending`, `tool-card-deferred`, `tool-card-done`, `tool-card-error`, `tool-card-declined`, `tool-card-interrupted`), `tool-card-head`, `tool-card-icon`, `tool-card-name`, `tool-card-status` (plus `tool-card-status-pending`, `tool-card-status-deferred`, `tool-card-status-done`, `tool-card-status-error`, `tool-card-status-declined`, `tool-card-status-interrupted`), `tool-card-decision`, `tool-card-toggle`, `tool-card-body`, `tool-card-section` (plus `tool-card-args-section`, `tool-card-result-section`), `tool-card-section-label` (plus `tool-card-args-label`, `tool-card-result-label`), `tool-card-args`, `tool-card-result`, `tool-card-approval`, `tool-card-subagent`, `tool-card-repeat` |
|
|
4003
|
+
| Tool-step group | `tool-group` (plus one state modifier: `tool-group-waiting`, `tool-group-running`, `tool-group-done`, `tool-group-error`), `tool-group-head`, `tool-group-icon`, `tool-group-label`, `tool-group-meta`, `tool-group-list` |
|
|
4004
|
+
| Delegated sub-agents | `subagent`, `subagent-row`, `subagent-icon`, `subagent-status`, `subagent-steps`, `subagent-step`, `subagent-step-icon`, `subagent-step-name` |
|
|
4005
|
+
| Client-side confirmation | `confirm`, `confirm-body`, `confirm-args`, `confirm-actions`, `confirm-button` (plus `confirm-confirm`, `confirm-cancel`, `confirm-always`) |
|
|
4006
|
+
| Server-side approval | `approval`, `approval-body`, `approval-actions`, `approval-button` (plus `approval-approve`, `approval-deny`), `approval-edit`, `approval-args`, `approval-error` |
|
|
4007
|
+
| Typed question | `question`, `question-body`, `question-options`, `question-choice`, `question-choice-text`, `question-radio`, `question-input`, `question-actions`, `question-button` |
|
|
4008
|
+
| Composer | `composer`, `composer-surface`, `composer-tools`, `input`, `send`, `attach-button`, `voice-button` |
|
|
4009
|
+
| Place chip | `place` (carries `data-excluded` while the user withholds the place), `place-icon`, `place-label`, `place-detail`, `place-toggle` |
|
|
4010
|
+
| Attachments | `attachment-tray`, `attachment-chips` (the read-only chips on sent bubbles), and the shared chip parts `attachment-chip`, `attachment-chip-icon`, `attachment-chip-name`, `attachment-chip-size`, `attachment-chip-bar`, `attachment-chip-bar-fill`, `attachment-chip-retry`, `attachment-chip-remove` |
|
|
4011
|
+
| Skills | `skill-chips`, `skill-chip`, `skill-palette`, `skill-item`, `skill-item-title`, `skill-item-desc`, `skill-item-token`, `skill-hint` (the composer hint: a skill’s missing placeholders, and a run continuation picked with nothing typed or while a run is in flight) |
|
|
4012
|
+
| Thread drawer | `drawer`, `drawer-backdrop`, `drawer-panel`, `drawer-header`, `drawer-title`, `drawer-new`, `drawer-close`, `drawer-filter`, `drawer-list`, `drawer-empty`, `drawer-group-label`, `drawer-row`, `drawer-row-select`, `drawer-row-title`, `drawer-row-time`, `drawer-row-preview`, `drawer-row-actions`, `drawer-row-rename`, `drawer-row-delete`, `drawer-rename-input`, `drawer-confirm`, `drawer-confirm-label`, `drawer-confirm-yes`, `drawer-confirm-no` |
|
|
4013
|
+
| Charts | `chart-block`, `chart-title`, `chart-legend` |
|
|
4014
|
+
| Checkpoints panel | `checkpoints`, `checkpoints-header`, `checkpoints-title`, `checkpoints-list`, `checkpoints-empty`, `checkpoint-row`, `checkpoint-label`, `checkpoint-time`, `checkpoint-id`, `checkpoint-branch`, `checkpoint-action` (plus `checkpoint-resume`, `checkpoint-fork`) |
|
|
4015
|
+
|
|
4016
|
+
> **Hiding `::part(header)` hides the controls inside it.** The history, checkpoints, new-chat,
|
|
4017
|
+
> theme and collapse buttons are all children of the header, so a host that renders its own title
|
|
4018
|
+
> bar and does `ag-ui-chat::part(header) { display: none }` loses thread switching entirely. Every
|
|
4019
|
+
> one of them has an imperative equivalent, so your own chrome can drive them:
|
|
4020
|
+
>
|
|
4021
|
+
> | Control | Method |
|
|
4022
|
+
> | --- | --- |
|
|
4023
|
+
> | History drawer | `chat.openThreads()` |
|
|
4024
|
+
> | Checkpoints panel | `chat.openCheckpoints()` |
|
|
4025
|
+
> | New chat | `chat.newChat()` |
|
|
4026
|
+
> | Collapse | `chat.toggleCollapsed()` / `chat.setCollapsed(bool)` |
|
|
4027
|
+
> | Theme toggle | `chat.toggleTheme()` |
|
|
4028
|
+
>
|
|
4029
|
+
> ```js
|
|
4030
|
+
> myHeaderButton.onclick = () => chat.openThreads();
|
|
4031
|
+
> ```
|
|
4032
|
+
>
|
|
4033
|
+
> The built-in buttons call exactly these methods, so the two routes cannot drift. If you only want
|
|
4034
|
+
> to restyle the header, prefer `::part(header)` styling or the `header-actions` slot over hiding it.
|
|
4035
|
+
>
|
|
4036
|
+
> To drop single controls instead, hide their parts:
|
|
4037
|
+
> `ag-ui-chat::part(history-button), ag-ui-chat::part(new-button) { display: none }`.
|
|
4038
|
+
> `display: none` removes them from the accessibility tree and the tab order as well.
|
|
4039
|
+
|
|
4040
|
+
Coarse **slots** let you replace whole regions with your own markup (project light-DOM children
|
|
4041
|
+
with a matching `slot=`):
|
|
4042
|
+
|
|
4043
|
+
| Slot | Where |
|
|
4044
|
+
| --- | --- |
|
|
4045
|
+
| `icon` | A header brand icon, before the title. |
|
|
4046
|
+
| `header-actions` | Extra controls between the title and the built-in buttons. |
|
|
4047
|
+
| `greeting` | The greeting's text, above the empty state, where the [greeting layout](#the-greeting-on-an-empty-page) is on. Replaces the text only; `empty` and the starters still render beneath it. |
|
|
4048
|
+
| `empty` | The empty-state shown before any message. |
|
|
4049
|
+
| `above-composer` | Directly above the composer, below the skill chips, hint, queued turns and attachment tray. Takes no space when empty, and moves with the composer in the [greeting layout](#the-greeting-on-an-empty-page). |
|
|
4050
|
+
| `footer` | Below the composer. |
|
|
4051
|
+
| `launcher` | The collapsed widget's mark — the floating launcher, or the sidebar rail. |
|
|
4052
|
+
| `icon-send` / `icon-stop` | The composer button's two glyphs (idle and mid-run). |
|
|
4053
|
+
| `icon-attach` / `icon-voice` | The paperclip and mic glyphs. |
|
|
4054
|
+
| `icon-history` / `icon-checkpoints` / `icon-new` / `icon-maximize` / `icon-collapse` | The header controls' glyphs. A slotted `icon-collapse` wins over `data-collapse-icon`. |
|
|
4055
|
+
|
|
4056
|
+
```html
|
|
4057
|
+
<ag-ui-chat endpoint="/agent/">
|
|
4058
|
+
<img slot="icon" src="/logo.svg" alt="" />
|
|
4059
|
+
<button slot="header-actions" onclick="openHelp()">?</button>
|
|
4060
|
+
</ag-ui-chat>
|
|
4061
|
+
```
|
|
4062
|
+
|
|
4063
|
+
### Header and launcher icon
|
|
4064
|
+
|
|
4065
|
+
Give the header a brand icon with either the `icon` slot (any markup) or the `data-icon-url`
|
|
4066
|
+
convenience attribute (an `<img>`); the slot wins when both are set, and with neither the header
|
|
4067
|
+
stays icon-less. The same icon seam feeds the collapsed sidebar rail. Size it via
|
|
4068
|
+
`--ag-ui-icon-size` (default `22px`) and round it with `--ag-ui-icon-radius` (default `4px`).
|
|
4069
|
+
|
|
4070
|
+
```html
|
|
4071
|
+
<ag-ui-chat endpoint="/agent/" data-icon-url="/logo.png"></ag-ui-chat>
|
|
4072
|
+
```
|
|
4073
|
+
|
|
4074
|
+
### Sidebar placement
|
|
4075
|
+
|
|
4076
|
+
`placement="sidebar"` is a full-height **docked** panel that slides open/closed and collapses to a
|
|
4077
|
+
slim **icon rail** rather than the [floating launcher](#collapsing-to-the-launcher) — the same
|
|
4078
|
+
element, shaped by the placement. It docks right by default; `data-side="left"` docks it left. The
|
|
4079
|
+
panel slides out through the edge it docks against. Collapse state reuses the `collapsed` attribute
|
|
4080
|
+
(persisted per-tab), and the rail carries `aria-expanded`. The slide honours
|
|
4081
|
+
`prefers-reduced-motion`.
|
|
4082
|
+
|
|
4083
|
+
```html
|
|
4084
|
+
<ag-ui-chat endpoint="/agent/" placement="sidebar" data-side="left"></ag-ui-chat>
|
|
4085
|
+
```
|
|
4086
|
+
|
|
4087
|
+
It overlays the page by default (no host-layout coupling). To make the host content reflow around
|
|
4088
|
+
it instead, set `--ag-ui-position: static` and place the element in your own grid/flex layout.
|
|
4089
|
+
|
|
4090
|
+
### Page placement
|
|
4091
|
+
|
|
4092
|
+
`placement="page"` turns the widget into a full-screen chat **page**: a full-bleed background with
|
|
4093
|
+
the conversation in a centred reading column (default ~820px, set via `--ag-ui-content-max-width`).
|
|
4094
|
+
The assistant turn spans the column width while the user message stays a right-aligned pill. Unlike
|
|
4095
|
+
`full` (edge-to-edge, left-aligned), it's the layout you want for a dedicated `/chat` route. Pairs
|
|
4096
|
+
naturally with the [answer well](#the-answer-well).
|
|
4097
|
+
|
|
4098
|
+
```html
|
|
4099
|
+
<ag-ui-chat endpoint="/agent/" placement="page" data-answer-well></ag-ui-chat>
|
|
4100
|
+
```
|
|
4101
|
+
|
|
4102
|
+
#### The greeting on an empty page
|
|
4103
|
+
|
|
4104
|
+
Until the first message is sent, a page shows a greeting with the composer centred beneath it,
|
|
4105
|
+
and the composer moves to the foot of the page once the conversation has something in it. On a
|
|
4106
|
+
phone it starts at the foot, for the reason below.
|
|
4107
|
+
|
|
4108
|
+
```html
|
|
4109
|
+
<ag-ui-chat endpoint="/agent/" placement="page" user-name="Ada"></ag-ui-chat>
|
|
4110
|
+
```
|
|
4111
|
+
|
|
4112
|
+
- **The text** is `Hello, {name}`, filled from `user-name`, or `Hello there` when there is no
|
|
4113
|
+
name. Both are string-table keys, `greeting` and `greetingNoName`, so `strings` and
|
|
4114
|
+
`data-strings` translate them like everything else. `user-name` is live: a name that arrives
|
|
4115
|
+
after an auth handshake replaces the greeting on screen.
|
|
4116
|
+
- **Your own greeting** goes in `slot="greeting"`, which replaces the text and nothing else. A
|
|
4117
|
+
brand mark belongs there too. The `empty` slot and `data-starters` still render beneath it.
|
|
4118
|
+
- **Styling**: the `greeting` part, plus `--ag-ui-greeting-font` (default: the widget's font) and
|
|
4119
|
+
`--ag-ui-greeting-size` (default `clamp(1.5rem, 4vw, 2.25rem)`, scaling with the viewport rather
|
|
4120
|
+
than stepping at a breakpoint).
|
|
4121
|
+
- **`data-empty`** is present on the element while the conversation is empty, for chrome of your
|
|
4122
|
+
own that should react to the same state.
|
|
4123
|
+
- **Which placements**: on by default for `page`, which is a route of its own. `data-greeting="off"`
|
|
4124
|
+
restores the plain layout there. `embedded` opts in with `data-greeting` (any value but `off`),
|
|
4125
|
+
for an app shell that gives the panel a page-sized box. The corner placements and the sidebar
|
|
4126
|
+
never show it: a panel opened from a launcher is already mid-task.
|
|
4127
|
+
- **On a phone** the composer stays at the foot, the starters (or your own `empty` content) sit
|
|
4128
|
+
against it, and the greeting takes the middle of what they leave — the middle of the
|
|
4129
|
+
transcript when there are none. Centring is a shape for a screen with room to spare; at
|
|
4130
|
+
[600px wide and below](#small-viewports) the room is what the on-screen keyboard takes, and a
|
|
4131
|
+
composer centred over an empty band sits halfway up what is left of the screen, with the
|
|
4132
|
+
prompts that start a conversation nowhere near the field that sends one.
|
|
4133
|
+
`data-small-viewport="off"` keeps the centred shape at every width, as it keeps the rest of
|
|
4134
|
+
the desktop layout.
|
|
4135
|
+
|
|
4136
|
+
The composer's rows stay centred while you type, while a draft grows and while attachments are
|
|
4137
|
+
added, because the centring is two equal flexible halves either side of them and growth splits
|
|
4138
|
+
into both. Nothing in the DOM moves, so focus, the caret and IME composition are unaffected.
|
|
4139
|
+
|
|
4140
|
+
**Sending is what moves it**, not typing, and the composer slides to the foot of the page over
|
|
4141
|
+
`--ag-ui-motion` (a single frame under reduced motion). Everything else that changes the
|
|
4142
|
+
conversation snaps: a restored conversation mounts docked, picking a thread from the list or
|
|
4143
|
+
continuing a run docks at once, and a new chat returns to the centre at once. While a slow
|
|
4144
|
+
conversation store is still fetching, the page stays docked with the greeting hidden, so a
|
|
4145
|
+
restored conversation never paints the empty layout first.
|
|
4146
|
+
|
|
4147
|
+
### The answer well
|
|
4148
|
+
|
|
4149
|
+
Each assistant turn renders inside one `.answer` group (part `answer`) that holds its streamed
|
|
4150
|
+
text, tool cards, and pending indicator — so a turn that calls tools reads as a single answer
|
|
4151
|
+
rather than a string of loose siblings. Add the boolean `data-answer-well` attribute to box that
|
|
4152
|
+
group in a bordered, padded "well"; without it the layout is the flat stack as before. The well is
|
|
4153
|
+
pure CSS and turn-scoped — no JS API — and themeable via `--ag-ui-well-bg`, `--ag-ui-well-border`
|
|
4154
|
+
and `--ag-ui-well-radius` (and `::part(answer)`).
|
|
4155
|
+
|
|
4156
|
+
The well's corners default to `--ag-ui-radius`, 12px unless you set it, in every placement. The
|
|
4157
|
+
panel's own frame is different: `page` and `sidebar` draw it square because it meets the edge of
|
|
4158
|
+
the window, but nothing inside the panel does, so the well stays round there too. Set
|
|
4159
|
+
`--ag-ui-well-radius` to round the well differently from the rest of the panel.
|
|
4160
|
+
|
|
4161
|
+
```html
|
|
4162
|
+
<ag-ui-chat endpoint="/agent/" data-answer-well></ag-ui-chat>
|
|
4163
|
+
```
|
|
4164
|
+
|
|
4165
|
+
### Model reasoning (thoughts)
|
|
4166
|
+
|
|
4167
|
+
When a reasoning model streams its chain-of-thought (django-ag-ui forwards it as AG-UI reasoning
|
|
4168
|
+
events — enable a thinking budget via `MODEL_SETTINGS`, see its docs), the element renders a muted,
|
|
4169
|
+
collapsible **thoughts region** (part `thoughts`) at the top of the current answer group. It opens
|
|
4170
|
+
while the model reasons and folds away on the answer's first token; the reader can reopen it. The
|
|
4171
|
+
web component handles the `REASONING_*` event family (and the retired `THINKING_*`, which
|
|
4172
|
+
`@ag-ui/client` still converts onto it, with a console warning, during its deprecation window), so
|
|
4173
|
+
no client config is needed — the thoughts appear whenever the server forwards reasoning.
|
|
4174
|
+
|
|
4175
|
+
The header is a toggle only once reasoning has text: a provider that opens and closes reasoning
|
|
4176
|
+
without sending any (redacted thinking, or summaries off) gets a plain label with no body, and no
|
|
4177
|
+
`thoughts-toggle` part. When reasoning ends the header settles to how long it took, string
|
|
4178
|
+
`thoughtFor` (`"Thought for {seconds}s"`, the sum of the turn's reasoning rounds); under a second it
|
|
4179
|
+
reads `thoughts`. A tool call likewise offers its details only over something: with no arguments
|
|
4180
|
+
and a blank result its toggle, and a one-step group's row, are not offered until a result with text
|
|
4181
|
+
arrives.
|
|
4182
|
+
|
|
4183
|
+
### The composer
|
|
4184
|
+
|
|
4185
|
+
The composer is one bordered surface (part `composer-surface`) that owns the border and the focus
|
|
4186
|
+
ring: the field sits on top and grows with what is typed until it hits its ceiling and scrolls,
|
|
4187
|
+
and a tool row (part `composer-tools`) sits underneath with the paperclip and mic as quiet icon
|
|
4188
|
+
buttons on the left and a circular **Send** on the right. Send is icon-only — its accessible name
|
|
4189
|
+
still comes from the `send` / `stop` [strings](#internationalization-i18n) — and it becomes the
|
|
4190
|
+
Stop control mid-run by swapping its glyph, so nothing moves when a run starts.
|
|
4191
|
+
|
|
4192
|
+
```css
|
|
4193
|
+
ag-ui-chat {
|
|
4194
|
+
--ag-ui-composer-radius: 14px;
|
|
4195
|
+
--ag-ui-composer-max-height: 40vh; /* where the growing field starts scrolling */
|
|
4196
|
+
--ag-ui-tool-btn-size: 30px; /* the paperclip / mic hit targets */
|
|
4197
|
+
--ag-ui-send-size: 30px; /* the send circle */
|
|
4198
|
+
--ag-ui-glyph-size: 18px;
|
|
4199
|
+
--ag-ui-glyph-stroke: 1.75;
|
|
4200
|
+
}
|
|
4201
|
+
```
|
|
4202
|
+
|
|
4203
|
+
Every glyph is a slot with the built-in mark as its fallback (`icon-send`, `icon-stop`,
|
|
4204
|
+
`icon-attach`, `icon-voice`), so projecting your own icon set never means restyling a character.
|
|
4205
|
+
|
|
4206
|
+
### Motion
|
|
4207
|
+
|
|
4208
|
+
One duration and two curves drive every collapse, expand and slide-over, so the whole widget
|
|
4209
|
+
settles as one thing:
|
|
4210
|
+
|
|
4211
|
+
```css
|
|
4212
|
+
ag-ui-chat {
|
|
4213
|
+
--ag-ui-motion: 0.28s;
|
|
4214
|
+
--ag-ui-ease: cubic-bezier(0.32, 0.72, 0, 1); /* the settle */
|
|
4215
|
+
--ag-ui-ease-pop: cubic-bezier(0.34, 1.36, 0.64, 1); /* the arrival, with overshoot */
|
|
4216
|
+
}
|
|
4217
|
+
```
|
|
4218
|
+
|
|
4219
|
+
Under `prefers-reduced-motion: reduce` the duration collapses to a single frame: states still
|
|
4220
|
+
change, nothing travels. Set `--ag-ui-motion: 0s` to switch the animation off outright.
|
|
4221
|
+
|
|
4222
|
+
### Voice input
|
|
4223
|
+
|
|
4224
|
+
Set `data-transcribe-url` (django-ag-ui's `TranscribeView`) to reveal a mic button in the
|
|
4225
|
+
composer (part `voice-button`). Click it to record via `MediaRecorder`, click again to stop — the
|
|
4226
|
+
clip is POSTed to the endpoint and the returned transcript is dropped into the textarea. Swap the
|
|
4227
|
+
transport with a custom `transcribeHandler` — `(audio: Blob) => Promise<string>` — to use a
|
|
4228
|
+
different STT endpoint or a browser Web Speech adapter without touching the button; when set, the
|
|
4229
|
+
mic appears even with no `data-transcribe-url`.
|
|
4230
|
+
|
|
4231
|
+
```html
|
|
4232
|
+
<ag-ui-chat endpoint="/agent/" data-transcribe-url="/agent/transcribe/"></ag-ui-chat>
|
|
4233
|
+
```
|
|
4234
|
+
|
|
4235
|
+
### Theme toggle
|
|
4236
|
+
|
|
4237
|
+
`theme` is a plain attribute you can set yourself, and a host can always drop its own switch into
|
|
4238
|
+
`slot="header-actions"`. For convenience, the boolean `data-theme-toggle` attribute adds a built-in
|
|
4239
|
+
light⇄dark toggle to the header (part `theme-toggle`) that flips the scheme word of `theme` and
|
|
4240
|
+
persists the choice per tab. Only the scheme changes: `soft` becomes `soft dark`, `soft dark`
|
|
4241
|
+
becomes `soft light`, and `auto` becomes the opposite of what the system shows, an explicit
|
|
4242
|
+
choice. With no scheme word the toggle flips the scheme the panel is drawn in, so a host rule
|
|
4243
|
+
that sets the element's `color-scheme` (to follow the page's own dark mode) is flipped from,
|
|
4244
|
+
and the written word then holds against that rule. The toggle's glyph follows a `theme` the host
|
|
4245
|
+
writes later too, so a host's own picker and the toggle never disagree. Only the scheme is saved, too:
|
|
4246
|
+
on the next load it is put beside whatever theme name the markup carries, so `dark` chosen on a
|
|
4247
|
+
`soft` page restores as `classic dark` on a `classic` one. Off by default so it never competes with
|
|
4248
|
+
a host-supplied control.
|
|
4249
|
+
|
|
4250
|
+
```html
|
|
4251
|
+
<ag-ui-chat endpoint="/agent/" data-theme-toggle></ag-ui-chat>
|
|
4252
|
+
```
|
|
4253
|
+
|
|
4254
|
+
---
|
|
4255
|
+
|
|
4256
|
+
## Internationalization (i18n)
|
|
4257
|
+
|
|
4258
|
+
Every user-facing string — labels, placeholders, `aria-label`s, and `title` tooltips — is read
|
|
4259
|
+
from a flat `UiStrings` table, so a non-English host can translate the widget without forking it.
|
|
4260
|
+
Override any subset; the rest fall back to the English defaults. Two equivalent seams:
|
|
4261
|
+
|
|
4262
|
+
```js
|
|
4263
|
+
// As a property (merged over the defaults):
|
|
4264
|
+
chat.strings = { send: "Senden", inputPlaceholder: "Frag mich…", stop: "Stopp" };
|
|
4265
|
+
```
|
|
4266
|
+
|
|
4267
|
+
```html
|
|
4268
|
+
<!-- Or inline, as JSON (the property wins key-by-key when both are set): -->
|
|
4269
|
+
<ag-ui-chat endpoint="/agent/" data-strings='{"send": "Senden", "inputPlaceholder": "Frag mich…"}'></ag-ui-chat>
|
|
4270
|
+
```
|
|
4271
|
+
|
|
4272
|
+
Set `strings` / `data-strings` **before** the element connects (they resolve on mount). A few keys
|
|
4273
|
+
are templates carrying `{token}` placeholders the widget fills in — e.g. `minutesAgo`
|
|
4274
|
+
(`"{n}m ago"`), `confirmRun` (`"Run “{tool}”?"`), `tooLarge` (`"Too large (max {size})"`). Keep the
|
|
4275
|
+
token verbatim when translating. The full key list and English defaults live in
|
|
4276
|
+
[`src/ui/ui_strings.ts`](src/ui/ui_strings.ts) (exported as `DEFAULT_UI_STRINGS`); `mergeUiStrings`
|
|
4277
|
+
is exported too if you want to compute a complete table yourself.
|
|
4278
|
+
|
|
4279
|
+
---
|
|
4280
|
+
|
|
4281
|
+
## Building the bundle
|
|
4282
|
+
|
|
4283
|
+
The build is driven by [esbuild](esbuild.config.mjs) plus `tsc` for type declarations:
|
|
4284
|
+
|
|
4285
|
+
```bash
|
|
4286
|
+
make build # node esbuild.config.mjs && tsc -p tsconfig.build.json
|
|
4287
|
+
```
|
|
4288
|
+
|
|
4289
|
+
This produces, into `dist/`:
|
|
4290
|
+
|
|
4291
|
+
- `index.js` — the ESM library build; `@ag-ui/*` are left **external** so npm consumers dedupe
|
|
4292
|
+
them.
|
|
4293
|
+
- `agents-web-components.bundle.js` — the **vendored** ESM bundle, every dependency inlined and
|
|
4294
|
+
minified, suitable for direct `<script type="module">` embedding.
|
|
4295
|
+
- `index.d.ts` (+ source maps) — type declarations; emitted `.js` import specifiers are preserved
|
|
4296
|
+
so consumers resolve types without extra flags.
|
|
4297
|
+
|
|
4298
|
+
There is **no CSS file to load**. The styles are string constants adopted into
|
|
4299
|
+
the shadow root at construction, so they ship inside the JS and cannot leak into
|
|
4300
|
+
the host page. Restyle through the [CSS custom properties](#theming-density-and-placement) and the
|
|
4301
|
+
`part` attributes, not a stylesheet override.
|
|
4302
|
+
|
|
4303
|
+
Other workflow targets (all identical in name to the sibling Python packages):
|
|
4304
|
+
|
|
4305
|
+
| Target | What it does |
|
|
4306
|
+
| --- | --- |
|
|
4307
|
+
| `make test` | Vitest with a 100% line + branch + function + statement coverage gate. |
|
|
4308
|
+
| `make lint` | `biome check .` + `tsc --noEmit`. |
|
|
4309
|
+
| `make format` | `biome format --write .`. |
|
|
4310
|
+
| `make demo` | Build, then serve the live playground (`demo/themes/index.html`) on port 5173 via `demo/mock-server.mjs`. |
|
|
4311
|
+
|
|
4312
|
+
---
|
|
4313
|
+
|
|
4314
|
+
## Moving from `@artooi/ag-ui-web-component`
|
|
4315
|
+
|
|
4316
|
+
The old package is replaced, not upgraded: the tags, events, parts, slots and `--ag-ui-*` custom
|
|
4317
|
+
properties keep their names, so markup and stylesheets carry over untouched. What changes is the
|
|
4318
|
+
package, how the element is defined, and a handful of removals.
|
|
4319
|
+
|
|
4320
|
+
**Install the new package and remove the old one.** Never keep both on a page: each defines the
|
|
4321
|
+
`<ag-ui-chat>` tag, and the second to load cannot claim it.
|
|
4322
|
+
|
|
4323
|
+
```bash
|
|
4324
|
+
npm uninstall @artooi/ag-ui-web-component
|
|
4325
|
+
npm install @artooi/agents-web-components
|
|
4326
|
+
```
|
|
4327
|
+
|
|
4328
|
+
A vendored bundle is renamed too: `dist/agents-web-components.bundle.js` replaces
|
|
4329
|
+
`dist/ag-ui-web-component.bundle.js`.
|
|
4330
|
+
|
|
4331
|
+
**Define the element.** `defineAgUiChat` is now `defineChat`, and it lives on the `./chat` entry.
|
|
4332
|
+
Importing the package still registers nothing, so the call stays explicit:
|
|
4333
|
+
|
|
4334
|
+
```js
|
|
4335
|
+
// before
|
|
4336
|
+
import { defineAgUiChat } from "@artooi/ag-ui-web-component";
|
|
4337
|
+
defineAgUiChat();
|
|
4338
|
+
|
|
4339
|
+
// after
|
|
4340
|
+
import { defineChat } from "@artooi/agents-web-components/chat";
|
|
4341
|
+
defineChat();
|
|
4342
|
+
```
|
|
4343
|
+
|
|
4344
|
+
The bundle exports it too (`import { defineChat } from "@artooi/agents-web-components/bundle"`).
|
|
4345
|
+
`defineChat()` does nothing when `AgUiChat` already holds the tag, and throws, naming the tag,
|
|
4346
|
+
when another class holds it, which is how a second copy of the element shows itself.
|
|
4347
|
+
|
|
4348
|
+
**The first deploy forgets what the browser stored.** Every storage key now starts with
|
|
4349
|
+
`agents-web-components` instead of `ag-ui-chat`, and nothing migrates the old keys. The first page
|
|
4350
|
+
load after the move starts from the defaults: the open thread, the collapsed state, the panel
|
|
4351
|
+
size, the launcher and dock position and the theme are gone once, and so is the drawer's list of
|
|
4352
|
+
threads, unless `data-threads-url` is set, in which case the server still supplies it.
|
|
4353
|
+
A host that reads the old keys itself must read the new root.
|
|
4354
|
+
|
|
4355
|
+
**Removed names.** Each is gone; most have a replacement that behaves the same:
|
|
4356
|
+
|
|
4357
|
+
| Removed | Use instead |
|
|
4358
|
+
| --- | --- |
|
|
4359
|
+
| `defineAgUiChat` | `defineChat`, from `./chat` |
|
|
4360
|
+
| `createStateHookTools`, `StateHook` | `createPageStateTools`, `PageState` |
|
|
4361
|
+
| the element's `registerStateHook` method | `registerPageState` |
|
|
4362
|
+
| `AgUiClient.annotatedMessages` | `AgUiClient.messages` |
|
|
4363
|
+
| the `noResult` string in `UiStrings` | nothing: `callNotFinished` answers a call that never returned |
|
|
4364
|
+
|
|
4365
|
+
A string table that still sets `noResult` stops type-checking; delete the entry.
|
|
4366
|
+
|
|
4367
|
+
**Server-side rendering.** The element extends `HTMLElement` and some helpers read the DOM, so
|
|
4368
|
+
evaluating the package in Node throws. Import it on the client only: from a client-side module, a
|
|
4369
|
+
dynamic `import()` behind a browser check, or your framework's client-only boundary. The package
|
|
4370
|
+
declares `sideEffects: false`, so an unused import is dropped, but that is a bundling property,
|
|
4371
|
+
not a promise that the modules load without a DOM.
|
|
4372
|
+
|
|
4373
|
+
---
|
|
4374
|
+
|
|
4375
|
+
## Compatibility
|
|
4376
|
+
|
|
4377
|
+
| Component | Floor | Tested |
|
|
4378
|
+
| --- | --- | --- |
|
|
4379
|
+
| Node (tooling/tests only) | 22 | 22, 24 |
|
|
4380
|
+
| Browsers (runtime target) | Chrome 123, Firefox 120, Safari 17.5 | Chromium |
|
|
4381
|
+
| `@ag-ui/client`, `@ag-ui/core` | 1.0 | 1.0 |
|
|
4382
|
+
| django-ag-ui (server) | 0.63.0 | — |
|
|
4383
|
+
|
|
4384
|
+
The shipped artefact targets evergreen browsers (Shadow DOM, Custom Elements v1, ES2022). The
|
|
4385
|
+
browser floor is set by `light-dark()`, which every theme declares its colours with: below Chrome
|
|
4386
|
+
123, Firefox 120 or Safari 17.5 those colours do not resolve. Node is only the build/test runtime,
|
|
4387
|
+
not a runtime target.
|
|
4388
|
+
|
|
4389
|
+
The server floor is a wire contract rather than a dependency. A tool call's outcome and a user
|
|
4390
|
+
message's attachment refs travel in `metadata`, since `@ag-ui/client` 1.0 strips every key its
|
|
4391
|
+
schemas do not declare. Against an older django-ag-ui the chat still runs, but the agent is never
|
|
4392
|
+
told about an attached file and a failed or declined server-side tool call settles as done.
|
|
4393
|
+
|
|
4394
|
+
---
|
|
4395
|
+
|
|
4396
|
+
## License
|
|
4397
|
+
|
|
4398
|
+
[MIT](LICENSE) © Artur Veres
|