From a12047ae664b4f8075ff7329a9e5427c4c0f2d2b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sat, 8 Aug 2026 01:22:43 -0700 Subject: [PATCH] [eric] apps: the full tool-ui widget set is vendored into every app workspace behind an @toolui alias, deps exact-pinned to the host's locked versions --- backend/apps/outputs/app_builder_skill.md | 5 + backend/apps/outputs/webapp_template/SDK.md | 29 + .../webapp_template/frontend/package.json | 36 +- .../frontend/src/toolui/.lintignore | 1 + .../frontend/src/toolui/GENERATED.md | 4 + .../frontend/src/toolui/LICENSE.md | 21 + .../frontend/src/toolui/VendoredToolUi.tsx | 216 +++ .../toolui/components/approval-card/README.md | 19 + .../components/approval-card/_adapter.tsx | 11 + .../approval-card/approval-card.tsx | 212 +++ .../toolui/components/approval-card/index.tsx | 7 + .../toolui/components/approval-card/schema.ts | 54 + .../src/toolui/components/audio/README.md | 19 + .../src/toolui/components/audio/_adapter.tsx | 8 + .../src/toolui/components/audio/audio.tsx | 341 ++++ .../src/toolui/components/audio/context.tsx | 53 + .../src/toolui/components/audio/index.ts | 5 + .../src/toolui/components/audio/schema.ts | 46 + .../src/toolui/components/chart/README.md | 19 + .../src/toolui/components/chart/_adapter.tsx | 27 + .../src/toolui/components/chart/chart.tsx | 180 +++ .../src/toolui/components/chart/index.tsx | 8 + .../src/toolui/components/chart/schema.ts | 121 ++ .../src/toolui/components/citation/README.md | 19 + .../toolui/components/citation/_adapter.tsx | 18 + .../components/citation/citation-list.tsx | 460 ++++++ .../toolui/components/citation/citation.tsx | 259 ++++ .../src/toolui/components/citation/index.ts | 9 + .../src/toolui/components/citation/schema.ts | 52 + .../toolui/components/code-block/README.md | 19 + .../toolui/components/code-block/_adapter.tsx | 14 + .../components/code-block/code-block.tsx | 469 ++++++ .../toolui/components/code-block/index.tsx | 11 + .../toolui/components/code-block/schema.ts | 43 + .../toolui/components/code-diff/_adapter.tsx | 14 + .../toolui/components/code-diff/code-diff.tsx | 463 ++++++ .../src/toolui/components/code-diff/index.tsx | 7 + .../src/toolui/components/code-diff/schema.ts | 71 + .../toolui/components/data-table/README.md | 19 + .../toolui/components/data-table/_adapter.tsx | 44 + .../components/data-table/data-table.tsx | 1019 ++++++++++++ .../components/data-table/formatters.tsx | 473 ++++++ .../toolui/components/data-table/index.tsx | 29 + .../toolui/components/data-table/schema.ts | 358 +++++ .../src/toolui/components/data-table/types.ts | 265 ++++ .../toolui/components/data-table/utilities.ts | 299 ++++ .../src/toolui/components/geo-map/README.md | 24 + .../toolui/components/geo-map/_adapter.tsx | 23 + .../components/geo-map/geo-map-engine.tsx | 756 +++++++++ .../components/geo-map/geo-map-icons.ts | 131 ++ .../components/geo-map/geo-map-overlays.tsx | 86 ++ .../geo-map/geo-map-theme.module.css | 216 +++ .../src/toolui/components/geo-map/geo-map.tsx | 162 ++ .../src/toolui/components/geo-map/index.tsx | 16 + .../components/geo-map/leaflet-overrides.css | 37 + .../src/toolui/components/geo-map/schema.ts | 198 +++ .../toolui/components/image-gallery/README.md | 19 + .../components/image-gallery/_adapter.tsx | 13 + .../components/image-gallery/context.tsx | 184 +++ .../components/image-gallery/gallery-grid.tsx | 133 ++ .../image-gallery/gallery-lightbox.tsx | 146 ++ .../image-gallery/image-gallery.tsx | 75 + .../toolui/components/image-gallery/index.tsx | 6 + .../toolui/components/image-gallery/schema.ts | 59 + .../components/image-gallery/styles.css | 25 + .../src/toolui/components/image/README.md | 19 + .../src/toolui/components/image/_adapter.tsx | 11 + .../src/toolui/components/image/image.tsx | 206 +++ .../src/toolui/components/image/index.ts | 3 + .../src/toolui/components/image/schema.ts | 50 + .../components/instagram-post/README.md | 19 + .../components/instagram-post/_adapter.tsx | 19 + .../toolui/components/instagram-post/index.ts | 8 + .../instagram-post/instagram-post.tsx | 305 ++++ .../components/instagram-post/schema.ts | 57 + .../toolui/components/item-carousel/README.md | 19 + .../components/item-carousel/_adapter.tsx | 12 + .../toolui/components/item-carousel/index.tsx | 8 + .../components/item-carousel/item-card.tsx | 110 ++ .../item-carousel/item-carousel.tsx | 404 +++++ .../toolui/components/item-carousel/schema.ts | 77 + .../toolui/components/link-preview/README.md | 19 + .../components/link-preview/_adapter.tsx | 6 + .../toolui/components/link-preview/index.ts | 3 + .../components/link-preview/link-preview.tsx | 141 ++ .../toolui/components/link-preview/schema.ts | 43 + .../toolui/components/linkedin-post/README.md | 19 + .../components/linkedin-post/_adapter.tsx | 19 + .../toolui/components/linkedin-post/index.ts | 9 + .../linkedin-post/linkedin-post.tsx | 283 ++++ .../toolui/components/linkedin-post/schema.ts | 59 + .../toolui/components/message-draft/README.md | 19 + .../components/message-draft/_adapter.tsx | 12 + .../toolui/components/message-draft/index.tsx | 10 + .../message-draft/message-draft.tsx | 511 ++++++ .../toolui/components/message-draft/schema.ts | 83 + .../toolui/components/option-list/README.md | 19 + .../components/option-list/_adapter.tsx | 14 + .../toolui/components/option-list/index.tsx | 7 + .../components/option-list/option-list.tsx | 625 ++++++++ .../toolui/components/option-list/schema.ts | 210 +++ .../components/option-list/selection.ts | 35 + .../toolui/components/order-summary/README.md | 19 + .../components/order-summary/_adapter.tsx | 16 + .../toolui/components/order-summary/index.tsx | 14 + .../order-summary/order-summary.tsx | 296 ++++ .../toolui/components/order-summary/schema.ts | 108 ++ .../components/parameter-slider/README.md | 19 + .../components/parameter-slider/_adapter.tsx | 16 + .../components/parameter-slider/index.tsx | 7 + .../components/parameter-slider/math.ts | 42 + .../parameter-slider/parameter-slider.tsx | 821 ++++++++++ .../components/parameter-slider/schema.ts | 114 ++ .../src/toolui/components/plan/README.md | 19 + .../src/toolui/components/plan/_adapter.tsx | 32 + .../src/toolui/components/plan/index.tsx | 7 + .../src/toolui/components/plan/plan.tsx | 428 +++++ .../src/toolui/components/plan/progress.ts | 29 + .../src/toolui/components/plan/schema.ts | 69 + .../components/preferences-panel/README.md | 19 + .../components/preferences-panel/_adapter.tsx | 28 + .../components/preferences-panel/index.tsx | 10 + .../preferences-panel/preferences-panel.tsx | 681 ++++++++ .../components/preferences-panel/schema.ts | 144 ++ .../components/preferences-panel/signature.ts | 37 + .../components/progress-tracker/README.md | 19 + .../components/progress-tracker/_adapter.tsx | 10 + .../components/progress-tracker/index.tsx | 7 + .../progress-tracker/progress-tracker.tsx | 381 +++++ .../components/progress-tracker/schema.ts | 76 + .../toolui/components/question-flow/README.md | 19 + .../components/question-flow/_adapter.tsx | 14 + .../toolui/components/question-flow/index.tsx | 15 + .../question-flow/question-flow.tsx | 793 ++++++++++ .../toolui/components/question-flow/schema.ts | 131 ++ .../src/toolui/components/shared/README.md | 19 + .../src/toolui/components/shared/_adapter.tsx | 12 + .../components/shared/action-buttons.tsx | 100 ++ .../components/shared/actions-config.ts | 48 + .../src/toolui/components/shared/contract.ts | 19 + .../components/shared/decision-actions.tsx | 87 ++ .../components/shared/embedded-actions.ts | 17 + .../src/toolui/components/shared/index.ts | 27 + .../components/shared/local-actions.tsx | 54 + .../components/shared/media/aspect-ratio.ts | 27 + .../components/shared/media/format-utils.ts | 35 + .../toolui/components/shared/media/index.ts | 19 + .../shared/media/overlay-gradient.ts | 25 + .../shared/media/safe-navigation.ts | 23 + .../components/shared/media/sanitize-href.ts | 36 + .../src/toolui/components/shared/parse.ts | 51 + .../components/shared/pierre-dark-theme.js | 1374 +++++++++++++++++ .../components/shared/pierre-light-theme.js | 1374 +++++++++++++++++ .../src/toolui/components/shared/schema.ts | 159 ++ .../components/shared/tool-ui-context.tsx | 27 + .../src/toolui/components/shared/tool-ui.tsx | 89 ++ .../src/toolui/components/shared/toolkit.tsx | 20 + .../components/shared/use-action-buttons.tsx | 153 ++ .../shared/use-controllable-state.ts | 54 + .../shared/use-copy-to-clipboard.ts | 59 + .../components/shared/use-signature-reset.ts | 16 + .../src/toolui/components/shared/utils.ts | 29 + .../toolui/components/stats-display/README.md | 19 + .../components/stats-display/_adapter.tsx | 18 + .../toolui/components/stats-display/index.tsx | 10 + .../toolui/components/stats-display/schema.ts | 91 ++ .../components/stats-display/sparkline.tsx | 128 ++ .../stats-display/stats-display.tsx | 281 ++++ .../src/toolui/components/terminal/README.md | 19 + .../toolui/components/terminal/_adapter.tsx | 14 + .../src/toolui/components/terminal/index.tsx | 2 + .../src/toolui/components/terminal/schema.ts | 43 + .../toolui/components/terminal/terminal.tsx | 283 ++++ .../src/toolui/components/video/README.md | 19 + .../src/toolui/components/video/_adapter.tsx | 7 + .../src/toolui/components/video/context.tsx | 53 + .../src/toolui/components/video/index.ts | 3 + .../src/toolui/components/video/schema.ts | 50 + .../toolui/components/video/video-helpers.ts | 57 + .../src/toolui/components/video/video.tsx | 283 ++++ .../weather-runtime-core.generated.ts | 67 + .../components/weather-widget/runtime.ts | 18 + .../weather-widget/schema-runtime.ts | 72 + .../weather-widget/weather-data-overlay.tsx | 586 +++++++ .../weather-widget-container.tsx | 144 ++ .../src/toolui/components/x-post/README.md | 19 + .../src/toolui/components/x-post/_adapter.tsx | 19 + .../src/toolui/components/x-post/index.ts | 9 + .../src/toolui/components/x-post/schema.ts | 68 + .../src/toolui/components/x-post/x-post.tsx | 348 +++++ .../frontend/src/toolui/lib/utils.ts | 6 + .../frontend/src/toolui/registry.tsx | 132 ++ .../frontend/src/toolui/toolui.css | 193 +++ .../frontend/src/toolui/ui/accordion.tsx | 76 + .../frontend/src/toolui/ui/alert.tsx | 68 + .../frontend/src/toolui/ui/avatar.tsx | 53 + .../frontend/src/toolui/ui/badge.tsx | 48 + .../frontend/src/toolui/ui/button-group.tsx | 83 + .../frontend/src/toolui/ui/button.tsx | 63 + .../frontend/src/toolui/ui/card.tsx | 92 ++ .../frontend/src/toolui/ui/chart.tsx | 357 +++++ .../frontend/src/toolui/ui/collapsible.tsx | 39 + .../frontend/src/toolui/ui/dialog.tsx | 143 ++ .../frontend/src/toolui/ui/dropdown-menu.tsx | 257 +++ .../frontend/src/toolui/ui/input-group.tsx | 170 ++ .../frontend/src/toolui/ui/input.tsx | 21 + .../frontend/src/toolui/ui/item.tsx | 193 +++ .../frontend/src/toolui/ui/label.tsx | 24 + .../frontend/src/toolui/ui/popover.tsx | 48 + .../frontend/src/toolui/ui/radio-group.tsx | 45 + .../frontend/src/toolui/ui/select.tsx | 187 +++ .../frontend/src/toolui/ui/separator.tsx | 28 + .../frontend/src/toolui/ui/sheet.tsx | 139 ++ .../frontend/src/toolui/ui/skeleton.tsx | 13 + .../frontend/src/toolui/ui/slider.tsx | 63 + .../frontend/src/toolui/ui/switch.tsx | 31 + .../frontend/src/toolui/ui/table.tsx | 81 + .../frontend/src/toolui/ui/tabs.tsx | 66 + .../frontend/src/toolui/ui/textarea.tsx | 18 + .../frontend/src/toolui/ui/toggle-group.tsx | 83 + .../frontend/src/toolui/ui/toggle.tsx | 47 + .../frontend/src/toolui/ui/tooltip.tsx | 61 + .../webapp_template/frontend/tsconfig.json | 3 +- .../webapp_template/frontend/vite.config.ts | 4 + scripts/sync-toolui-template.sh | 22 + 225 files changed, 25736 insertions(+), 2 deletions(-) create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/.lintignore create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/GENERATED.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/LICENSE.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/VendoredToolUi.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/approval-card.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/audio.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/context.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/chart.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/citation-list.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/citation.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-block/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-block/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-block/code-block.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-block/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-block/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-diff/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-diff/code-diff.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-diff/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/code-diff/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/data-table/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/data-table/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/data-table/data-table.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/data-table/formatters.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/data-table/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/data-table/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/data-table/types.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/data-table/utilities.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/geo-map-engine.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/geo-map-icons.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/geo-map-overlays.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/geo-map-theme.module.css create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/geo-map.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/leaflet-overrides.css create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/geo-map/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/context.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/gallery-grid.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/gallery-lightbox.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/image-gallery.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image-gallery/styles.css create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image/image.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/image/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/instagram-post/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/instagram-post/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/instagram-post/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/instagram-post/instagram-post.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/instagram-post/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/item-card.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/item-carousel.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/link-preview.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/message-draft.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/option-list.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/selection.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/order-summary.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/math.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/parameter-slider.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/plan.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/progress.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/preferences-panel/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/preferences-panel/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/preferences-panel/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/preferences-panel/preferences-panel.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/preferences-panel/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/preferences-panel/signature.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/progress-tracker/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/progress-tracker/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/progress-tracker/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/progress-tracker/progress-tracker.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/progress-tracker/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/question-flow/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/question-flow/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/question-flow/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/question-flow/question-flow.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/question-flow/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/action-buttons.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/actions-config.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/contract.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/decision-actions.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/embedded-actions.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/local-actions.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/media/aspect-ratio.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/media/format-utils.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/media/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/media/overlay-gradient.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/media/safe-navigation.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/media/sanitize-href.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/parse.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/pierre-dark-theme.js create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/pierre-light-theme.js create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/tool-ui-context.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/tool-ui.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/toolkit.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/use-action-buttons.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/use-controllable-state.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/use-copy-to-clipboard.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/use-signature-reset.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/shared/utils.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/stats-display/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/stats-display/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/stats-display/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/stats-display/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/stats-display/sparkline.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/stats-display/stats-display.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/terminal/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/terminal/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/terminal/index.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/terminal/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/terminal/terminal.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/video/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/video/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/video/context.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/video/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/video/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/video/video-helpers.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/video/video.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/weather-widget/generated/weather-runtime-core.generated.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/weather-widget/runtime.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/weather-widget/schema-runtime.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/weather-widget/weather-data-overlay.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/weather-widget/weather-widget-container.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/x-post/README.md create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/x-post/_adapter.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/x-post/index.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/x-post/schema.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/components/x-post/x-post.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/lib/utils.ts create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/registry.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/toolui.css create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/accordion.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/alert.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/avatar.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/badge.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/button-group.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/button.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/card.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/chart.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/collapsible.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/dialog.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/dropdown-menu.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/input-group.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/input.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/item.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/label.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/popover.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/radio-group.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/select.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/separator.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/sheet.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/skeleton.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/slider.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/switch.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/table.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/tabs.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/textarea.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/toggle-group.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/toggle.tsx create mode 100644 backend/apps/outputs/webapp_template/frontend/src/toolui/ui/tooltip.tsx create mode 100755 scripts/sync-toolui-template.sh diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index d24de975..e7ec2d9b 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -368,6 +368,11 @@ Auth is automatic (host-injected token); never hand-roll fetches against host ro works in preview and installed apps; for features that must survive PUBLISHING to the public web, use `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE` below instead. +The workspace also vendors the full tool-ui widget set at `src/toolui/` (data-table, chart, +code-block, code-diff, terminal, geo-map, media, social-post cards, and 15 more): reach for +`import { DataTable } from '@toolui/components/data-table'` before hand-building any table, +chart, or code view. The catalog + usage patterns are in `SDK.md`. + ## Publishable AI + compute — `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE` The FastAPI backend above runs in preview but is **not hosted when an app is diff --git a/backend/apps/outputs/webapp_template/SDK.md b/backend/apps/outputs/webapp_template/SDK.md index 6be32de4..7a8be494 100644 --- a/backend/apps/outputs/webapp_template/SDK.md +++ b/backend/apps/outputs/webapp_template/SDK.md @@ -51,6 +51,35 @@ const state = await agentSession(sessionId); // {status, messages, ...} — pol The agent is a real OpenSwarm agent card the user can watch and take over. Spawn sparingly: one agent per user action, never in a loop. +## Tool UI components — ready-made rich widgets + +The full OpenSwarm tool-ui component set is vendored at `src/toolui/` (import via the +`@toolui` alias). These are the same widgets agents render for rich results: use them +instead of hand-building tables, charts, code viewers, or media blocks. + +Available components (each lives at `@toolui/components/`): approval-card, audio, +chart, citation, code-block, code-diff, data-table, geo-map, image, image-gallery, +instagram-post, item-carousel, link-preview, linkedin-post, message-draft, option-list, +order-summary, parameter-slider, plan, preferences-panel, progress-tracker, question-flow, +stats-display, terminal, video, weather-widget, x-post. + +Render through `VendoredToolUi` (it validates props against the component's zod schema, +loads the styles, and applies the required `.tool-ui-scope` wrapper + dark mode for you): + +```tsx +import VendoredToolUi from '@toolui/VendoredToolUi'; + + +``` + +Direct imports (`import { DataTable } from '@toolui/components/data-table'`) work too, but +then YOU must import `@toolui/toolui.css` once and wrap the render in +`
` (add `dark` in dark mode) or the widget renders unstyled. + +Each component folder carries its own README + zod schema (`@toolui/registry` maps +name -> schema). They style themselves (scoped Tailwind, no preflight), so they drop +into the MUI app without fights, and they follow the app's light/dark mode. + ## What the SDK does NOT give you (yet) - Direct calls to the user's connected tools/MCP connectors (Gmail, Slack, ...). That surface diff --git a/backend/apps/outputs/webapp_template/frontend/package.json b/backend/apps/outputs/webapp_template/frontend/package.json index 1ca8db08..febfd8fc 100644 --- a/backend/apps/outputs/webapp_template/frontend/package.json +++ b/backend/apps/outputs/webapp_template/frontend/package.json @@ -13,17 +13,51 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.9", "@mui/material": "^7.3.9", + "@pierre/diffs": "1.0.11", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-avatar": "^1.2.6", + "@radix-ui/react-collapsible": "^1.1.20", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-radio-group": "^1.4.7", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-toggle": "^1.1.18", + "@radix-ui/react-toggle-group": "^1.1.19", + "@radix-ui/react-tooltip": "^1.2.16", "@reduxjs/toolkit": "^2.8.2", + "ansi-to-react": "6.2.6", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", "framer-motion": "^12.36.0", + "leaflet": "1.9.4", + "lucide-react": "1.17.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-leaflet": "4.2.1", "react-redux": "^9.2.0", - "react-router-dom": "^7.13.1" + "react-router-dom": "^7.13.1", + "recharts": "2.15.4", + "shiki": "3.23.0", + "supercluster": "8.0.1", + "tailwind-merge": "3.6.0", + "tw-animate-css": "1.4.0", + "zod": "4.4.3" }, "devDependencies": { + "@tailwindcss/vite": "^4.1.0", + "@types/leaflet": "^1.9.21", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "@types/supercluster": "^7.1.3", "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "vite": "^6.3.5", "vite-plugin-pages": "^0.33.3", diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/.lintignore b/backend/apps/outputs/webapp_template/frontend/src/toolui/.lintignore new file mode 100644 index 00000000..05a6f010 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/.lintignore @@ -0,0 +1 @@ +# Vendored tool-ui component library (pierre). Do not hand-edit; upstream drift lands wholesale. diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/GENERATED.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/GENERATED.md new file mode 100644 index 00000000..a42c474d --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/GENERATED.md @@ -0,0 +1,4 @@ +This directory is a GENERATED copy of OpenSwarm's `frontend/src/toolui` +(vendored per app template so generated apps can use the tool-ui components). +Do not hand-edit; run `scripts/sync-toolui-template.sh` from the OpenSwarm +repo root to refresh it. diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/LICENSE.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/LICENSE.md new file mode 100644 index 00000000..1be0da05 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 AgentbaseAI Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/VendoredToolUi.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/VendoredToolUi.tsx new file mode 100644 index 00000000..0fafa6e5 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/VendoredToolUi.tsx @@ -0,0 +1,216 @@ +import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { useThemeMode } from '@/shared/styles/ThemeContext'; +import { TOOL_UI_REGISTRY } from './registry'; + +interface GuardProps { name: string; quiet?: boolean; children: React.ReactNode } + +// A component render throwing must cost exactly one quiet line, never the app: the top-level +// ErrorBoundary unmounts the whole shell for any uncaught child throw (the linkedin-post {post} +// mismatch took down the dashboard until this wall existed). +class ComponentGuard extends React.Component { + constructor(props: GuardProps) { + super(props); + this.state = { failed: false }; + } + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true }; + } + + render(): React.ReactNode { + if (this.state.failed) { + if (this.props.quiet) return null; + return ( +
+ Couldn't draw the {this.props.name.replace(/-/g, ' ')} view +
+ ); + } + return this.props.children; + } +} + +interface VendoredToolUiProps { + name: string; + props: Record; + /** Non-serializable React props (callbacks, live overrides) merged AFTER validation of the wire props. */ + extraProps?: Record; + /** Ambient surfaces (the collapsed pill) show NOTHING on failure; a floating error line on the canvas is worse than absence. */ + quietFail?: boolean; +} + +const warnedShapes = new Set(); + +type Gate = + | { state: 'pending' } + | { state: 'ok'; parsed: Record } + | { state: 'bad'; problem: string }; + +/** Models pad payloads with invented keys; strip ONLY unrecognized-key issues and retry once, so + sloppiness self-heals while genuinely wrong shapes still fall back loudly. */ +function slugFor(label: unknown, i: number): string { + const t = typeof label === 'string' ? label.trim().toLowerCase().replace(/\s+/g, '-').slice(0, 40) : ''; + return t || `item-${i + 1}`; +} + +// Mechanical repairs for the mistakes agents actually make (numeric ids, ranked priorities, nested +// row objects, bare action objects). Only ever applied when the strict parse FAILED, and the result +// is re-validated, so a repair can flip fail->pass but never corrupt a valid payload. +function repairCommonAgentShapes(props: Record): Record { + let out: Record; + try { + out = JSON.parse(JSON.stringify(props ?? {}, (_k, v) => (v === undefined ? null : v))); + } catch { + return props; + } + const fixIdLabel = (arr: unknown): unknown => { + if (!Array.isArray(arr)) return arr; + return arr.map((o, i) => { + if (typeof o === 'string') return { id: slugFor(o, i), label: o }; + if (o && typeof o === 'object' && !Array.isArray(o)) { + const obj = { ...(o as Record) }; + // Agents reach for value/name/key and title/text as synonyms; honor them before inventing a slug. + if (obj.id == null || obj.id === '') obj.id = obj.value ?? obj.key ?? obj.name ?? null; + if (obj.id == null || obj.id === '') obj.id = slugFor(obj.label ?? obj.title ?? obj.text, i); + else if (typeof obj.id !== 'string') obj.id = String(obj.id); + if (typeof obj.label !== 'string' || !obj.label) obj.label = String(obj.label ?? obj.title ?? obj.text ?? obj.name ?? obj.id); + return obj; + } + return o; + }); + }; + if ('options' in out) out.options = fixIdLabel(out.options); + if ('actions' in out) { + if (out.actions && !Array.isArray(out.actions) && typeof out.actions === 'object' && 'label' in (out.actions as object)) out.actions = [out.actions]; + out.actions = fixIdLabel(out.actions); + } + const PRIORITY_SYNONYMS: Record = { '1': 'primary', '2': 'secondary', '3': 'tertiary', high: 'primary', medium: 'secondary', low: 'tertiary', primary: 'primary', secondary: 'secondary', tertiary: 'tertiary' }; + if (Array.isArray(out.columns)) { + out.columns = out.columns.map((c) => { + if (c && typeof c === 'object' && 'priority' in (c as object)) { + const mapped = PRIORITY_SYNONYMS[String((c as Record).priority).toLowerCase()]; + const copy = { ...(c as Record) }; + if (mapped) copy.priority = mapped; else delete copy.priority; + return copy; + } + return c; + }); + } + if (Array.isArray(out.data)) { + // Row arrays (instead of keyed objects) zip against the column keys, in order. + const colKeys = Array.isArray(out.columns) + ? (out.columns as Array>).map((c, i) => String((c && typeof c === 'object' ? (c.key ?? c.id ?? c.label) : c) ?? `col${i + 1}`)) + : null; + out.data = out.data.map((row) => { + if (Array.isArray(row) && colKeys && colKeys.length > 0) { + return Object.fromEntries(row.map((v, i) => [colKeys[i] ?? `col${i + 1}`, v])); + } + if (!row || typeof row !== 'object' || Array.isArray(row)) return row; + return Object.fromEntries(Object.entries(row as Record).map(([k, v]) => { + if (v !== null && typeof v === 'object' && !Array.isArray(v)) return [k, JSON.stringify(v)]; + if (Array.isArray(v)) return [k, v.map((x) => (x !== null && typeof x === 'object' ? JSON.stringify(x) : x))]; + return [k, v]; + })); + }); + } + return out; +} + +function parseLeniently(schema: { safeParse: (v: unknown) => any }, props: Record): Gate { + let result = schema.safeParse(props); + let base: Record = props; + if (!result.success) { + const issues: Array<{ code: string; keys?: string[]; path: Array; message: string }> = result.error.issues; + if (issues.every((i) => i.code === 'unrecognized_keys')) { + const cleaned: Record = { ...props }; + for (const issue of issues) { + for (const key of issue.keys || []) delete cleaned[key]; + } + base = cleaned; + result = schema.safeParse(cleaned); + } + } + if (!result.success) { + const repaired = schema.safeParse(repairCommonAgentShapes(base)); + if (repaired.success) return { state: 'ok', parsed: repaired.data as Record }; + } + if (result.success) return { state: 'ok', parsed: result.data as Record }; + const issues = result.error.issues.slice(0, 2).map((i: { path: Array; message: string }) => `${i.path.join('.')}: ${i.message}`).join('; '); + return { state: 'bad', problem: issues }; +} + +// Rough resting height per component family so the loading skeleton reserves believable space +// (Lobe/Open WebUI pattern: a breathing block where the card will land, not a tiny sliver). +function skeletonHeightFor(name: string): number { + if (/table|chart|gallery|map|carousel|post|terminal/.test(name)) return 180; + if (/stats|weather|plan|order|preferences|question/.test(name)) return 110; + return 56; +} + +const SkeletonBlock: React.FC<{ name: string }> = ({ name }) => ( +
+ +
+); + +/** Validates against the upstream zod contract, then renders the vendored component inside the scoped theme. */ +function VendoredToolUi({ name, props, extraProps, quietFail = false }: VendoredToolUiProps): React.ReactElement | null { + const { mode } = useThemeMode(); + const entry = TOOL_UI_REGISTRY[name]; + const [gate, setGate] = useState({ state: 'pending' }); + // Parents rebuild the props object every render; keying the validation on identity re-ran an async zod parse per transcript render (real typing-lag cost in table-bearing chats). Content is the real dependency. + const propsKey = useMemo(() => { try { return JSON.stringify(props); } catch { return String(Math.random()); } }, [props]); + + useEffect(() => { + let cancelled = false; + if (!entry) return undefined; + entry + .loadSchema() + .then((schema) => { + if (!cancelled) setGate(parseLeniently(schema, props)); + }) + .catch(() => { if (!cancelled) setGate({ state: 'bad', problem: 'component failed to load' }); }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entry, propsKey]); + + if (!entry) return null; + if (gate.state === 'bad') { + // Schema jargon is for the console, once per component+issue SHAPE for the whole session; a + // transcript full of the same agent mistake used to print 37 copies of the identical warning. + const shapeKey = `${name}:${gate.problem}`; + if (!warnedShapes.has(shapeKey)) { + warnedShapes.add(shapeKey); + console.warn(`[tool-ui] ${name} payload didn't validate:`, gate.problem); + } + if (quietFail) return null; + return ( +
+ Couldn't draw the {name.replace(/-/g, ' ')} view +
+ ); + } + if (gate.state === 'pending') { + return quietFail ? null : ; + } + const Component = entry.Component; + return ( +
+ + }> + + + +
+ ); +} + +export default VendoredToolUi; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/README.md new file mode 100644 index 00000000..070bdded --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/README.md @@ -0,0 +1,19 @@ +# Approval Card + +Implementation for the "approval-card" Tool UI surface. + +## Files + +- public exports: components/tool-ui/approval-card/index.tsx +- serializable schema + parse helpers: components/tool-ui/approval-card/schema.ts + +## Companion assets + +- Docs page: app/docs/approval-card/content.mdx +- Preset payload: lib/presets/approval-card.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/_adapter.tsx new file mode 100644 index 00000000..770d1920 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/_adapter.tsx @@ -0,0 +1,11 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + */ + +export { cn } from "@toolui/lib/utils"; +export { Separator } from "@toolui/ui/separator"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/approval-card.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/approval-card.tsx new file mode 100644 index 00000000..f0ea1ed9 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/approval-card.tsx @@ -0,0 +1,212 @@ +"use client"; + +import * as React from "react"; +import { cn, Separator } from "./_adapter"; +import type { ApprovalCardProps, ApprovalDecision } from "./schema"; +import { ActionButtons } from "../shared/action-buttons"; +import { type Action } from "../shared/schema"; + +import { icons, Check, X } from "lucide-react"; + +type LucideIcon = React.ComponentType<{ className?: string }>; + +function getLucideIcon(name: string): LucideIcon | null { + const pascalName = name + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + + const Icon = icons[pascalName as keyof typeof icons]; + return Icon ?? null; +} + +interface ApprovalCardReceiptProps { + id: string; + title: string; + choice: ApprovalDecision; + actionLabel?: string; + className?: string; +} + +function ApprovalCardReceipt({ + id, + title, + choice, + actionLabel, + className, +}: ApprovalCardReceiptProps) { + const isApproved = choice === "approved"; + const displayLabel = actionLabel ?? (isApproved ? "Approved" : "Denied"); + + return ( +
+
+ + {isApproved ? : } + +
+ {displayLabel} + {title} +
+
+
+ ); +} + +export function ApprovalCard({ + id, + title, + description, + icon, + metadata, + variant, + confirmLabel, + cancelLabel, + className, + choice, + onConfirm, + onCancel, +}: ApprovalCardProps) { + const resolvedVariant = variant ?? "default"; + const resolvedConfirmLabel = confirmLabel ?? "Approve"; + const resolvedCancelLabel = cancelLabel ?? "Deny"; + const Icon = icon ? getLucideIcon(icon) : null; + + const handleAction = React.useCallback( + async (actionId: string) => { + if (actionId === "confirm") { + await onConfirm?.(); + } else if (actionId === "cancel") { + await onCancel?.(); + } + }, + [onConfirm, onCancel], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel?.(); + } + }, + [onCancel], + ); + + const isDestructive = resolvedVariant === "destructive"; + + const actions: Action[] = [ + { + id: "cancel", + label: resolvedCancelLabel, + variant: "ghost", + }, + { + id: "confirm", + label: resolvedConfirmLabel, + variant: isDestructive ? "destructive" : "default", + }, + ]; + + const viewKey = choice ? `receipt-${choice}` : "interactive"; + + return ( +
+ {choice ? ( + + ) : ( +
+
+
+ {Icon && ( + + + + )} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ + {metadata && metadata.length > 0 && ( + <> + +
+ {metadata.map((item, index) => ( +
+
+ {item.key} +
+
{item.value}
+
+ ))} +
+ + )} +
+
+ +
+
+ )} +
+ ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/index.tsx new file mode 100644 index 00000000..1bbfb6a6 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/index.tsx @@ -0,0 +1,7 @@ +export { ApprovalCard } from "./approval-card"; +export { + type SerializableApprovalCard, + type ApprovalCardProps, + type ApprovalDecision, + type MetadataItem, +} from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/schema.ts new file mode 100644 index 00000000..9371e726 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/approval-card/schema.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema"; +import { defineToolUiContract } from "../shared/contract"; + +export const MetadataItemSchema = z.object({ + key: z.string().min(1), + value: z.string(), +}); + +export type MetadataItem = z.infer; + +export const ApprovalDecisionSchema = z.enum(["approved", "denied"]); + +export type ApprovalDecision = z.infer; + +export const SerializableApprovalCardSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + + title: z.string().min(1), + description: z.string().optional(), + icon: z.string().optional(), + metadata: z.array(MetadataItemSchema).optional(), + + variant: z.enum(["default", "destructive"]).optional(), + + confirmLabel: z.string().optional(), + cancelLabel: z.string().optional(), + + choice: ApprovalDecisionSchema.optional(), +}); + +export type SerializableApprovalCard = z.infer< + typeof SerializableApprovalCardSchema +>; + +const SerializableApprovalCardSchemaContract = defineToolUiContract( + "ApprovalCard", + SerializableApprovalCardSchema, +); + +export const parseSerializableApprovalCard: ( + input: unknown, +) => SerializableApprovalCard = SerializableApprovalCardSchemaContract.parse; + +export const safeParseSerializableApprovalCard: ( + input: unknown, +) => SerializableApprovalCard | null = + SerializableApprovalCardSchemaContract.safeParse; +export interface ApprovalCardProps extends SerializableApprovalCard { + className?: string; + onConfirm?: () => void | Promise; + onCancel?: () => void | Promise; +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/README.md new file mode 100644 index 00000000..da6a00e6 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/README.md @@ -0,0 +1,19 @@ +# Audio + +Implementation for the "audio" Tool UI surface. + +## Files + +- public exports: components/tool-ui/audio/index.ts +- serializable schema + parse helpers: components/tool-ui/audio/schema.ts + +## Companion assets + +- Docs page: app/docs/audio/content.mdx +- Preset payload: lib/presets/audio.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/_adapter.tsx new file mode 100644 index 00000000..ae01af74 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/_adapter.tsx @@ -0,0 +1,8 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + */ +"use client"; + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Slider } from "@toolui/ui/slider"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/audio.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/audio.tsx new file mode 100644 index 00000000..785c3c9f --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/audio.tsx @@ -0,0 +1,341 @@ +"use client"; + +import * as React from "react"; +import { Pause, Play } from "lucide-react"; +import { cn, Button, Slider } from "./_adapter"; + +import { AudioProvider, useAudio } from "./context"; +import type { SerializableAudio, AudioVariant } from "./schema"; + +const FALLBACK_LOCALE = "en-US"; + +function formatTime(seconds: number): string { + if (!Number.isFinite(seconds)) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export interface AudioProps extends SerializableAudio { + variant?: AudioVariant; + className?: string; + onMediaEvent?: (type: "play" | "pause" | "mute" | "unmute") => void; +} + +export function Audio(props: AudioProps) { + return ( + + + + ); +} + +interface PlayerControls { + isPlaying: boolean; + currentTime: number; + duration: number; + onPlayPause: () => void; + onSeek: (value: number[]) => void; + onSeekStart: () => void; + onSeekEnd: () => void; +} + +interface FullPlayerProps { + artwork?: string; + title?: string; + description?: string; + controls: PlayerControls; +} + +function FullPlayer({ + artwork, + title, + description, + controls, +}: FullPlayerProps) { + return ( +
+ {artwork && ( +
+ +
+ )} +
+ {(title || description) && ( +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} +
+ )} +
+
+ +
+ {formatTime(controls.currentTime)} + {formatTime(controls.duration)} +
+
+ +
+
+
+ ); +} + +interface CompactPlayerProps { + artwork?: string; + title?: string; + description?: string; + controls: PlayerControls; +} + +function CompactPlayer({ + artwork, + title, + description, + controls, +}: CompactPlayerProps) { + const progress = + controls.duration > 0 + ? (controls.currentTime / controls.duration) * 100 + : 0; + + return ( +
+ {artwork && ( + <> + +
+ + )} + {artwork && ( +
+ +
+ )} +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} + {controls.duration > 0 && ( +
+
+
+
+ + {formatTime(controls.currentTime)} + +
+ )} +
+ +
+ ); +} + +function AudioInner(props: AudioProps) { + const { variant = "full", className, onMediaEvent, ...serializable } = props; + + const { + id, + src, + title, + description, + artwork, + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + + const { state, setState, setAudioElement } = useAudio(); + const audioRef = React.useRef(null); + const [currentTime, setCurrentTime] = React.useState(0); + const [duration, setDuration] = React.useState(0); + const [isSeeking, setIsSeeking] = React.useState(false); + + React.useEffect(() => { + setAudioElement(audioRef.current); + return () => setAudioElement(null); + }, [setAudioElement]); + + React.useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + if (state.playing && audio.paused) { + void audio.play().catch(() => undefined); + } else if (!state.playing && !audio.paused) { + audio.pause(); + } + }, [state.playing]); + + const handlePlayPause = () => { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + void audio.play().catch(() => undefined); + } else { + audio.pause(); + } + }; + + const handleSeek = (value: number[]) => { + const audio = audioRef.current; + if (!audio) return; + const newTime = value[0]; + audio.currentTime = newTime; + setCurrentTime(newTime); + }; + + const handleSeekStart = () => { + setIsSeeking(true); + }; + + const handleSeekEnd = () => { + setIsSeeking(false); + }; + + const controls: PlayerControls = { + isPlaying: state.playing, + currentTime, + duration, + onPlayPause: handlePlayPause, + onSeek: handleSeek, + onSeekStart: handleSeekStart, + onSeekEnd: handleSeekEnd, + }; + + const isCompact = variant === "compact"; + + return ( +
+
+ {isCompact ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/context.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/context.tsx new file mode 100644 index 00000000..18e37ef8 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/context.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; + +export interface AudioPlaybackState { + playing: boolean; + muted: boolean; +} + +export interface AudioContextValue { + state: AudioPlaybackState; + setState: (patch: Partial) => void; + audioElement: HTMLAudioElement | null; + setAudioElement: (node: HTMLAudioElement | null) => void; +} + +const AudioContext = React.createContext(null); + +export function useAudio() { + const ctx = React.useContext(AudioContext); + if (!ctx) { + throw new Error("useAudio must be used within an "); + } + return ctx; +} + +export interface AudioProviderProps { + children: React.ReactNode; + defaultState?: Partial; +} + +export function AudioProvider({ children, defaultState }: AudioProviderProps) { + const [state, setStateInternal] = React.useState({ + playing: defaultState?.playing ?? false, + muted: defaultState?.muted ?? false, + }); + + const [audioElement, setAudioElement] = + React.useState(null); + + const setState = React.useCallback((patch: Partial) => { + setStateInternal((prev) => ({ ...prev, ...patch })); + }, []); + + const value = React.useMemo( + () => ({ state, setState, audioElement, setAudioElement }), + [state, setState, audioElement], + ); + + return ( + {children} + ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/index.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/index.ts new file mode 100644 index 00000000..9b588b80 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/index.ts @@ -0,0 +1,5 @@ +export { Audio } from "./audio"; +export type { AudioProps } from "./audio"; +export { AudioProvider, useAudio } from "./context"; +export type { AudioPlaybackState, AudioContextValue } from "./context"; +export type { SerializableAudio, Source, AudioVariant } from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/schema.ts new file mode 100644 index 00000000..b4fdcfcf --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/audio/schema.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const SourceSchema = z.object({ + label: z.string(), + iconUrl: z.url().optional(), + url: z.url().optional(), +}); + +export type Source = z.infer; + +export const SerializableAudioSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + assetId: z.string(), + src: z.url(), + title: z.string().optional(), + description: z.string().optional(), + artwork: z.url().optional(), + durationMs: z.number().int().positive().optional(), + fileSizeBytes: z.number().int().positive().optional(), + createdAt: z.string().datetime().optional(), + locale: z.string().optional(), + source: SourceSchema.optional(), +}); + +export type SerializableAudio = z.infer; + +const SerializableAudioSchemaContract = defineToolUiContract( + "Audio", + SerializableAudioSchema, +); + +export const parseSerializableAudio: (input: unknown) => SerializableAudio = + SerializableAudioSchemaContract.parse; + +export const safeParseSerializableAudio: ( + input: unknown, +) => SerializableAudio | null = SerializableAudioSchemaContract.safeParse; +export type AudioVariant = "full" | "compact"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/README.md new file mode 100644 index 00000000..eb421cd2 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/README.md @@ -0,0 +1,19 @@ +# Chart + +Implementation for the "chart" Tool UI surface. + +## Files + +- public exports: components/tool-ui/chart/index.tsx +- serializable schema + parse helpers: components/tool-ui/chart/schema.ts + +## Companion assets + +- Docs page: app/docs/chart/content.mdx +- Preset payload: lib/presets/chart.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/_adapter.tsx new file mode 100644 index 00000000..52cd2e17 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/_adapter.tsx @@ -0,0 +1,27 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Chart → shadcn/ui Chart (recharts wrapper) + * Card → shadcn/ui Card + */ + +export { cn } from "@toolui/lib/utils"; +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + type ChartConfig, +} from "@toolui/ui/chart"; +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@toolui/ui/card"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/chart.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/chart.tsx new file mode 100644 index 00000000..97a7b44b --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/chart.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useMemo, useCallback, memo } from "react"; +import { + BarChart, + LineChart, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; + +import { + cn, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + type ChartConfig, +} from "./_adapter"; +import type { ChartProps } from "./schema"; + +const DEFAULT_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +]; + +export const Chart = memo(function Chart({ + id, + type, + title, + description, + data, + xKey, + series, + colors, + showLegend = false, + showGrid = true, + className, + onDataPointClick, +}: ChartProps) { + const palette = colors?.length ? colors : DEFAULT_COLORS; + + const seriesColors = useMemo( + () => + series.map( + (seriesItem, index) => + seriesItem.color ?? palette[index % palette.length], + ), + [series, palette], + ); + + const chartConfig: ChartConfig = useMemo( + () => + Object.fromEntries( + series.map((seriesItem, index) => [ + seriesItem.key, + { + label: seriesItem.label, + color: seriesColors[index], + }, + ]), + ), + [series, seriesColors], + ); + + const handleDataPointClick = useCallback( + ( + seriesKey: string, + seriesLabel: string, + payload: Record, + index: number, + ) => { + onDataPointClick?.({ + seriesKey, + seriesLabel, + xValue: payload[xKey], + yValue: payload[seriesKey], + index, + payload, + }); + }, + [onDataPointClick, xKey], + ); + + const ChartComponent = type === "bar" ? BarChart : LineChart; + + const chartContent = ( + + + {showGrid && } + + + } /> + {showLegend && } />} + + {type === "bar" && + series.map((s, i) => ( + + handleDataPointClick(s.key, s.label, data.payload, data.index) + } + cursor={onDataPointClick ? "pointer" : undefined} + /> + ))} + + {type === "line" && + series.map((s, i) => ( + ; index: number }, + ) => { + handleDataPointClick( + s.key, + s.label, + dotData.payload, + dotData.index, + ); + }) as unknown as React.MouseEventHandler, + }} + /> + ))} + + + ); + + return ( + + {(title || description) && ( + + {title && {title}} + {description && ( + + {description} + + )} + + )} + {chartContent} + + ); +}); diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/index.tsx new file mode 100644 index 00000000..aeebab8f --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/index.tsx @@ -0,0 +1,8 @@ +export { Chart } from "./chart"; +export { + type ChartProps, + type ChartSeries, + type ChartDataPoint, + type ChartClientProps, + type SerializableChart, +} from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/schema.ts new file mode 100644 index 00000000..77a5b7f1 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/chart/schema.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const ChartSeriesSchema = z.object({ + key: z.string().min(1), + label: z.string().min(1), + color: z.string().optional(), +}); + +export type ChartSeries = z.infer; + +export const ChartPropsSchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + type: z.enum(["bar", "line"]), + title: z.string().optional(), + description: z.string().optional(), + data: z.array(z.record(z.string(), z.unknown())).min(1), + xKey: z.string().min(1), + series: z.array(ChartSeriesSchema).min(1), + /** Color palette applied to series in order. Individual series.color takes precedence. */ + colors: z.array(z.string().min(1)).min(1).optional(), + showLegend: z.boolean().optional(), + showGrid: z.boolean().optional(), + }) + .superRefine((value, ctx) => { + const seenSeriesKeys = new Set(); + value.series.forEach((series, index) => { + if (seenSeriesKeys.has(series.key)) { + ctx.addIssue({ + code: "custom", + path: ["series", index, "key"], + message: `Duplicate series key "${series.key}".`, + }); + return; + } + seenSeriesKeys.add(series.key); + }); + + value.data.forEach((row, rowIndex) => { + if (!(value.xKey in row)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, value.xKey], + message: `Missing xKey "${value.xKey}" in data row.`, + }); + } else { + const xVal = row[value.xKey]; + const isValidX = typeof xVal === "string" || typeof xVal === "number"; + if (!isValidX) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, value.xKey], + message: `Expected "${value.xKey}" to be a string or number.`, + }); + } + } + + value.series.forEach((series) => { + if (!(series.key in row)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, series.key], + message: `Missing series key "${series.key}" in data row.`, + }); + return; + } + + const yVal = row[series.key]; + if (yVal === null) { + return; + } + if (typeof yVal !== "number" || !Number.isFinite(yVal)) { + ctx.addIssue({ + code: "custom", + path: ["data", rowIndex, series.key], + message: `Expected "${series.key}" to be a finite number (or null).`, + }); + } + }); + }); + }); + +export type ChartDataPoint = { + seriesKey: string; + seriesLabel: string; + xValue: unknown; + yValue: unknown; + index: number; + payload: Record; +}; + +export type ChartClientProps = { + className?: string; + onDataPointClick?: (point: ChartDataPoint) => void; +}; + +export type ChartProps = z.infer & ChartClientProps; + +export const SerializableChartSchema = ChartPropsSchema; + +export type SerializableChart = z.infer; + +const SerializableChartSchemaContract = defineToolUiContract( + "Chart", + SerializableChartSchema, +); + +export const parseSerializableChart: (input: unknown) => SerializableChart = + SerializableChartSchemaContract.parse; + +export const safeParseSerializableChart: ( + input: unknown, +) => SerializableChart | null = SerializableChartSchemaContract.safeParse; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/README.md new file mode 100644 index 00000000..e248e34d --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/README.md @@ -0,0 +1,19 @@ +# Citation + +Implementation for the "citation" Tool UI surface. + +## Files + +- public exports: components/tool-ui/citation/index.ts +- serializable schema + parse helpers: components/tool-ui/citation/schema.ts + +## Companion assets + +- Docs page: app/docs/citation/content.mdx +- Preset payload: lib/presets/citation.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/_adapter.tsx new file mode 100644 index 00000000..33f4cdc4 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/_adapter.tsx @@ -0,0 +1,18 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Tooltip → shadcn/ui Tooltip (only needed for variant="inline") + * Popover → shadcn/ui Popover (only needed for CitationList) + */ +"use client"; + +export { cn } from "@toolui/lib/utils"; +export { + Popover, + PopoverContent, + PopoverTrigger, +} from "@toolui/ui/popover"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/citation-list.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/citation-list.tsx new file mode 100644 index 00000000..db586c7e --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/citation-list.tsx @@ -0,0 +1,460 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; +import { Citation } from "./citation"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; +import { + openSafeNavigationHref, + resolveSafeNavigationHref, +} from "../shared/media"; + +const TYPE_ICONS: Record = { + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, +}; + +function useHoverPopover(delay = 100) { + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + const containerRef = React.useRef(null); + + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); + + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); + + const handleFocus = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setOpen(true); + }, []); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (containerRef.current?.contains(relatedTarget)) { + return; + } + if (relatedTarget?.closest("[data-radix-popper-content-wrapper]")) { + return; + } + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, + [delay], + ); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { + open, + setOpen, + containerRef, + handleMouseEnter, + handleMouseLeave, + handleFocus, + handleBlur, + }; +} + +export interface CitationListProps { + id: string; + citations: SerializableCitation[]; + variant?: CitationVariant; + maxVisible?: number; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +export function CitationList(props: CitationListProps) { + const { + id, + citations, + variant = "default", + maxVisible, + className, + onNavigate, + } = props; + + const shouldTruncate = + maxVisible !== undefined && citations.length > maxVisible; + const visibleCitations = shouldTruncate + ? citations.slice(0, maxVisible) + : citations; + const overflowCitations = shouldTruncate ? citations.slice(maxVisible) : []; + const overflowCount = overflowCitations.length; + + const wrapperClass = + variant === "inline" + ? "flex flex-wrap items-center gap-1.5" + : "flex flex-col gap-2"; + + // Stacked variant: overlapping favicons with popover + if (variant === "stacked") { + return ( + + ); + } + + if (variant === "default") { + return ( +
+ {visibleCitations.map((citation) => ( + + ))} + {shouldTruncate && ( + + )} +
+ ); + } + + return ( +
+ {visibleCitations.map((citation) => ( + + ))} + {shouldTruncate && ( + + )} +
+ ); +} + +interface OverflowIndicatorProps { + citations: SerializableCitation[]; + count: number; + variant: CitationVariant; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +function OverflowIndicator({ + citations, + count, + variant, + onNavigate, +}: OverflowIndicatorProps) { + const { open, handleMouseEnter, handleMouseLeave } = useHoverPopover(); + + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; + + const popoverContent = ( +
+ {citations.map((citation) => ( + handleClick(citation)} + /> + ))} +
+ ); + + if (variant === "inline") { + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); + } + + // Default variant + return ( + + + + + e.preventDefault()} + > + {popoverContent} + + + ); +} + +interface OverflowItemProps { + citation: SerializableCitation; + onClick: () => void; +} + +function OverflowItem({ citation, onClick }: OverflowItemProps) { + const TypeIcon = TYPE_ICONS[citation.type ?? "webpage"] ?? Globe; + + return ( + + ); +} + +interface StackedCitationsProps { + id: string; + citations: SerializableCitation[]; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +function StackedCitations({ + id, + citations, + className, + onNavigate, +}: StackedCitationsProps) { + const { + open, + setOpen, + containerRef, + handleMouseEnter, + handleMouseLeave, + handleBlur, + } = useHoverPopover(); + const maxIcons = 4; + const visibleCitations = citations.slice(0, maxIcons); + const remainingCount = Math.max(0, citations.length - maxIcons); + + const handleClick = (citation: SerializableCitation) => { + const href = resolveSafeNavigationHref(citation.href); + if (!href) return; + if (onNavigate) { + onNavigate(href, citation); + } else { + openSafeNavigationHref(href); + } + }; + + return ( +
+ + + + + setOpen(false)} + > +
+ {citations.map((citation) => ( + handleClick(citation)} + /> + ))} +
+
+
+
+ ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/citation.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/citation.tsx new file mode 100644 index 00000000..551f82a7 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/citation/citation.tsx @@ -0,0 +1,259 @@ +"use client"; + +import * as React from "react"; +import type { LucideIcon } from "lucide-react"; +import { + FileText, + Globe, + Code2, + Newspaper, + Database, + File, + ExternalLink, +} from "lucide-react"; +import { cn, Popover, PopoverContent, PopoverTrigger } from "./_adapter"; + +import { openSafeNavigationHref, sanitizeHref } from "../shared/media"; +import type { + SerializableCitation, + CitationType, + CitationVariant, +} from "./schema"; + +const FALLBACK_LOCALE = "en-US"; + +const TYPE_ICONS: Record = { + webpage: Globe, + document: FileText, + article: Newspaper, + api: Database, + code: Code2, + other: File, +}; + +function extractDomain(url: string): string | undefined { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ""); + } catch { + return undefined; + } +} + +function formatDate(isoString: string, locale: string): string { + try { + const date = new Date(isoString); + return date.toLocaleDateString(locale, { + year: "numeric", + month: "short", + }); + } catch { + return isoString; + } +} + +function useHoverPopover(delay = 100) { + const [open, setOpen] = React.useState(false); + const timeoutRef = React.useRef | null>(null); + + const handleMouseEnter = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(true), delay); + }, [delay]); + + const handleMouseLeave = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setOpen(false), delay); + }, [delay]); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + return { open, setOpen, handleMouseEnter, handleMouseLeave }; +} + +export interface CitationProps extends SerializableCitation { + variant?: CitationVariant; + className?: string; + onNavigate?: (href: string, citation: SerializableCitation) => void; +} + +export function Citation(props: CitationProps) { + const { variant = "default", className, onNavigate, ...serializable } = props; + + const { + id, + href: rawHref, + title, + snippet, + domain: providedDomain, + favicon, + author, + publishedAt, + type = "webpage", + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + const sanitizedHref = sanitizeHref(rawHref); + const domain = providedDomain ?? extractDomain(rawHref); + + const citationData: SerializableCitation = { + ...serializable, + href: sanitizedHref ?? rawHref, + domain, + locale, + }; + + const TypeIcon = TYPE_ICONS[type] ?? Globe; + + const handleClick = () => { + if (!sanitizedHref) return; + if (onNavigate) { + onNavigate(sanitizedHref, citationData); + } else { + openSafeNavigationHref(sanitizedHref); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (sanitizedHref && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + handleClick(); + } + }; + + const iconElement = favicon ? ( + + ) : ( +
+ + ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/item-carousel.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/item-carousel.tsx new file mode 100644 index 00000000..6c0b601b --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/item-carousel.tsx @@ -0,0 +1,404 @@ +"use client"; + +import { useRef, useState, useEffect, useCallback } from "react"; +import { cn, Button, Card, ChevronLeft, ChevronRight } from "./_adapter"; +import { ItemCard } from "./item-card"; +import { prefersReducedMotion } from "../shared/utils"; +import type { ItemCarouselProps } from "./schema"; + +const SCROLL_PADDING_STYLE = { scrollPaddingInline: "1rem" }; + +const SCROLL_EDGE_THRESHOLD_PX = 8; +const SNAP_EPSILON_PX = 5; +const SCROLL_ANIMATION_DURATION_MS = 300; +const PAGE_SCROLL_RATIO = 0.8; +const PAGE_SCROLL_BREAKPOINT_PX = 640; + +type ScrollDirection = "left" | "right"; + +interface ScrollAnimationState { + target: number; + start: number; + startTime: number; + duration: number; + onComplete?: () => void; +} + +function useSmoothScroll() { + const animationRef = useRef(null); + const frameRef = useRef(null); + + const cancelAnimation = useCallback(() => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + animationRef.current = null; + }, []); + + useEffect(() => cancelAnimation, [cancelAnimation]); + + const scrollTo = useCallback( + ( + element: HTMLElement, + target: number, + duration = SCROLL_ANIMATION_DURATION_MS, + onComplete?: () => void, + ) => { + if (prefersReducedMotion() || duration <= 0) { + element.scrollLeft = target; + onComplete?.(); + return; + } + + cancelAnimation(); + + animationRef.current = { + target, + start: element.scrollLeft, + startTime: performance.now(), + duration, + onComplete, + }; + + element.style.scrollSnapType = "none"; + + const step = () => { + const anim = animationRef.current; + if (!anim) return; + + const elapsed = performance.now() - anim.startTime; + const progress = Math.min(elapsed / anim.duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + + element.scrollLeft = anim.start + (anim.target - anim.start) * eased; + + if (progress < 1) { + frameRef.current = requestAnimationFrame(step); + return; + } + + element.scrollLeft = anim.target; + const callback = anim.onComplete; + cancelAnimation(); + + requestAnimationFrame(() => { + element.style.scrollSnapType = ""; + callback?.(); + }); + }; + + frameRef.current = requestAnimationFrame(step); + }, + [cancelAnimation], + ); + + const isAnimating = useCallback( + () => animationRef.current !== null && frameRef.current !== null, + [], + ); + + return { scrollTo, isAnimating, cancelAnimation }; +} + +function useScrollEdgeState( + scrollRef: React.RefObject, + itemCount: number, +) { + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateState = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + + const scrollLeft = Math.round(container.scrollLeft); + const maxScroll = Math.max( + 0, + Math.round(container.scrollWidth - container.clientWidth), + ); + + setCanScrollLeft(scrollLeft > SCROLL_EDGE_THRESHOLD_PX); + setCanScrollRight(scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD_PX); + }, [scrollRef]); + + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + + let rafId: number | null = null; + + const scheduleUpdate = () => { + if (rafId !== null) cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + rafId = null; + updateState(); + }); + }; + + scheduleUpdate(); + + container.addEventListener("scroll", scheduleUpdate, { passive: true }); + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(container); + + return () => { + container.removeEventListener("scroll", scheduleUpdate); + resizeObserver.disconnect(); + if (rafId !== null) cancelAnimationFrame(rafId); + }; + }, [scrollRef, updateState, itemCount]); + + return { canScrollLeft, canScrollRight }; +} + +function CarouselNavButton({ + direction, + visible, + onClick, +}: { + direction: ScrollDirection; + visible: boolean; + onClick: () => void; +}) { + const isLeft = direction === "left"; + const Icon = isLeft ? ChevronLeft : ChevronRight; + + return ( + + ); +} + +interface ItemCarouselHeaderProps { + title?: string; + description?: string; +} + +function ItemCarouselHeader({ title, description }: ItemCarouselHeaderProps) { + if (!title && !description) return null; + + return ( +
+ {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+ ); +} + +interface EmptyStateProps { + id: string; + className?: string; +} + +function EmptyState({ id, className }: EmptyStateProps) { + return ( + +

No items to display

+
+ ); +} + +function ItemCarouselRoot({ + id, + title, + description, + items, + className, + onItemClick, + onItemAction, +}: ItemCarouselProps) { + const scrollRef = useRef(null); + const targetIndexRef = useRef(null); + + const { scrollTo, isAnimating } = useSmoothScroll(); + const { canScrollLeft, canScrollRight } = useScrollEdgeState( + scrollRef, + items.length, + ); + + const scroll = useCallback( + (direction: ScrollDirection) => { + const container = scrollRef.current; + if (!container) return; + + const paddingValue = window.getComputedStyle(container).scrollPaddingLeft; + const scrollPaddingLeft = Number.isFinite(Number.parseFloat(paddingValue)) + ? Number.parseFloat(paddingValue) + : 0; + + const itemElements = Array.from( + container.querySelectorAll("[data-carousel-item]"), + ); + if (itemElements.length === 0) return; + + const snapPositions = itemElements.map((el) => + Math.max(0, el.offsetLeft - scrollPaddingLeft), + ); + + const scrollLeft = Math.round(container.scrollLeft); + let currentIndex: number; + if (isAnimating()) { + currentIndex = Math.min( + targetIndexRef.current ?? 0, + snapPositions.length - 1, + ); + } else { + currentIndex = snapPositions.length - 1; + for (let i = 0; i < snapPositions.length; i++) { + const snap = snapPositions[i]; + if (Math.abs(snap - scrollLeft) < SNAP_EPSILON_PX) { + currentIndex = i; + break; + } + if (snap > scrollLeft) { + currentIndex = Math.max(0, i - 1); + break; + } + } + } + + const itemStep = + itemElements.length > 1 + ? itemElements[1].offsetLeft - itemElements[0].offsetLeft + : 0; + const safeStep = + itemStep > 0 ? itemStep : itemElements[0].offsetWidth || 1; + + const pageIndexStep = + container.clientWidth >= PAGE_SCROLL_BREAKPOINT_PX + ? Math.max( + 1, + Math.floor( + (container.clientWidth * PAGE_SCROLL_RATIO) / safeStep, + ), + ) + : 1; + + const targetIndex = + direction === "right" + ? Math.min(currentIndex + pageIndexStep, itemElements.length - 1) + : Math.max(currentIndex - pageIndexStep, 0); + + targetIndexRef.current = targetIndex; + const targetScrollLeft = snapPositions[targetIndex]; + + if (Math.abs(targetScrollLeft - container.scrollLeft) > 1) { + scrollTo( + container, + targetScrollLeft, + SCROLL_ANIMATION_DURATION_MS, + () => { + targetIndexRef.current = null; + }, + ); + } + }, + [scrollTo, isAnimating], + ); + + const handleScrollLeft = useCallback(() => scroll("left"), [scroll]); + const handleScrollRight = useCallback(() => scroll("right"), [scroll]); + + if (items.length === 0) { + return ; + } + + return ( +
+ + +
+ + + +
+ {items.map((item) => ( +
+ +
+ ))} +
+
+
+ ); +} + +type ItemCarouselComponent = typeof ItemCarouselRoot & { + Root: typeof ItemCarouselRoot; + Header: typeof ItemCarouselHeader; + EmptyState: typeof EmptyState; + NavButton: typeof CarouselNavButton; + Card: typeof ItemCard; +}; + +export const ItemCarousel = Object.assign(ItemCarouselRoot, { + Root: ItemCarouselRoot, + Header: ItemCarouselHeader, + EmptyState, + NavButton: CarouselNavButton, + Card: ItemCard, +}) as ItemCarouselComponent; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/schema.ts new file mode 100644 index 00000000..d20a72b5 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/item-carousel/schema.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ActionSchema, + SerializableActionSchema, + ToolUIIdSchema, +} from "../shared/schema"; + +export const ItemSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + subtitle: z.string().optional(), + image: z.url().optional(), + color: z.string().optional(), + actions: z.array(ActionSchema).optional(), +}); + +export const ItemCarouselPropsSchema = z.object({ + id: ToolUIIdSchema, + title: z.string().optional(), + description: z.string().optional(), + items: z.array(ItemSchema), + className: z.string().optional(), +}); + +export type Item = z.infer; + +export type ItemCarouselProps = z.infer & { + onItemClick?: (itemId: string) => void; + onItemAction?: (itemId: string, actionId: string) => void; +}; + +export const SerializableItemSchema = ItemSchema.extend({ + actions: z.array(SerializableActionSchema).optional(), +}); + +export const SerializableItemCarouselSchema = ItemCarouselPropsSchema.omit({ + className: true, +}) + .extend({ + items: z.array(SerializableItemSchema), + }) + .superRefine((payload, ctx) => { + const seenItemIds = new Map(); + + payload.items.forEach((item, index) => { + const firstSeenAt = seenItemIds.get(item.id); + if (firstSeenAt !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["items", index, "id"], + message: `duplicate item id '${item.id}' (first seen at index ${firstSeenAt})`, + }); + return; + } + seenItemIds.set(item.id, index); + }); + }); + +export type SerializableItem = z.infer; +export type SerializableItemCarousel = z.infer< + typeof SerializableItemCarouselSchema +>; + +const SerializableItemCarouselSchemaContract = defineToolUiContract( + "ItemCarousel", + SerializableItemCarouselSchema, +); + +export const parseSerializableItemCarousel: ( + input: unknown, +) => SerializableItemCarousel = SerializableItemCarouselSchemaContract.parse; + +export const safeParseSerializableItemCarousel: ( + input: unknown, +) => SerializableItemCarousel | null = + SerializableItemCarouselSchemaContract.safeParse; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/README.md new file mode 100644 index 00000000..0fab3dbb --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/README.md @@ -0,0 +1,19 @@ +# Link Preview + +Implementation for the "link-preview" Tool UI surface. + +## Files + +- public exports: components/tool-ui/link-preview/index.ts +- serializable schema + parse helpers: components/tool-ui/link-preview/schema.ts + +## Companion assets + +- Docs page: app/docs/link-preview/content.mdx +- Preset payload: lib/presets/link-preview.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/_adapter.tsx new file mode 100644 index 00000000..ac928498 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/_adapter.tsx @@ -0,0 +1,6 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + */ +"use client"; + +export { cn } from "@toolui/lib/utils"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/index.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/index.ts new file mode 100644 index 00000000..cea9fe77 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/index.ts @@ -0,0 +1,3 @@ +export { LinkPreview } from "./link-preview"; +export type { LinkPreviewProps } from "./link-preview"; +export type { SerializableLinkPreview } from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/link-preview.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/link-preview.tsx new file mode 100644 index 00000000..9d2ae161 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/link-preview.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { Globe } from "lucide-react"; +import { cn } from "./_adapter"; + +import { + RATIO_CLASS_MAP, + getFitClass, + openSafeNavigationHref, + sanitizeHref, +} from "../shared/media"; +import type { SerializableLinkPreview } from "./schema"; + +const FALLBACK_LOCALE = "en-US"; +const CONTENT_SPACING = "px-5 py-4 gap-2"; + +export interface LinkPreviewProps extends SerializableLinkPreview { + className?: string; + onNavigate?: (href: string, preview: SerializableLinkPreview) => void; +} + +export function LinkPreview(props: LinkPreviewProps) { + const { className, onNavigate, ...serializable } = props; + + const { + id, + href: rawHref, + title, + description, + image, + domain, + favicon, + ratio = "16:9", + fit = "cover", + locale: providedLocale, + } = serializable; + + const locale = providedLocale ?? FALLBACK_LOCALE; + const sanitizedHref = sanitizeHref(rawHref); + + const previewData: SerializableLinkPreview = { + ...serializable, + href: sanitizedHref ?? rawHref, + locale, + }; + + const handleClick = () => { + if (!sanitizedHref) return; + if (onNavigate) { + onNavigate(sanitizedHref, previewData); + } else { + openSafeNavigationHref(sanitizedHref); + } + }; + + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(); + } + } + : undefined + } + > +
+ {image && ( +
+ +
+ )} +
+ {domain && ( +
+ {favicon ? ( + + ) : ( +
+
+ )} + {domain} +
+ )} + {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )} +
+
+
+
+ ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/schema.ts new file mode 100644 index 00000000..3bde91b5 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/link-preview/schema.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +import { AspectRatioSchema, MediaFitSchema } from "../shared/media"; + +export const SerializableLinkPreviewSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + href: z.url(), + title: z.string().optional(), + description: z.string().optional(), + image: z.url().optional(), + domain: z.string().optional(), + favicon: z.url().optional(), + ratio: AspectRatioSchema.optional(), + fit: MediaFitSchema.optional(), + createdAt: z.string().datetime().optional(), + locale: z.string().optional(), +}); + +export type SerializableLinkPreview = z.infer< + typeof SerializableLinkPreviewSchema +>; + +const SerializableLinkPreviewSchemaContract = defineToolUiContract( + "LinkPreview", + SerializableLinkPreviewSchema, +); + +export const parseSerializableLinkPreview: ( + input: unknown, +) => SerializableLinkPreview = SerializableLinkPreviewSchemaContract.parse; + +export const safeParseSerializableLinkPreview: ( + input: unknown, +) => SerializableLinkPreview | null = + SerializableLinkPreviewSchemaContract.safeParse; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/README.md new file mode 100644 index 00000000..1869eff8 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/README.md @@ -0,0 +1,19 @@ +# Linkedin Post + +Implementation for the "linkedin-post" Tool UI surface. + +## Files + +- public exports: components/tool-ui/linkedin-post/index.ts +- serializable schema + parse helpers: components/tool-ui/linkedin-post/schema.ts + +## Companion assets + +- Docs page: app/docs/social-post/content.mdx +- Preset payload: lib/presets/linkedin-post.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/_adapter.tsx new file mode 100644 index 00000000..c314b96c --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/_adapter.tsx @@ -0,0 +1,19 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Tooltip → shadcn/ui Tooltip + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@toolui/ui/tooltip"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/index.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/index.ts new file mode 100644 index 00000000..2fd27172 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/index.ts @@ -0,0 +1,9 @@ +export { LinkedInPost } from "./linkedin-post"; +export type { LinkedInPostProps } from "./linkedin-post"; +export type { + LinkedInPostData, + LinkedInPostAuthor, + LinkedInPostMedia, + LinkedInPostLinkPreview, + LinkedInPostStats, +} from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx new file mode 100644 index 00000000..51fcd221 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/linkedin-post.tsx @@ -0,0 +1,283 @@ +"use client"; + +import * as React from "react"; +import { ThumbsUp, Share } from "lucide-react"; +import { + cn, + Button, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./_adapter"; +import { formatCount, formatRelativeTime, getDomain } from "../shared/utils"; + +import { resolveSafeNavigationHref } from "../shared/media"; +import type { + LinkedInPostData, + LinkedInPostMedia, + LinkedInPostLinkPreview, +} from "./schema"; + +const TEXT_PREVIEW_LENGTH = 280; + +export interface LinkedInPostProps { + post: LinkedInPostData; + className?: string; + onAction?: (action: string, post: LinkedInPostData) => void; +} + +function LinkedInLogo({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function Header({ + author, + createdAt, +}: { + author: LinkedInPostData["author"]; + createdAt?: string; +}) { + return ( +
+ {`${author.name} +
+ {author.name} + {author.headline && ( + + {author.headline} + + )} + {createdAt && ( +
+ {formatRelativeTime(createdAt)} + · + Edited +
+ )} +
+ +
+ ); +} + +function PostBody({ text }: { text?: string }) { + const [isExpanded, setIsExpanded] = React.useState(false); + const shouldTruncate = text && text.length > TEXT_PREVIEW_LENGTH; + + if (!text) return null; + + return ( +
+ {shouldTruncate && !isExpanded ? ( + <> + {text.slice(0, TEXT_PREVIEW_LENGTH)} + ... + + + ) : ( + text + )} +
+ ); +} + +function PostMedia({ media }: { media: LinkedInPostMedia }) { + return ( +
+ {media.type === "image" ? ( + {media.alt} + ) : ( +
+ ); +} + +function PostLinkPreview({ preview }: { preview: LinkedInPostLinkPreview }) { + const href = resolveSafeNavigationHref(preview.url); + const domain = preview.domain ?? getDomain(preview.url); + const content = ( + <> + {preview.imageUrl && ( + + )} +
+ {preview.title && ( +
+ {preview.title} +
+ )} + {domain && ( +
{domain}
+ )} +
+ + ); + + if (!href) { + return ( +
{content}
+ ); + } + + return ( + + {content} + + ); +} + +function ActionButton({ + icon: Icon, + label, + count, + active, + hoverColor, + activeColor, + onClick, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + count?: number; + active?: boolean; + hoverColor: string; + activeColor?: string; + onClick: () => void; +}) { + return ( + + + + + {label} + + ); +} + +function PostActions({ + stats, + onAction, +}: { + stats?: LinkedInPostData["stats"]; + onAction: (action: string) => void; +}) { + return ( + +
+ onAction("like")} + /> + onAction("share")} + /> +
+
+ ); +} + +export function LinkedInPost({ post, className, onAction }: LinkedInPostProps) { + return ( +
+
+
+ + + {post.media && } + + {post.linkPreview && !post.media && ( + + )} + + onAction?.(action, post)} + /> +
+
+ ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/schema.ts new file mode 100644 index 00000000..177f5b4c --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/linkedin-post/schema.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; + +export const LinkedInPostAuthorSchema = z.object({ + name: z.string(), + avatarUrl: z.string(), + headline: z.string().optional(), +}); + +export const LinkedInPostMediaSchema = z.object({ + type: z.enum(["image", "video"]), + url: z.string(), + alt: z.string(), +}); + +export const LinkedInPostLinkPreviewSchema = z.object({ + url: z.string(), + title: z.string().optional(), + description: z.string().optional(), + imageUrl: z.string().optional(), + domain: z.string().optional(), +}); + +export const LinkedInPostStatsSchema = z.object({ + likes: z.number().optional(), + isLiked: z.boolean().optional(), +}); + +export const SerializableLinkedInPostSchema = z.object({ + id: z.string(), + author: LinkedInPostAuthorSchema, + text: z.string().optional(), + media: LinkedInPostMediaSchema.optional(), + linkPreview: LinkedInPostLinkPreviewSchema.optional(), + stats: LinkedInPostStatsSchema.optional(), + createdAt: z.string().optional(), +}); + +export type LinkedInPostData = z.infer; + +export type LinkedInPostAuthor = z.infer; +export type LinkedInPostMedia = z.infer; +export type LinkedInPostLinkPreview = z.infer< + typeof LinkedInPostLinkPreviewSchema +>; +export type LinkedInPostStats = z.infer; + +const SerializableLinkedInPostSchemaContract = defineToolUiContract( + "LinkedInPost", + SerializableLinkedInPostSchema, +); + +export const parseSerializableLinkedInPost: ( + input: unknown, +) => LinkedInPostData = SerializableLinkedInPostSchemaContract.parse; + +export const safeParseSerializableLinkedInPost: ( + input: unknown, +) => LinkedInPostData | null = SerializableLinkedInPostSchemaContract.safeParse; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/README.md new file mode 100644 index 00000000..48ed63e2 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/README.md @@ -0,0 +1,19 @@ +# Message Draft + +Implementation for the "message-draft" Tool UI surface. + +## Files + +- public exports: components/tool-ui/message-draft/index.tsx +- serializable schema + parse helpers: components/tool-ui/message-draft/schema.ts + +## Companion assets + +- Docs page: app/docs/message-draft/content.mdx +- Preset payload: lib/presets/message-draft.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/_adapter.tsx new file mode 100644 index 00000000..4d2303fd --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/_adapter.tsx @@ -0,0 +1,12 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/index.tsx new file mode 100644 index 00000000..9970ea34 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/index.tsx @@ -0,0 +1,10 @@ +export { MessageDraft } from "./message-draft"; +export { + type SerializableMessageDraft, + type SerializableEmailDraft, + type SerializableSlackDraft, + type MessageDraftChannel, + type MessageDraftOutcome, + type SlackTarget, + type MessageDraftProps, +} from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/message-draft.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/message-draft.tsx new file mode 100644 index 00000000..1753b795 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/message-draft.tsx @@ -0,0 +1,511 @@ +"use client"; + +import * as React from "react"; +import { cn, Button } from "./_adapter"; +import type { + MessageDraftProps, + SerializableEmailDraft, + SerializableSlackDraft, +} from "./schema"; +import { ActionButtons } from "../shared/action-buttons"; +import type { Action } from "../shared/schema"; +import { Check, ChevronDown } from "lucide-react"; + +type DraftState = "review" | "sending" | "sent" | "cancelled"; +type DraftOutcome = MessageDraftProps["outcome"]; + +const DEFAULT_GRACE_PERIOD = 5000; +const COLLAPSED_BODY_HEIGHT = 280; + +interface RecipientRowProps { + label: string; + recipients: string[]; + maxVisible?: number; + muted?: boolean; +} + +function RecipientRow({ + label, + recipients, + maxVisible = 3, + muted = false, +}: RecipientRowProps) { + const visibleRecipients = recipients.slice(0, maxVisible); + const overflowCount = recipients.length - maxVisible; + + return ( + + + {label} + + + {visibleRecipients.join(", ")} + {overflowCount > 0 && ( + +{overflowCount} more + )} + + + ); +} + +interface SingleFieldRowProps { + label: string; + value: string; +} + +function SingleFieldRow({ label, value }: SingleFieldRowProps) { + return ( + + + {label} + + {value} + + ); +} + +interface ExpandableBodyProps { + body: string; + isExpanded: boolean; + onNeedsExpansionChange?: (needsExpansion: boolean) => void; +} + +function ExpandableBody({ + body, + isExpanded, + onNeedsExpansionChange, +}: ExpandableBodyProps) { + const [needsExpansion, setNeedsExpansion] = React.useState( + null, + ); + const contentRef = React.useRef(null); + + React.useLayoutEffect(() => { + if (contentRef.current) { + const needs = contentRef.current.scrollHeight > COLLAPSED_BODY_HEIGHT; + setNeedsExpansion(needs); + onNeedsExpansionChange?.(needs); + } + }, [body, onNeedsExpansionChange]); + + return ( +
+
+

{body}

+
+ {needsExpansion && ( +
+ )} +
+ ); +} + +interface EmailDraftContentProps { + draft: SerializableEmailDraft; + titleId: string; + isExpanded: boolean; + onNeedsExpansionChange?: (needsExpansion: boolean) => void; +} + +function EmailDraftContent({ + draft, + titleId, + isExpanded, + onNeedsExpansionChange, +}: EmailDraftContentProps) { + return ( + <> +

+ {draft.subject} +

+ + + + {draft.from && } + + {draft.cc && draft.cc.length > 0 && ( + + )} + {draft.bcc && draft.bcc.length > 0 && ( + + )} + +
+ +
+ + + + ); +} + +interface SlackDraftContentProps { + draft: SerializableSlackDraft; + titleId: string; + isExpanded: boolean; + onNeedsExpansionChange?: (needsExpansion: boolean) => void; +} + +function SlackLogo({ className }: { className?: string }) { + return ( + + ); +} + +function SlackDraftContent({ + draft, + titleId, + isExpanded, + onNeedsExpansionChange, +}: SlackDraftContentProps) { + const { target } = draft; + const isChannel = target.type === "channel"; + const targetDisplay = isChannel + ? `#${target.name}` + : `Message to @${target.name}`; + const memberCount = isChannel ? target.memberCount : undefined; + + return ( + <> +
+ + {targetDisplay} + {memberCount !== undefined && ( + + {memberCount.toLocaleString()} members + + )} +
+ +
+ + + + ); +} + +function formatSentTime(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); +} + +export function resolveStateFromOutcome(outcome: DraftOutcome): DraftState { + if (outcome === "sent") return "sent"; + if (outcome === "cancelled") return "cancelled"; + return "review"; +} + +export function resolveOutcomeTransition( + previousOutcome: DraftOutcome, + nextOutcome: DraftOutcome, +): DraftState | null { + if (previousOutcome === nextOutcome) { + return null; + } + + return resolveStateFromOutcome(nextOutcome); +} + +interface SentConfirmationProps { + sentAt: Date; +} + +function SentConfirmation({ sentAt }: SentConfirmationProps) { + return ( +
+ + Sent at {formatSentTime(sentAt)} + + + + +
+ ); +} + +export function MessageDraft(props: MessageDraftProps) { + const { + id, + className, + outcome, + undoGracePeriod = DEFAULT_GRACE_PERIOD, + onSend, + onUndo, + onCancel, + } = props; + + const [state, setState] = React.useState(() => + resolveStateFromOutcome(outcome), + ); + const [countdown, setCountdown] = React.useState( + Math.ceil(undoGracePeriod / 1000), + ); + const [sentAt, setSentAt] = React.useState(() => + outcome === "sent" ? new Date() : null, + ); + const [isExpanded, setIsExpanded] = React.useState(false); + const [needsExpansion, setNeedsExpansion] = React.useState(false); + const undoButtonRef = React.useRef(null); + const timerRef = React.useRef | null>(null); + const countdownRef = React.useRef | null>( + null, + ); + const previousOutcomeRef = React.useRef(outcome); + + const clearTimers = React.useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + }, []); + + React.useEffect(() => { + return clearTimers; + }, [clearTimers]); + + React.useEffect(() => { + const nextState = resolveOutcomeTransition( + previousOutcomeRef.current, + outcome, + ); + + previousOutcomeRef.current = outcome; + + if (nextState === null) { + return; + } + + clearTimers(); + setState(nextState); + setCountdown(Math.ceil(undoGracePeriod / 1000)); + setSentAt(nextState === "sent" ? new Date() : null); + }, [outcome, undoGracePeriod, clearTimers]); + + React.useEffect(() => { + if (state === "sending") { + undoButtonRef.current?.focus(); + + setCountdown(Math.ceil(undoGracePeriod / 1000)); + + countdownRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + + timerRef.current = setTimeout(async () => { + clearTimers(); + await onSend?.(); + setSentAt(new Date()); + setState("sent"); + }, undoGracePeriod); + } + }, [state, undoGracePeriod, onSend, clearTimers]); + + const handleSend = React.useCallback(() => { + setState("sending"); + }, []); + + const handleUndo = React.useCallback(() => { + clearTimers(); + setState("review"); + onUndo?.(); + }, [clearTimers, onUndo]); + + const handleCancel = React.useCallback(() => { + clearTimers(); + setState("cancelled"); + onCancel?.(); + }, [clearTimers, onCancel]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape" && state === "review") { + event.preventDefault(); + handleCancel(); + } + }, + [state, handleCancel], + ); + + const handleNeedsExpansionChange = React.useCallback((needs: boolean) => { + setNeedsExpansion(needs); + }, []); + + const handleToggleExpand = React.useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + const handleAction = React.useCallback( + async (actionId: string) => { + if (actionId === "send") { + handleSend(); + } else if (actionId === "cancel") { + handleCancel(); + } + }, + [handleSend, handleCancel], + ); + + const actions: Action[] = [ + { + id: "cancel", + label: "Cancel", + variant: "ghost", + }, + { + id: "send", + label: "Send", + variant: "default", + }, + ]; + + const expandButton = needsExpansion ? ( + + ) : null; + + const renderActions = () => { + switch (state) { + case "sending": + return ( +
+ + Sending in {countdown}s + + +
+ ); + case "sent": + return ; + case "cancelled": + return null; + default: + return ; + } + }; + + if (state === "cancelled") { + return null; + } + + return ( +
+
+ {props.channel === "email" ? ( + + ) : ( + + )} + + {expandButton} +
+ +
{renderActions()}
+
+ ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/schema.ts new file mode 100644 index 00000000..8cf14d5c --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/message-draft/schema.ts @@ -0,0 +1,83 @@ +import { z } from "zod"; +import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema"; +import { defineToolUiContract } from "../shared/contract"; + +export const MessageDraftChannelSchema = z.enum(["email", "slack"]); + +export type MessageDraftChannel = z.infer; + +export const MessageDraftOutcomeSchema = z.enum(["sent", "cancelled"]); + +export type MessageDraftOutcome = z.infer; + +const SlackTargetSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("channel"), + name: z.string().min(1), + memberCount: z.number().optional(), + }), + z.object({ type: z.literal("dm"), name: z.string().min(1) }), +]); + +export type SlackTarget = z.infer; + +export const SerializableEmailDraftSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + body: z.string().min(1), + outcome: MessageDraftOutcomeSchema.optional(), + channel: z.literal("email"), + subject: z.string().min(1), + from: z.string().optional(), + to: z.array(z.string()).min(1), + cc: z.array(z.string()).optional(), + bcc: z.array(z.string()).optional(), +}); + +export const SerializableSlackDraftSchema = z.object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + body: z.string().min(1), + outcome: MessageDraftOutcomeSchema.optional(), + channel: z.literal("slack"), + target: SlackTargetSchema, +}); + +export const SerializableMessageDraftSchema = z.discriminatedUnion("channel", [ + SerializableEmailDraftSchema, + SerializableSlackDraftSchema, +]); + +export type SerializableMessageDraft = z.infer< + typeof SerializableMessageDraftSchema +>; + +export type SerializableEmailDraft = z.infer< + typeof SerializableEmailDraftSchema +>; + +export type SerializableSlackDraft = z.infer< + typeof SerializableSlackDraftSchema +>; + +const SerializableMessageDraftSchemaContract = defineToolUiContract( + "MessageDraft", + SerializableMessageDraftSchema, +); + +export const parseSerializableMessageDraft: ( + input: unknown, +) => SerializableMessageDraft = SerializableMessageDraftSchemaContract.parse; + +export const safeParseSerializableMessageDraft: ( + input: unknown, +) => SerializableMessageDraft | null = + SerializableMessageDraftSchemaContract.safeParse; + +export type MessageDraftProps = SerializableMessageDraft & { + className?: string; + undoGracePeriod?: number; + onSend?: () => void | Promise; + onUndo?: () => void; + onCancel?: () => void; +}; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/README.md new file mode 100644 index 00000000..6d8d4574 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/README.md @@ -0,0 +1,19 @@ +# Option List + +Implementation for the "option-list" Tool UI surface. + +## Files + +- public exports: components/tool-ui/option-list/index.tsx +- serializable schema + parse helpers: components/tool-ui/option-list/schema.ts + +## Companion assets + +- Docs page: app/docs/option-list/content.mdx +- Preset payload: lib/presets/option-list.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/_adapter.tsx new file mode 100644 index 00000000..a7873bcc --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/_adapter.tsx @@ -0,0 +1,14 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Separator → shadcn/ui Separator + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Separator } from "@toolui/ui/separator"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/index.tsx new file mode 100644 index 00000000..7ae9d2cb --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/index.tsx @@ -0,0 +1,7 @@ +export { OptionList } from "./option-list"; +export type { + OptionListProps, + OptionListOption, + OptionListSelection, + SerializableOptionList, +} from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/option-list.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/option-list.tsx new file mode 100644 index 00000000..34df143b --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/option-list.tsx @@ -0,0 +1,625 @@ +"use client"; + +import { + useMemo, + useState, + useCallback, + useEffect, + useRef, + Fragment, +} from "react"; +import type { KeyboardEvent } from "react"; +import type { + OptionListProps, + OptionListSelection, + OptionListOption, +} from "./schema"; +import { + normalizeSelectionForOptions, + parseSelectionToIdSet, +} from "./selection"; +import { ActionButtons } from "../shared/action-buttons"; +import { normalizeActionsConfig } from "../shared/actions-config"; +import type { Action } from "../shared/schema"; +import { cn, Button, Separator } from "./_adapter"; +import { Check } from "lucide-react"; + +function convertIdSetToSelection( + selected: Set, + mode: "multi" | "single", +): OptionListSelection { + if (mode === "single") { + const [first] = selected; + return first ?? null; + } + return Array.from(selected); +} + +function areSetsEqual(a: Set, b: Set) { + if (a.size !== b.size) return false; + for (const val of a) { + if (!b.has(val)) return false; + } + return true; +} + +interface SelectionIndicatorProps { + mode: "multi" | "single"; + isSelected: boolean; + disabled?: boolean; +} + +function SelectionIndicator({ + mode, + isSelected, + disabled, +}: SelectionIndicatorProps) { + const shape = mode === "single" ? "rounded-full" : "rounded"; + + return ( +
+ {mode === "multi" && isSelected && } + {mode === "single" && isSelected && ( + + )} +
+ ); +} + +interface OptionItemProps { + option: OptionListOption; + isSelected: boolean; + isDisabled: boolean; + selectionMode: "multi" | "single"; + isFirst: boolean; + isLast: boolean; + onToggle: () => void; + tabIndex?: number; + onFocus?: () => void; + buttonRef?: (el: HTMLButtonElement | null) => void; +} + +function OptionItem({ + option, + isSelected, + isDisabled, + selectionMode, + isFirst, + isLast, + onToggle, + tabIndex, + onFocus, + buttonRef, +}: OptionItemProps) { + const hasAdjacentOptions = !isFirst && !isLast; + + return ( + + ); +} + +interface OptionListConfirmationProps { + id: string; + options: OptionListOption[]; + selectedIds: Set; + className?: string; +} + +function OptionListConfirmation({ + id, + options, + selectedIds, + className, +}: OptionListConfirmationProps) { + const confirmedOptions = options.filter((opt) => selectedIds.has(opt.id)); + + return ( +
+
+ {confirmedOptions.map((option, index) => ( + + {index > 0 && ( + + )} +
+ + + + {option.icon && ( + {option.icon} + )} +
+ + {option.label} + + {option.description && ( + + {option.description} + + )} +
+
+
+ ))} +
+
+ ); +} + +export function OptionList({ + id, + options, + selectionMode = "multi", + minSelections = 1, + maxSelections, + value, + defaultValue, + choice, + onChange, + actions, + onAction, + onBeforeAction, + className, +}: OptionListProps) { + if (process.env["NODE_ENV"] !== "production") { + if (value !== undefined && defaultValue !== undefined) { + console.warn( + "[OptionList] Both `value` (controlled) and `defaultValue` (uncontrolled) were provided. `defaultValue` is ignored when `value` is set.", + ); + } + if (value !== undefined && !onChange) { + console.warn( + "[OptionList] `value` was provided without `onChange`. This makes OptionList controlled; selection will not update unless the parent updates `value`.", + ); + } + } + + const effectiveMaxSelections = selectionMode === "single" ? 1 : maxSelections; + const optionIds = useMemo( + () => new Set(options.map((option) => option.id)), + [options], + ); + + const [uncontrolledSelected, setUncontrolledSelected] = useState>( + () => + normalizeSelectionForOptions( + parseSelectionToIdSet( + defaultValue, + selectionMode, + effectiveMaxSelections, + ), + optionIds, + ), + ); + + const selectedIds = useMemo(() => { + const parsed = + value !== undefined + ? parseSelectionToIdSet(value, selectionMode, effectiveMaxSelections) + : uncontrolledSelected; + return normalizeSelectionForOptions(parsed, optionIds); + }, [ + value, + uncontrolledSelected, + selectionMode, + effectiveMaxSelections, + optionIds, + ]); + + const selectedCount = selectedIds.size; + + const optionStates = useMemo(() => { + return options.map((option) => { + const isSelected = selectedIds.has(option.id); + const isSelectionLocked = + selectionMode === "multi" && + effectiveMaxSelections !== undefined && + selectedCount >= effectiveMaxSelections && + !isSelected; + const isDisabled = option.disabled || isSelectionLocked; + + return { option, isSelected, isDisabled }; + }); + }, [ + options, + selectedIds, + selectionMode, + effectiveMaxSelections, + selectedCount, + ]); + + const optionRefs = useRef>([]); + const [activeIndex, setActiveIndex] = useState(() => { + const firstSelected = optionStates.findIndex( + (s) => s.isSelected && !s.isDisabled, + ); + if (firstSelected >= 0) return firstSelected; + const firstEnabled = optionStates.findIndex((s) => !s.isDisabled); + return firstEnabled >= 0 ? firstEnabled : 0; + }); + + useEffect(() => { + if (optionStates.length === 0) return; + setActiveIndex((prev) => { + if ( + prev < 0 || + prev >= optionStates.length || + optionStates[prev].isDisabled + ) { + const firstEnabled = optionStates.findIndex((s) => !s.isDisabled); + return firstEnabled >= 0 ? firstEnabled : 0; + } + return prev; + }); + }, [optionStates]); + + const updateSelection = useCallback( + (next: Set) => { + const normalizedNext = normalizeSelectionForOptions( + parseSelectionToIdSet( + Array.from(next), + selectionMode, + effectiveMaxSelections, + ), + optionIds, + ); + + if (value === undefined) { + if (!areSetsEqual(uncontrolledSelected, normalizedNext)) { + setUncontrolledSelected(normalizedNext); + } + } + + onChange?.(convertIdSetToSelection(normalizedNext, selectionMode)); + }, + [ + effectiveMaxSelections, + selectionMode, + uncontrolledSelected, + value, + onChange, + optionIds, + ], + ); + + const toggleSelection = useCallback( + (optionId: string) => { + const next = new Set(selectedIds); + const isSelected = next.has(optionId); + + if (selectionMode === "single") { + if (isSelected) { + next.delete(optionId); + } else { + next.clear(); + next.add(optionId); + } + } else { + if (isSelected) { + next.delete(optionId); + } else { + if (effectiveMaxSelections && next.size >= effectiveMaxSelections) { + return; + } + next.add(optionId); + } + } + + updateSelection(next); + }, + [effectiveMaxSelections, selectedIds, selectionMode, updateSelection], + ); + + const toSelectionState = useCallback( + (selected: Set): OptionListSelection => + convertIdSetToSelection(selected, selectionMode), + [selectionMode], + ); + + const handleCancel = useCallback((): OptionListSelection => { + const empty = new Set(); + updateSelection(empty); + return toSelectionState(empty); + }, [toSelectionState, updateSelection]); + + const customActions = useMemo( + () => normalizeActionsConfig(actions), + [actions], + ); + + const handleFooterAction = useCallback( + async (actionId: string) => { + let nextState = toSelectionState(selectedIds); + + if (actionId === "cancel") { + nextState = handleCancel(); + } + + await onAction?.(actionId, nextState); + }, + [handleCancel, onAction, selectedIds, toSelectionState], + ); + + const normalizedFooterActions = useMemo(() => { + if (customActions) return customActions; + return { + items: [ + { id: "cancel", label: "Clear", variant: "ghost" as const }, + { id: "confirm", label: "Confirm", variant: "default" as const }, + ], + align: "right" as const, + } satisfies ReturnType; + }, [customActions]); + + const isConfirmDisabled = + selectedCount < minSelections || selectedCount === 0; + const hasNothingToClear = selectedCount === 0; + + const focusOptionAt = useCallback((index: number) => { + const el = optionRefs.current[index]; + if (el) el.focus(); + setActiveIndex(index); + }, []); + + const findFirstEnabledIndex = useCallback(() => { + const idx = optionStates.findIndex((s) => !s.isDisabled); + return idx >= 0 ? idx : 0; + }, [optionStates]); + + const findLastEnabledIndex = useCallback(() => { + for (let i = optionStates.length - 1; i >= 0; i--) { + if (!optionStates[i].isDisabled) return i; + } + return 0; + }, [optionStates]); + + const findNextEnabledIndex = useCallback( + (start: number, direction: 1 | -1) => { + const len = optionStates.length; + if (len === 0) return 0; + for (let step = 1; step <= len; step++) { + const idx = (start + direction * step + len) % len; + if (!optionStates[idx].isDisabled) return idx; + } + return start; + }, + [optionStates], + ); + + const handleListboxKeyDown = useCallback( + (e: KeyboardEvent) => { + if (optionStates.length === 0) return; + + const key = e.key; + + if (key === "ArrowDown") { + e.preventDefault(); + e.stopPropagation(); + focusOptionAt(findNextEnabledIndex(activeIndex, 1)); + return; + } + + if (key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + focusOptionAt(findNextEnabledIndex(activeIndex, -1)); + return; + } + + if (key === "Home") { + e.preventDefault(); + e.stopPropagation(); + focusOptionAt(findFirstEnabledIndex()); + return; + } + + if (key === "End") { + e.preventDefault(); + e.stopPropagation(); + focusOptionAt(findLastEnabledIndex()); + return; + } + + if (key === "Enter" || key === " ") { + e.preventDefault(); + e.stopPropagation(); + const current = optionStates[activeIndex]; + if (!current || current.isDisabled) return; + toggleSelection(current.option.id); + return; + } + + if (key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + if (!hasNothingToClear) { + handleCancel(); + } + } + }, + [ + activeIndex, + findFirstEnabledIndex, + findLastEnabledIndex, + findNextEnabledIndex, + focusOptionAt, + handleCancel, + hasNothingToClear, + optionStates, + toggleSelection, + ], + ); + + const actionsWithDisabledState = useMemo((): Action[] => { + return normalizedFooterActions.items.map((action) => { + const isDisabledByValidation = + (action.id === "confirm" && isConfirmDisabled) || + (action.id === "cancel" && hasNothingToClear); + return { + ...action, + disabled: action.disabled || isDisabledByValidation, + label: + action.id === "confirm" && + selectionMode === "multi" && + selectedCount > 0 + ? `${action.label} (${selectedCount})` + : action.label, + }; + }); + }, [ + normalizedFooterActions.items, + isConfirmDisabled, + hasNothingToClear, + selectionMode, + selectedCount, + ]); + + const isReceipt = choice !== undefined && choice !== null; + const viewKey = isReceipt ? `receipt-${String(choice)}` : "interactive"; + + return ( +
+ {isReceipt ? ( + + ) : ( +
+
+ {optionStates.map(({ option, isSelected, isDisabled }, index) => { + return ( + + {index > 0 && ( + + )} + setActiveIndex(index)} + buttonRef={(el) => { + optionRefs.current[index] = el; + }} + onToggle={() => toggleSelection(option.id)} + /> + + ); + })} +
+ +
+ + onBeforeAction(actionId, toSelectionState(selectedIds)) + : undefined + } + /> +
+
+ )} +
+ ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/schema.ts new file mode 100644 index 00000000..76a9745c --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/schema.ts @@ -0,0 +1,210 @@ +import { z } from "zod"; +import type { ReactNode } from "react"; +import type { ActionsProp } from "../shared/actions-config"; +import type { EmbeddedActionsProps } from "../shared/embedded-actions"; +import { + ActionSchema, + SerializableActionSchema, + SerializableActionsConfigSchema, + ToolUIIdSchema, + ToolUIReceiptSchema, + ToolUIRoleSchema, +} from "../shared/schema"; +import { defineToolUiContract } from "../shared/contract"; + +export const OptionListOptionSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + icon: z.custom().optional(), + disabled: z.boolean().optional(), +}); + +export type OptionListSelection = string[] | string | null; + +const OptionListSelectionSchema = z + .union([z.array(z.string()), z.string(), z.null()]) + .optional(); + +type OptionListSchemaInvariantInput = { + options: Array<{ id: string }>; + minSelections?: number; + maxSelections?: number; + value?: OptionListSelection; + defaultValue?: OptionListSelection; + choice?: OptionListSelection; +}; + +function selectionToIds(selection: OptionListSelection | undefined): string[] { + if (selection == null) return []; + if (typeof selection === "string") return [selection]; + return Array.isArray(selection) ? selection : []; +} + +function validateOptionListInvariants( + data: OptionListSchemaInvariantInput, + ctx: z.RefinementCtx, +) { + if ( + data.minSelections !== undefined && + data.maxSelections !== undefined && + data.minSelections > data.maxSelections + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["minSelections"], + message: "`minSelections` cannot be greater than `maxSelections`.", + }); + } + + const optionIds = new Set(); + for (let index = 0; index < data.options.length; index++) { + const optionId = data.options[index]?.id; + if (!optionId) continue; + + if (optionIds.has(optionId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["options", index, "id"], + message: `Duplicate option id "${optionId}" is not allowed.`, + }); + } else { + optionIds.add(optionId); + } + } + + const selectionFields: Array< + ["value" | "defaultValue" | "choice", OptionListSelection | undefined] + > = [ + ["value", data.value], + ["defaultValue", data.defaultValue], + ["choice", data.choice], + ]; + + for (const [fieldName, selection] of selectionFields) { + if (selection == null) continue; + + const ids = selectionToIds(selection); + ids.forEach((selectionId, index) => { + if (!optionIds.has(selectionId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: + typeof selection === "string" ? [fieldName] : [fieldName, index], + message: `Selection id "${selectionId}" must exist in options.`, + }); + } + }); + } +} + +const OptionListPropsSchemaBase = z.object({ + /** + * Unique identifier for this tool UI instance in the conversation. + * + * Used for: + * - Assistant referencing ("the options above") + * - Receipt generation (linking selections to their source) + * - Narration context + * + * Should be stable across re-renders, meaningful, and unique within the conversation. + * + * @example "option-list-deploy-target", "format-selection" + */ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + receipt: ToolUIReceiptSchema.optional(), + options: z.array(OptionListOptionSchema).min(1), + selectionMode: z.enum(["multi", "single"]).optional(), + /** + * Controlled selection value (advanced / runtime only). + * + * For Tool UI tool payloads, prefer `defaultValue` (initial selection) and + * `choice` (receipt state). Controlled `value` is intentionally excluded + * from `SerializableOptionListSchema` to avoid accidental "controlled but + * non-interactive" states when an LLM includes `value` in args. + */ + value: OptionListSelectionSchema, + defaultValue: OptionListSelectionSchema, + /** + * When set, renders the component in receipt state showing the user's choice. + * + * In receipt state: + * - Only the chosen option(s) are shown + * - Actions are hidden + * - The component is read-only + * + * Use this with assistant-ui's `addResult` to show the outcome of a decision. + * + * @example + * ```tsx + * // In a toolkit render function: + * if (result) { + * return ; + * } + * ``` + */ + choice: OptionListSelectionSchema, + actions: z + .union([z.array(ActionSchema), SerializableActionsConfigSchema]) + .optional(), + minSelections: z.number().min(0).optional(), + maxSelections: z.number().min(1).optional(), +}); + +export const OptionListPropsSchema = OptionListPropsSchemaBase.superRefine( + validateOptionListInvariants, +); + +export type OptionListOption = z.infer; + +export type OptionListProps = Omit< + z.infer, + "value" | "defaultValue" | "choice" | "actions" +> & { + /** @see OptionListPropsSchema.id */ + id: string; + value?: OptionListSelection; + defaultValue?: OptionListSelection; + /** @see OptionListPropsSchema.choice */ + choice?: OptionListSelection; + onChange?: (value: OptionListSelection) => void; + actions?: ActionsProp; + onAction?: EmbeddedActionsProps["onAction"]; + onBeforeAction?: EmbeddedActionsProps["onBeforeAction"]; + className?: string; +}; + +export const SerializableOptionListSchema = OptionListPropsSchemaBase.omit({ + // Exclude controlled selection from tool/LLM payloads. + value: true, +}) + .extend({ + options: z.array(OptionListOptionSchema.omit({ icon: true })), + actions: z + .union([ + z.array(SerializableActionSchema), + SerializableActionsConfigSchema, + ]) + .optional(), + }) + .strict() + .superRefine(validateOptionListInvariants); + +export type SerializableOptionList = z.infer< + typeof SerializableOptionListSchema +>; + +const SerializableOptionListSchemaContract = defineToolUiContract( + "OptionList", + SerializableOptionListSchema, +); + +export const parseSerializableOptionList: ( + input: unknown, +) => SerializableOptionList = SerializableOptionListSchemaContract.parse; + +export const safeParseSerializableOptionList: ( + input: unknown, +) => SerializableOptionList | null = + SerializableOptionListSchemaContract.safeParse; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/selection.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/selection.ts new file mode 100644 index 00000000..48ff8ece --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/option-list/selection.ts @@ -0,0 +1,35 @@ +import type { OptionListSelection } from "./schema"; + +export function parseSelectionToIdSet( + value: OptionListSelection | undefined, + mode: "multi" | "single", + maxSelections?: number, +): Set { + if (mode === "single") { + const single = + typeof value === "string" + ? value + : Array.isArray(value) + ? value[0] + : null; + return single ? new Set([single]) : new Set(); + } + + const arr = + typeof value === "string" ? [value] : Array.isArray(value) ? value : []; + + return new Set(maxSelections ? arr.slice(0, maxSelections) : arr); +} + +export function normalizeSelectionForOptions( + selection: Set, + optionIds: Set, +): Set { + const normalized = new Set(); + for (const id of selection) { + if (optionIds.has(id)) { + normalized.add(id); + } + } + return normalized; +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/README.md new file mode 100644 index 00000000..6f80a24d --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/README.md @@ -0,0 +1,19 @@ +# Order Summary + +Implementation for the "order-summary" Tool UI surface. + +## Files + +- public exports: components/tool-ui/order-summary/index.tsx +- serializable schema + parse helpers: components/tool-ui/order-summary/schema.ts + +## Companion assets + +- Docs page: app/docs/order-summary/content.mdx +- Preset payload: lib/presets/order-summary.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/_adapter.tsx new file mode 100644 index 00000000..b111ed52 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/_adapter.tsx @@ -0,0 +1,16 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Separator → shadcn/ui Separator + * Skeleton → shadcn/ui Skeleton + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Separator } from "@toolui/ui/separator"; +export { Skeleton } from "@toolui/ui/skeleton"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/index.tsx new file mode 100644 index 00000000..d8024df9 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/index.tsx @@ -0,0 +1,14 @@ +export { OrderSummary } from "./order-summary"; +export type { + OrderSummaryDisplayProps, + OrderSummaryReceiptProps, + OrderSummaryCompoundComponent, +} from "./order-summary"; +export { + type SerializableOrderSummary, + type OrderSummaryProps, + type OrderSummaryVariant, + type OrderItem, + type Pricing, + type OrderDecision, +} from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/order-summary.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/order-summary.tsx new file mode 100644 index 00000000..5583dd55 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/order-summary.tsx @@ -0,0 +1,296 @@ +import { CheckCircle, Package } from "lucide-react"; +import type { ReactElement } from "react"; +import { cn, Separator } from "./_adapter"; +import type { + OrderSummaryProps, + OrderItem, + Pricing, + OrderDecision, + OrderSummaryVariant, +} from "./schema"; + +function formatCurrency(amount: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + }).format(amount); + } catch { + return `${currency} ${amount.toFixed(2)}`; + } +} + +function formatQuantity(quantity: number): string { + return quantity === 1 ? "" : `Qty: ${quantity}`; +} + +function ItemImage({ src, alt }: { src?: string; alt: string }) { + if (!src) { + return ( +
+
+ ); + } + + return ( + {alt} + ); +} + +function OrderItemRow({ + item, + currency, +}: { + item: OrderItem; + currency: string; +}) { + const quantity = item.quantity ?? 1; + const quantityText = formatQuantity(quantity); + const hasDescription = item.description || quantityText; + const lineTotal = item.unitPrice * quantity; + + return ( +
+ +
+
+
+ {item.name} + + {formatCurrency(lineTotal, currency)} + +
+ {hasDescription && ( +
+ {[item.description, quantityText].filter(Boolean).join(" · ")} +
+ )} +
+
+
+ ); +} + +function PricingBreakdown({ + pricing, + className, +}: { + pricing: Pricing; + className?: string; +}) { + const currency = pricing.currency ?? "USD"; + + return ( +
+
+
Subtotal
+
+ {formatCurrency(pricing.subtotal, currency)} +
+
+ + {pricing.discount !== undefined && pricing.discount > 0 && ( +
+
{pricing.discountLabel || "Discount"}
+
+ -{formatCurrency(pricing.discount, currency)} +
+
+ )} + + {pricing.shipping !== undefined && ( +
+
Shipping
+
+ {pricing.shipping === 0 + ? "Free" + : formatCurrency(pricing.shipping, currency)} +
+
+ )} + + {pricing.tax !== undefined && ( +
+
{pricing.taxLabel || "Tax"}
+
+ {formatCurrency(pricing.tax, currency)} +
+
+ )} + +
+
Total
+
+ {formatCurrency(pricing.total, currency)} +
+
+
+ ); +} + +function formatDate(isoString: string): string | undefined { + try { + const date = new Date(isoString); + if (isNaN(date.getTime())) return undefined; + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return undefined; + } +} + +function ReceiptBadge({ + orderId, + confirmedAt, +}: { + orderId?: string; + confirmedAt?: string; +}) { + const formattedDate = confirmedAt ? formatDate(confirmedAt) : undefined; + + const parts = [orderId && `#${orderId}`, formattedDate].filter(Boolean); + if (parts.length === 0) return null; + + return ( +

{parts.join(" · ")}

+ ); +} + +function OrderSummaryRoot({ + id, + title = "Order Summary", + variant, + items, + pricing, + choice, + className, +}: OrderSummaryProps) { + const titleId = `${id}-title`; + const resolvedVariant: OrderSummaryVariant = + variant ?? (choice === undefined ? "summary" : "receipt"); + const isReceipt = resolvedVariant === "receipt"; + const isMalformedPayload = + !Array.isArray(items) || + items.length === 0 || + pricing == null || + (isReceipt && choice === undefined); + + if (isMalformedPayload) { + return ( +
+
+

+ {title} +

+

+ Unable to render order summary +

+
+
+ ); + } + + return ( +
+
+
+
+

+ {isReceipt && ( +

+ {isReceipt && choice && ( + + )} +
+ +
+ {items.map((item) => ( + + ))} +
+ + + + +
+
+
+ ); +} + +export type OrderSummaryDisplayProps = OrderSummaryProps; + +function OrderSummaryDisplay(props: OrderSummaryDisplayProps) { + return ; +} + +export interface OrderSummaryReceiptProps extends Omit< + OrderSummaryProps, + "choice" +> { + choice: OrderDecision; +} + +function OrderSummaryReceipt(props: OrderSummaryReceiptProps) { + return ; +} + +export interface OrderSummaryCompoundComponent { + (props: OrderSummaryProps): ReactElement; + Display: (props: OrderSummaryDisplayProps) => ReactElement; + Receipt: (props: OrderSummaryReceiptProps) => ReactElement; +} + +export const OrderSummary: OrderSummaryCompoundComponent = Object.assign( + OrderSummaryRoot, + { + Display: OrderSummaryDisplay, + Receipt: OrderSummaryReceipt, + }, +); diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/schema.ts new file mode 100644 index 00000000..9e9c1d0a --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/order-summary/schema.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { defineToolUiContract } from "../shared/contract"; +import { ToolUIIdSchema, ToolUIRoleSchema } from "../shared/schema"; + +export const OrderItemSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + imageUrl: z.string().url().optional(), + quantity: z.number().int().positive().optional(), + unitPrice: z.number(), +}); + +export type OrderItem = z.infer; + +const OrderItemsSchema = z + .array(OrderItemSchema) + .min(1) + .superRefine((items, ctx) => { + const seenIds = new Set(); + + for (const [index, item] of items.entries()) { + if (seenIds.has(item.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate item id: "${item.id}"`, + path: [index, "id"], + }); + } + + seenIds.add(item.id); + } + }); + +export const PricingSchema = z.object({ + subtotal: z.number(), + tax: z.number().optional(), + taxLabel: z.string().optional(), + shipping: z.number().optional(), + discount: z.number().nonnegative().optional(), + discountLabel: z.string().optional(), + total: z.number(), + currency: z.string().optional(), +}); + +export type Pricing = z.infer; + +export const OrderSummaryVariantSchema = z.enum(["summary", "receipt"]); +export type OrderSummaryVariant = z.infer; + +export const OrderDecisionSchema = z.object({ + action: z.literal("confirm"), + orderId: z.string().optional(), + confirmedAt: z.string().datetime().optional(), +}); + +export type OrderDecision = z.infer; + +export const SerializableOrderSummarySchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + title: z.string().optional(), + variant: OrderSummaryVariantSchema.optional(), + items: OrderItemsSchema, + pricing: PricingSchema, + choice: OrderDecisionSchema.optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.variant === "receipt" && value.choice === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Receipt variant requires "choice".', + path: ["choice"], + }); + } + + if (value.variant === "summary" && value.choice !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Summary variant cannot include "choice".', + path: ["choice"], + }); + } + }); + +export type SerializableOrderSummary = z.infer< + typeof SerializableOrderSummarySchema +>; + +const SerializableOrderSummarySchemaContract = defineToolUiContract( + "OrderSummary", + SerializableOrderSummarySchema, +); + +export const parseSerializableOrderSummary: ( + input: unknown, +) => SerializableOrderSummary = SerializableOrderSummarySchemaContract.parse; + +export const safeParseSerializableOrderSummary: ( + input: unknown, +) => SerializableOrderSummary | null = + SerializableOrderSummarySchemaContract.safeParse; + +export interface OrderSummaryProps extends SerializableOrderSummary { + className?: string; +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/README.md new file mode 100644 index 00000000..04d1be58 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/README.md @@ -0,0 +1,19 @@ +# Parameter Slider + +Implementation for the "parameter-slider" Tool UI surface. + +## Files + +- public exports: components/tool-ui/parameter-slider/index.tsx +- serializable schema + parse helpers: components/tool-ui/parameter-slider/schema.ts + +## Companion assets + +- Docs page: app/docs/parameter-slider/content.mdx +- Preset payload: lib/presets/parameter-slider.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/_adapter.tsx new file mode 100644 index 00000000..52a1b4d7 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/_adapter.tsx @@ -0,0 +1,16 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Button → shadcn/ui Button + * Separator → shadcn/ui Separator + * Slider → shadcn/ui Slider + */ + +export { cn } from "@toolui/lib/utils"; +export { Button } from "@toolui/ui/button"; +export { Separator } from "@toolui/ui/separator"; +export { Slider } from "@toolui/ui/slider"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/index.tsx new file mode 100644 index 00000000..8f2ecf3e --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/index.tsx @@ -0,0 +1,7 @@ +export { ParameterSlider } from "./parameter-slider"; +export type { + ParameterSliderProps, + SliderConfig, + SliderValue, + SerializableParameterSlider, +} from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/math.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/math.ts new file mode 100644 index 00000000..80df94e3 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/math.ts @@ -0,0 +1,42 @@ +import type { SliderConfig, SliderValue } from "./schema"; + +type SliderPercentInput = { + value: number; + min: number; + max: number; +}; + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +export function sliderRangeToPercent({ + value, + min, + max, +}: SliderPercentInput): number { + const range = max - min; + if (!Number.isFinite(range) || range <= 0) return 0; + return clampPercent(((value - min) / range) * 100); +} + +export function createSliderValueSnapshot( + sliders: SliderConfig[], +): SliderValue[] { + return sliders.map((slider) => ({ id: slider.id, value: slider.value })); +} + +export function createSliderSignature(sliders: SliderConfig[]): string { + return JSON.stringify( + sliders.map(({ id, min, max, step, value, unit, precision }) => ({ + id, + min, + max, + step: step ?? 1, + value, + unit: unit ?? "", + precision: precision ?? null, + })), + ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/parameter-slider.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/parameter-slider.tsx new file mode 100644 index 00000000..4bbaa0df --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/parameter-slider.tsx @@ -0,0 +1,821 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; +import type { ParameterSliderProps, SliderConfig, SliderValue } from "./schema"; +import { ActionButtons } from "../shared/action-buttons"; +import { normalizeActionsConfig } from "../shared/actions-config"; +import { useControllableState } from "../shared/use-controllable-state"; +import { useSignatureReset } from "../shared/use-signature-reset"; + +import { cn } from "./_adapter"; +import { + createSliderSignature, + createSliderValueSnapshot, + sliderRangeToPercent, +} from "./math"; + +function formatSignedValue( + value: number, + min: number, + max: number, + precision?: number, + unit?: string, +): string { + const crossesZero = min < 0 && max > 0; + const fixed = + precision !== undefined ? value.toFixed(precision) : String(value); + const numericPart = crossesZero && value >= 0 ? `+${fixed}` : fixed; + return unit ? `${numericPart} ${unit}` : numericPart; +} + +function getAriaValueText( + value: number, + min: number, + max: number, + unit?: string, +): string { + const crossesZero = min < 0 && max > 0; + if (crossesZero) { + if (value > 0) { + return unit ? `plus ${value} ${unit}` : `plus ${value}`; + } else if (value < 0) { + return unit + ? `minus ${Math.abs(value)} ${unit}` + : `minus ${Math.abs(value)}`; + } + } + return unit ? `${value} ${unit}` : String(value); +} + +const TICK_COUNT = 16; +const TEXT_PADDING_X = 4; +const TEXT_PADDING_X_OUTER = 0; // Less inset on outer-facing side (near edges) +const TEXT_PADDING_Y = 2; +const DETECTION_MARGIN_X = 12; +const DETECTION_MARGIN_X_OUTER = 4; // Small margin at edges for steep falloff - segments fully close at terminal positions +const DETECTION_MARGIN_Y = 12; +const TRACK_HEIGHT = 48; +const TEXT_RELEASE_INSET = 8; +const TRACK_EDGE_INSET = 4; // px from track edge - keeps elements visible at extremes +const THUMB_WIDTH = 12; // w-3 +// Text vertical offset: raised slightly from center +// Positive = raised, negative = lowered +const TEXT_VERTICAL_OFFSET = 0.5; + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, value)); +} + +// Convert a percentage (0-100) to an inset position string +// At 0%: 4px from left edge; at 100%: 4px from right edge +function toInsetPosition(percent: number): string { + const safePercent = clampPercent(percent); + return `calc(${TRACK_EDGE_INSET}px + (100% - ${TRACK_EDGE_INSET * 2}px) * ${safePercent / 100})`; +} + +// Radix keeps the thumb in bounds by applying a percent-dependent px offset. +// Matching this for fill clipping prevents handle/fill drift near extremes. +function getRadixThumbInBoundsOffsetPx(percent: number): number { + const safePercent = clampPercent(percent); + const halfWidth = THUMB_WIDTH / 2; + return halfWidth - (safePercent * halfWidth) / 50; +} + +function toRadixThumbPosition(percent: number): string { + const safePercent = clampPercent(percent); + const offsetPx = getRadixThumbInBoundsOffsetPx(safePercent); + return `calc(${safePercent}% + ${offsetPx}px)`; +} + +function signedDistanceToRoundedRect( + px: number, + py: number, + left: number, + right: number, + top: number, + bottom: number, + radiusLeft: number, + radiusRight: number, +): number { + const innerLeft = left + radiusLeft; + const innerRight = right - radiusRight; + const innerTop = top + Math.max(radiusLeft, radiusRight); + const innerBottom = bottom - Math.max(radiusLeft, radiusRight); + + const inLeftCorner = px < innerLeft; + const inRightCorner = px > innerRight; + const inCornerY = py < innerTop || py > innerBottom; + + if ((inLeftCorner || inRightCorner) && inCornerY) { + const radius = inLeftCorner ? radiusLeft : radiusRight; + const cornerX = inLeftCorner ? innerLeft : innerRight; + const cornerY = py < innerTop ? top + radius : bottom - radius; + const distToCornerCenter = Math.hypot(px - cornerX, py - cornerY); + return distToCornerCenter - radius; + } + + const dx = Math.max(left - px, px - right, 0); + const dy = Math.max(top - py, py - bottom, 0); + + if (dx === 0 && dy === 0) { + return -Math.min(px - left, right - px, py - top, bottom - py); + } + + return Math.max(dx, dy); +} + +const OUTER_EDGE_RADIUS_FACTOR = 0.3; // Reduced radius on outer-facing sides for steeper falloff + +function calculateGap( + thumbCenterX: number, + textRect: { left: number; right: number; height: number; centerY: number }, + isLeftAligned: boolean, +): number { + const { left, right, height, centerY } = textRect; + // Asymmetric padding/margin: outer-facing side has less padding, more margin + const paddingLeft = isLeftAligned ? TEXT_PADDING_X_OUTER : TEXT_PADDING_X; + const paddingRight = isLeftAligned ? TEXT_PADDING_X : TEXT_PADDING_X_OUTER; + const marginLeft = isLeftAligned + ? DETECTION_MARGIN_X_OUTER + : DETECTION_MARGIN_X; + const marginRight = isLeftAligned + ? DETECTION_MARGIN_X + : DETECTION_MARGIN_X_OUTER; + const paddingY = TEXT_PADDING_Y; + const marginY = DETECTION_MARGIN_Y; + const thumbCenterY = centerY; + + // Inner boundary (where max gap occurs) + const innerLeft = left - paddingLeft; + const innerRight = right + paddingRight; + const innerTop = centerY - height / 2 - paddingY; + const innerBottom = centerY + height / 2 + paddingY; + const innerHeight = height + paddingY * 2; + const innerRadius = innerHeight / 2; + // Smaller radius on outer-facing side (left for label, right for value) + const innerRadiusLeft = isLeftAligned + ? innerRadius * OUTER_EDGE_RADIUS_FACTOR + : innerRadius; + const innerRadiusRight = isLeftAligned + ? innerRadius + : innerRadius * OUTER_EDGE_RADIUS_FACTOR; + + // Outer boundary (where effect starts) - proportionally larger + const outerLeft = left - paddingLeft - marginLeft; + const outerRight = right + paddingRight + marginRight; + const outerTop = centerY - height / 2 - paddingY - marginY; + const outerBottom = centerY + height / 2 + paddingY + marginY; + const outerHeight = height + paddingY * 2 + marginY * 2; + const outerRadius = outerHeight / 2; + const outerRadiusLeft = isLeftAligned + ? outerRadius * OUTER_EDGE_RADIUS_FACTOR + : outerRadius; + const outerRadiusRight = isLeftAligned + ? outerRadius + : outerRadius * OUTER_EDGE_RADIUS_FACTOR; + + const outerDist = signedDistanceToRoundedRect( + thumbCenterX, + thumbCenterY, + outerLeft, + outerRight, + outerTop, + outerBottom, + outerRadiusLeft, + outerRadiusRight, + ); + + // Outside outer boundary - no gap + if (outerDist > 0) return 0; + + const innerDist = signedDistanceToRoundedRect( + thumbCenterX, + thumbCenterY, + innerLeft, + innerRight, + innerTop, + innerBottom, + innerRadiusLeft, + innerRadiusRight, + ); + + // Inside inner boundary - max gap + const maxGap = height + paddingY * 2; + if (innerDist <= 0) return maxGap; + + // Between boundaries - linear interpolation + // outerDist is negative (inside outer), innerDist is positive (outside inner) + const totalDist = Math.abs(outerDist) + innerDist; + const t = Math.abs(outerDist) / totalDist; + + return maxGap * t; +} + +interface SliderRowProps { + config: SliderConfig; + value: number; + onChange: (value: number) => void; + trackClassName?: string; + fillClassName?: string; + handleClassName?: string; +} + +function SliderRow({ + config, + value, + onChange, + trackClassName, + fillClassName, + handleClassName, +}: SliderRowProps) { + const { id, label, min, max, step = 1, unit, precision, disabled } = config; + // Per-slider theming overrides component-level theming + const resolvedTrackClassName = config.trackClassName ?? trackClassName; + const resolvedFillClassName = config.fillClassName ?? fillClassName; + const resolvedHandleClassName = config.handleClassName ?? handleClassName; + const crossesZero = min < 0 && max > 0; + const [isDragging, setIsDragging] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + const trackRef = useRef(null); + const labelRef = useRef(null); + const valueRef = useRef(null); + + const [dragGap, setDragGap] = useState(0); + const [fullGap, setFullGap] = useState(0); + const [intersectsText, setIntersectsText] = useState(false); + const [layoutVersion, setLayoutVersion] = useState(0); + + useEffect(() => { + if (!isDragging) return; + const handlePointerUp = () => setIsDragging(false); + document.addEventListener("pointerup", handlePointerUp); + return () => document.removeEventListener("pointerup", handlePointerUp); + }, [isDragging]); + + useEffect(() => { + const track = trackRef.current; + const labelEl = labelRef.current; + const valueEl = valueRef.current; + if (!track || !labelEl || !valueEl) return; + + const bumpLayoutVersion = () => setLayoutVersion((v) => v + 1); + + if (typeof ResizeObserver !== "undefined") { + const observer = new ResizeObserver(() => { + bumpLayoutVersion(); + }); + observer.observe(track); + observer.observe(labelEl); + observer.observe(valueEl); + return () => observer.disconnect(); + } + + window.addEventListener("resize", bumpLayoutVersion); + return () => window.removeEventListener("resize", bumpLayoutVersion); + }, []); + + useLayoutEffect(() => { + const track = trackRef.current; + const labelEl = labelRef.current; + const valueEl = valueRef.current; + + if (!track || !labelEl || !valueEl) return; + + const trackRect = track.getBoundingClientRect(); + const labelRect = labelEl.getBoundingClientRect(); + const valueRect = valueEl.getBoundingClientRect(); + + const trackWidth = trackRect.width; + const valuePercent = sliderRangeToPercent({ value, min, max }); + // Use same inset coordinate system as visual elements + const thumbCenterPx = + (trackWidth * clampPercent(valuePercent)) / 100 + + getRadixThumbInBoundsOffsetPx(valuePercent); + const thumbHalfWidth = THUMB_WIDTH / 2; + + // Text is raised by TEXT_VERTICAL_OFFSET from center + const trackCenterY = TRACK_HEIGHT / 2 - TEXT_VERTICAL_OFFSET; + + const labelGap = calculateGap( + thumbCenterPx, + { + left: labelRect.left - trackRect.left, + right: labelRect.right - trackRect.left, + height: labelRect.height, + centerY: trackCenterY, + }, + true, + ); // label is left-aligned + + const valueGap = calculateGap( + thumbCenterPx, + { + left: valueRect.left - trackRect.left, + right: valueRect.right - trackRect.left, + height: valueRect.height, + centerY: trackCenterY, + }, + false, + ); // value is right-aligned + + setDragGap(Math.max(labelGap, valueGap)); + + // Tight intersection check for release state + // Inset by px-2 (8px) padding to check against actual text, not padded container + const labelLeft = labelRect.left - trackRect.left + TEXT_RELEASE_INSET; + const labelRight = labelRect.right - trackRect.left - TEXT_RELEASE_INSET; + const valueLeft = valueRect.left - trackRect.left + TEXT_RELEASE_INSET; + const valueRight = valueRect.right - trackRect.left - TEXT_RELEASE_INSET; + + const thumbLeft = thumbCenterPx - thumbHalfWidth; + const thumbRight = thumbCenterPx + thumbHalfWidth; + + const hitsLabel = thumbRight > labelLeft && thumbLeft < labelRight; + const hitsValue = thumbRight > valueLeft && thumbLeft < valueRight; + + setIntersectsText(hitsLabel || hitsValue); + + // Calculate full separation gap for release state + // Use the max gap of whichever text element(s) the handle intersects + const labelFullGap = labelRect.height + TEXT_PADDING_Y * 2; + const valueFullGap = valueRect.height + TEXT_PADDING_Y * 2; + const releaseGap = + hitsLabel && hitsValue + ? Math.max(labelFullGap, valueFullGap) + : hitsLabel + ? labelFullGap + : hitsValue + ? valueFullGap + : 0; + setFullGap(releaseGap); + }, [value, min, max, layoutVersion]); + + // While dragging: use distance-based separation, but never collapse below + // the release split when the thumb still intersects text. + const gap = isDragging + ? Math.max(dragGap, intersectsText ? fullGap : 0) + : intersectsText + ? fullGap + : 0; + + const ticks = useMemo(() => { + // Generate equidistant ticks regardless of step value + const majorTickCount = TICK_COUNT; + const result: { percent: number; isCenter: boolean; isSubtick: boolean }[] = + []; + + for (let i = 0; i <= majorTickCount; i++) { + const percent = (i / majorTickCount) * 100; + const isCenter = !crossesZero && percent === 50; + + // Skip the center tick (50%) for crossesZero sliders + if (crossesZero && percent === 50) continue; + + // Add subtick at midpoint before this tick (except for first) + if (i > 0) { + const prevPercent = ((i - 1) / majorTickCount) * 100; + // Don't add subtick if it would be at 50% for crossesZero + const midPercent = (prevPercent + percent) / 2; + if (!(crossesZero && midPercent === 50)) { + result.push({ + percent: midPercent, + isCenter: false, + isSubtick: true, + }); + } + } + + result.push({ percent, isCenter, isSubtick: false }); + } + + return result; + }, [crossesZero]); + + const zeroPercent = crossesZero + ? sliderRangeToPercent({ value: 0, min, max }) + : 0; + const valuePercent = sliderRangeToPercent({ value, min, max }); + + // Fill clip-path uses the same inset coordinate system as the handle. + // This keeps the collapsed stroke aligned with the fill edge near extremes. + const fillClipPath = useMemo(() => { + const toClipFromRightInset = (percent: number) => + `calc(100% - ${toRadixThumbPosition(percent)})`; + const toClipFromLeftInset = (percent: number) => + toRadixThumbPosition(percent); + const TERMINAL_EPSILON = 1e-6; + const snapLeftInset = (percent: number) => { + if (percent <= TERMINAL_EPSILON) return "0"; + if (percent >= 100 - TERMINAL_EPSILON) return "100%"; + return toClipFromLeftInset(percent); + }; + const snapRightInset = (percent: number) => { + if (percent <= TERMINAL_EPSILON) return "100%"; + if (percent >= 100 - TERMINAL_EPSILON) return "0"; + return toClipFromRightInset(percent); + }; + + if (crossesZero) { + // Keep center anchor stable by always clipping the low/high pair, + // independent of sign branch, then snapping at terminal edges. + const lowPercent = Math.min(valuePercent, zeroPercent); + const highPercent = Math.max(valuePercent, zeroPercent); + return `inset(0 ${snapRightInset(highPercent)} 0 ${snapLeftInset(lowPercent)})`; + } + // Non-crossing: fill starts at left edge; snap right inset at terminals. + return `inset(0 ${snapRightInset(valuePercent)} 0 0)`; + }, [crossesZero, zeroPercent, valuePercent]); + + const fillMaskImage = crossesZero + ? "linear-gradient(to right, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.35) 50%, rgba(0,0,0,0.7) 100%)" + : "linear-gradient(to right, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%)"; + + // Metallic reflection gradient that follows the handle position + // Visible while dragging OR when resting at edges (0%/100%) + const reflectionStyle = useMemo(() => { + const edgeThreshold = 3; + const nearEdge = + valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold; + + // Narrower spread when stationary at edges (~35% narrower) + const spreadPercent = nearEdge && !isDragging ? 6.5 : 10; + const handlePos = toRadixThumbPosition(valuePercent); + const start = `clamp(0%, calc(${handlePos} - ${spreadPercent}%), 100%)`; + const end = `clamp(0%, calc(${handlePos} + ${spreadPercent}%), 100%)`; + + const gradient = `linear-gradient(to right, + transparent ${start}, + white ${handlePos}, + transparent ${end})`; + + return { + background: gradient, + WebkitMask: + "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)", + WebkitMaskComposite: "xor", + maskComposite: "exclude", + padding: "1px", + }; + }, [valuePercent, isDragging]); + + // Opacity scales with handle size: rest → hover → drag + const reflectionOpacity = useMemo(() => { + const edgeThreshold = 3; + const atEdge = + valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold; + + if (isDragging || atEdge) { + return 1; + } + if (isHovered) { + return 0.6; + } + return 0; + }, [valuePercent, isDragging, isHovered]); + + const handleValueChange = useCallback( + (values: number[]) => { + if (values[0] !== undefined) { + onChange(values[0]); + } + }, + [onChange], + ); + + return ( +
+ span]:transition-[left,transform] [&>span]:duration-45 [&>span]:ease-linear" + : "[&>span]:transition-[left,transform] [&>span]:duration-90 [&>span]:ease-[cubic-bezier(0.22,1,0.36,1)]", + "[&>span]:will-change-[left,transform]", + "motion-reduce:[&>span]:transition-none", + disabled && "pointer-events-none opacity-50", + )} + value={[value]} + onValueChange={handleValueChange} + onPointerDown={() => setIsDragging(true)} + onPointerUp={() => setIsDragging(false)} + onPointerEnter={() => setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + min={min} + max={max} + step={step} + disabled={disabled} + aria-valuetext={getAriaValueText(value, min, max, unit)} + > + +
+ + {ticks.map((tick, i) => { + const isEdge = + !tick.isSubtick && (tick.percent === 0 || tick.percent === 100); + return ( + + ); + })} + + + {/* Metallic reflection overlay - follows handle, brightness scales with interaction */} +
+ + + {(() => { + // Calculate morph state + const isActive = isHovered || isDragging; + + // Indicator stays centered on the real thumb while CSS transitions + // smooth thumb wrapper and fill movement together. + const fillEdgeOffset = 0; + + // Hide rest-state indicator at edges (0% or 100%) - the reflection gradient handles this + const edgeThreshold = 3; + const atEdge = + valuePercent <= edgeThreshold || + valuePercent >= 100 - edgeThreshold; + const restOpacity = atEdge ? 0 : 0.25; + + // Asymmetric segment heights: gap is shifted up to match raised text position + // Top segment is shorter, bottom segment is taller + const topHeight = + isActive && gap > 0 + ? `calc(50% - ${gap / 2 + TEXT_VERTICAL_OFFSET}px)` + : "50%"; + const bottomHeight = + isActive && gap > 0 + ? `calc(50% - ${gap / 2 - TEXT_VERTICAL_OFFSET}px)` + : "50%"; + + return ( + <> + 0 + ? "rounded-full" + : "rounded-t-full" + : "rounded-t-sm", + isDragging ? "w-2" : isActive ? "w-1.5" : "w-px", + resolvedHandleClassName ?? "bg-primary", + )} + style={{ + transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`, + height: topHeight, + opacity: isActive ? 1 : restOpacity, + }} + /> + 0 + ? "rounded-full" + : "rounded-b-full" + : "rounded-b-sm", + isDragging ? "w-2" : isActive ? "w-1.5" : "w-px", + resolvedHandleClassName ?? "bg-primary", + )} + style={{ + transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`, + height: bottomHeight, + opacity: isActive ? 1 : restOpacity, + }} + /> + + ); + })()} + + +
+ + {label} + + + {formatSignedValue(value, min, max, precision, unit)} + +
+ +
+ ); +} + +export function ParameterSlider({ + id, + sliders, + values: controlledValues, + onChange, + actions, + onAction, + onBeforeAction, + className, + trackClassName, + fillClassName, + handleClassName, +}: ParameterSliderProps) { + const slidersSignature = useMemo( + () => createSliderSignature(sliders), + [sliders], + ); + const sliderSnapshot = useMemo( + () => createSliderValueSnapshot(sliders), + [sliders], + ); + const { + value: currentValues, + isControlled, + setValue, + setUncontrolledValue, + } = useControllableState({ + value: controlledValues, + defaultValue: sliderSnapshot, + onChange, + }); + + useSignatureReset(slidersSignature, () => { + if (!isControlled) { + setUncontrolledValue(sliderSnapshot); + } + }); + + const valueMap = useMemo(() => { + const map = new Map(); + for (const v of currentValues) { + map.set(v.id, v.value); + } + return map; + }, [currentValues]); + + const updateValue = useCallback( + (sliderId: string, newValue: number) => { + setValue((prev) => + prev.map((v) => (v.id === sliderId ? { ...v, value: newValue } : v)), + ); + }, + [setValue], + ); + + const handleReset = useCallback(() => { + setValue(sliderSnapshot); + }, [setValue, sliderSnapshot]); + + const handleAction = useCallback( + async (actionId: string) => { + let nextValues = currentValues; + if (actionId === "reset") { + handleReset(); + nextValues = sliderSnapshot; + } + + await onAction?.(actionId, nextValues); + }, + [currentValues, handleReset, onAction, sliderSnapshot], + ); + + const normalizedActions = useMemo(() => { + const normalized = normalizeActionsConfig(actions); + if (normalized) return normalized; + return { + items: [ + { id: "reset", label: "Reset", variant: "ghost" as const }, + { id: "apply", label: "Apply", variant: "default" as const }, + ], + align: "right" as const, + }; + }, [actions]); + + return ( +
+
+ {sliders.map((slider) => ( + updateValue(slider.id, v)} + trackClassName={trackClassName} + fillClassName={fillClassName} + handleClassName={handleClassName} + /> + ))} +
+ +
+ onBeforeAction(actionId, currentValues) + : undefined + } + /> +
+
+ ); +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/schema.ts b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/schema.ts new file mode 100644 index 00000000..86673967 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/parameter-slider/schema.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import { type ActionsProp } from "../shared/actions-config"; +import type { EmbeddedActionsProps } from "../shared/embedded-actions"; +import { defineToolUiContract } from "../shared/contract"; +import { + SerializableActionSchema, + SerializableActionsConfigSchema, + ToolUIIdSchema, + ToolUIRoleSchema, +} from "../shared/schema"; + +export const SliderConfigSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + min: z.number().finite(), + max: z.number().finite(), + step: z.number().finite().positive().optional(), + value: z.number().finite(), + unit: z.string().optional(), + precision: z.number().int().min(0).optional(), + disabled: z.boolean().optional(), + trackClassName: z.string().optional(), + fillClassName: z.string().optional(), + handleClassName: z.string().optional(), + }) + .superRefine((slider, ctx) => { + if (slider.max <= slider.min) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["max"], + message: "max must be greater than min", + }); + } + + if (slider.value < slider.min || slider.value > slider.max) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["value"], + message: "value must be between min and max", + }); + } + }); + +export type SliderConfig = z.infer; + +export const SerializableParameterSliderSchema = z + .object({ + id: ToolUIIdSchema, + role: ToolUIRoleSchema.optional(), + sliders: z.array(SliderConfigSchema).min(1), + actions: z + .union([ + z.array(SerializableActionSchema), + SerializableActionsConfigSchema, + ]) + .optional(), + }) + .strict() + .superRefine((payload, ctx) => { + const seenIds = new Map(); + + payload.sliders.forEach((slider, index) => { + const firstSeenAt = seenIds.get(slider.id); + if (firstSeenAt !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sliders", index, "id"], + message: `duplicate slider id '${slider.id}' (first seen at index ${firstSeenAt})`, + }); + return; + } + seenIds.set(slider.id, index); + }); + }); + +export type SerializableParameterSlider = z.infer< + typeof SerializableParameterSliderSchema +>; + +const SerializableParameterSliderSchemaContract = defineToolUiContract( + "ParameterSlider", + SerializableParameterSliderSchema, +); + +export const parseSerializableParameterSlider: ( + input: unknown, +) => SerializableParameterSlider = + SerializableParameterSliderSchemaContract.parse; + +export const safeParseSerializableParameterSlider: ( + input: unknown, +) => SerializableParameterSlider | null = + SerializableParameterSliderSchemaContract.safeParse; + +export interface SliderValue { + id: string; + value: number; +} + +export interface ParameterSliderProps extends Omit< + SerializableParameterSlider, + "actions" +> { + className?: string; + values?: SliderValue[]; + onChange?: (values: SliderValue[]) => void; + actions?: ActionsProp; + onAction?: EmbeddedActionsProps["onAction"]; + onBeforeAction?: EmbeddedActionsProps["onBeforeAction"]; + trackClassName?: string; + fillClassName?: string; + handleClassName?: string; +} diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/README.md b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/README.md new file mode 100644 index 00000000..2e7b7db7 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/README.md @@ -0,0 +1,19 @@ +# Plan + +Implementation for the "plan" Tool UI surface. + +## Files + +- public exports: components/tool-ui/plan/index.tsx +- serializable schema + parse helpers: components/tool-ui/plan/schema.ts + +## Companion assets + +- Docs page: app/docs/plan/content.mdx +- Preset payload: lib/presets/plan.ts + +## Quick check + +Run this after edits: + +pnpm test diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/_adapter.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/_adapter.tsx new file mode 100644 index 00000000..48ea8dc9 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/_adapter.tsx @@ -0,0 +1,32 @@ +/** + * Adapter: UI and utility re-exports for copy-standalone portability. + * + * When copying this component to another project, update these imports + * to match your project's paths: + * + * cn → Your Tailwind merge utility (e.g., "@toolui/lib/utils", "~/lib/cn") + * Accordion → shadcn/ui Accordion + * Card → shadcn/ui Card + * Collapsible → shadcn/ui Collapsible + */ + +export { cn } from "@toolui/lib/utils"; +export { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@toolui/ui/accordion"; +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from "@toolui/ui/card"; +export { + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "@toolui/ui/collapsible"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/index.tsx new file mode 100644 index 00000000..de5021c2 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/index.tsx @@ -0,0 +1,7 @@ +export { Plan, PlanCompact } from "./plan"; +export type { + PlanProps, + PlanTodo, + PlanTodoStatus, + SerializablePlan, +} from "./schema"; diff --git a/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/plan.tsx b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/plan.tsx new file mode 100644 index 00000000..a62032bb --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/toolui/components/plan/plan.tsx @@ -0,0 +1,428 @@ +"use client"; + +import * as React from "react"; +import { useMemo, useState, useEffect, useRef, memo } from "react"; +import { Loader2, Check, X, MoreHorizontal, ChevronRight } from "lucide-react"; +import type { PlanProps, PlanTodo, PlanTodoStatus } from "./schema"; +import { + cn, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, + Collapsible, + CollapsibleTrigger, + CollapsibleContent, +} from "./_adapter"; +import { calculatePlanProgress, shouldCelebrateProgress } from "./progress"; + +const INITIAL_VISIBLE_TODO_COUNT = 4; + +const TodoIcon = memo(function TodoIcon({ + status, +}: { + status: PlanTodoStatus; +}) { + if (status === "pending") { + return ( +