From c3a93a687dcd96c3e6034bf18f4a864f54ae66b7 Mon Sep 17 00:00:00 2001 From: Daniel Blignaut Date: Tue, 8 Sep 2026 00:16:34 +0200 Subject: [PATCH 1/3] feat: share Dispatch chat and native resource workflows --- .../reusable-settings-and-connections.md | 62 + THIRD_PARTY_NOTICES.md | 12 +- apps/control-service/README.md | 6 +- apps/control-service/src/app.test.ts | 36 + apps/control-service/src/app.ts | 3 - .../src/capability-approvals.test.ts | 263 - .../src/capability-approvals.ts | 223 - apps/control-service/src/chat-proxy.ts | 5 + apps/control-service/src/tilde-proxy.test.ts | 47 + apps/control-service/src/tilde-proxy.ts | 56 +- apps/web/src/runtime.ts | 11 +- apps/web/src/screens/composer-layout.test.ts | 11 +- apps/web/src/screens/composer-layout.ts | 8 +- apps/web/src/screens/openbot-app.tsx | 864 +-- apps/web/src/screens/settings-app.tsx | 622 +- apps/web/src/web-attachments.ts | 174 +- cli/src/agent-scaffold.test.ts | 37 +- cli/src/agent-scaffold.ts | 27 +- cli/src/assets/agents/factory/agent.ts.hbs | 8 +- .../assets/agents/factory/instructions.ts.hbs | 8 +- .../factory/tools/configure_connector.ts.hbs | 8 - .../tools/propose_self_extension.ts.hbs | 61 - .../skills/tilde-connectors/SKILL.md.hbs | 34 - ...-workspace-ui-package-owns-presentation.md | 26 + .../0036-multiplayer-room-client-parity.md | 29 +- .../0037-safe-self-extension-proposals.md | 5 + ...sable-kit-and-direct-resource-workflows.md | 28 + docs/reusable-chat-audit.md | 141 + docs/reusable-settings-kit.md | 60 + docs/updates/pending/reusable-dispatch-kit.md | 65 + packages/agent-provider/src/index.ts | 2 + .../tilde-chatkit/SKILL.md.hbs | 14 +- .../tilde-connectors/SKILL.md.hbs | 17 + .../tilde-control-plane/SKILL.md.hbs | 6 +- .../tilde-dev-tunnels/SKILL.md.hbs | 6 +- .../tilde-memory/SKILL.md.hbs | 6 +- .../tilde-skills/SKILL.md.hbs | 4 + .../platform-skills}/tilde-state/SKILL.md.hbs | 4 + .../platform-skills}/tilde-tools/SKILL.md.hbs | 10 +- .../agent-provider/src/tilde/index.test.ts | 33 +- packages/agent-provider/src/tilde/index.ts | 29 +- .../src/tilde/platform-skills.ts | 33 + packages/api-client/specs/openapi.cloud.json | 1148 +--- packages/api-client/src/generated/index.ts | 4 +- packages/api-client/src/generated/sdk.gen.ts | 83 +- .../api-client/src/generated/types.gen.ts | 399 +- packages/client-runtime/README.md | 110 +- .../src/chat-connectors.test.ts | 232 + .../client-runtime/src/chat-connectors.ts | 351 + .../client-runtime/src/chat/client.test.ts | 6 +- packages/client-runtime/src/chat/client.ts | 191 +- .../src/chat/session-client.test.ts | 45 + .../client-runtime/src/connections.test.ts | 128 + packages/client-runtime/src/connections.ts | 142 + .../contracts/capability-approvals.test.ts | 92 - .../src/contracts/capability-approvals.ts | 122 - .../src/contracts/connectors.test.ts | 22 + .../src/contracts/connectors.ts | 77 + .../client-runtime/src/contracts/events.ts | 8 +- .../client-runtime/src/contracts/messages.ts | 3 + .../client-runtime/src/contracts/plugins.ts | 7 + .../client-runtime/src/contracts/prompt.ts | 54 + .../client-runtime/src/contracts/rooms.ts | 16 +- .../client-runtime/src/contracts/session.ts | 74 + .../src/contracts/transcript.test.ts | 215 + .../src/contracts/transcript.ts | 269 + packages/client-runtime/src/index.ts | 26 +- .../src/native-connector-setup.test.ts | 184 + .../src/native-connector-setup.ts | 386 ++ packages/client-runtime/src/plugins.test.ts | 163 + packages/client-runtime/src/plugins.ts | 236 + packages/client-runtime/src/prompt.test.ts | 157 + packages/client-runtime/src/prompt.ts | 258 + .../client-runtime/src/provider-setup.test.ts | 83 + packages/client-runtime/src/provider-setup.ts | 130 + packages/client-runtime/src/session.test.ts | 357 + packages/client-runtime/src/session.ts | 469 ++ .../client-runtime/src/state/runtime.test.ts | 24 +- packages/client-runtime/src/state/runtime.ts | 151 +- .../client-runtime/src/tilde-plugins.test.ts | 100 +- packages/client-runtime/src/tilde-plugins.ts | 180 +- packages/connector-tools/src/index.ts | 19 +- packages/sdk-vercel-ai-node/src/mcp.ts | 1 + .../src/memory-synthesis-tools.ts | 3 +- .../test/memory-synthesis-tools.test.ts | 11 +- packages/sdk/package.json | 4 +- packages/sdk/src/client.ts | 3 - packages/sdk/src/generated/schema.d.ts | 613 +- packages/sdk/src/index.ts | 13 - packages/sdk/src/self-extension.ts | 379 -- packages/sdk/test/self-extension.test.ts | 124 - packages/sdk/tsconfig.json | 5 +- packages/ui/.storybook/manager.ts | 11 + packages/ui/.storybook/preview.tsx | 41 +- packages/ui/.storybook/styles.d.ts | 2 + packages/ui/README.md | 223 +- packages/ui/package.json | 30 +- ...ctivity.test.ts => activity-queue.test.ts} | 4 +- packages/ui/src/activity-queue.tsx | 215 + packages/ui/src/agent-activity.tsx | 200 - packages/ui/src/agent-avatar-package.test.ts | 5 +- packages/ui/src/assets/channels/LICENSE.txt | 21 + packages/ui/src/assets/channels/NOTICE.md | 9 + packages/ui/src/assets/channels/github.svg | 3 + packages/ui/src/assets/channels/slack.svg | 24 + packages/ui/src/assets/channels/whatsapp.svg | 1 + packages/ui/src/beautiful-ui/PROVENANCE.md | 26 +- .../ui/src/beautiful-ui/atoms/stream-text.tsx | 61 - .../beautiful-ui/blocks/tool-chips-block.tsx | 232 +- .../src/beautiful-ui/blocks/trace-block.tsx | 8 +- .../beautiful-ui/upstream/approval-card.tsx | 240 - .../beautiful-ui/upstream/chat-composer.tsx | 187 - .../src/beautiful-ui/upstream/code-block.tsx | 108 - .../beautiful-ui/upstream/context-cards.tsx | 90 - .../src/beautiful-ui/upstream/diff-table.tsx | 237 - .../beautiful-ui/upstream/filter-table.tsx | 122 - .../beautiful-ui/upstream/fine-tune-card.tsx | 249 - .../src/beautiful-ui/upstream/flowchart.tsx | 538 -- .../ui/src/beautiful-ui/upstream/globals.css | 13 - .../beautiful-ui/upstream/loading-state.tsx | 151 - .../src/beautiful-ui/upstream/prompt-bar.tsx | 717 -- .../upstream/recommendation-card.tsx | 173 - .../beautiful-ui/upstream/records-table.tsx | 747 --- .../src/beautiful-ui/upstream/search-list.tsx | 93 - .../src/beautiful-ui/upstream/sidebar-nav.tsx | 192 - .../beautiful-ui/upstream/streaming-text.tsx | 223 - .../src/beautiful-ui/upstream/task-rows.tsx | 221 - .../beautiful-ui/upstream/thinking-state.tsx | 273 - .../src/beautiful-ui/upstream/tool-chips.tsx | 289 - .../capability-approval-components.test.ts | 35 - .../ui/src/capability-approval-components.tsx | 111 - packages/ui/src/chat-components.tsx | 196 +- packages/ui/src/chat-composer.tsx | 77 +- packages/ui/src/chat-connector-dialog.tsx | 227 + packages/ui/src/chat-events.test.tsx | 134 + packages/ui/src/chat-events.tsx | 78 + packages/ui/src/chat-find-highlight.ts | 108 + packages/ui/src/chat-prompt.tsx | 155 + packages/ui/src/components/ui/dialog.tsx | 5 +- packages/ui/src/connector-components.tsx | 101 +- packages/ui/src/connector-enable-card.tsx | 23 + packages/ui/src/connectors.css | 109 + packages/ui/src/connectors.ts | 13 + packages/ui/src/content-components.tsx | 166 +- packages/ui/src/form-dialog.tsx | 43 + packages/ui/src/index.ts | 191 +- packages/ui/src/markdown-prompt-input.tsx | 78 + packages/ui/src/message-blocks.test.ts | 123 +- packages/ui/src/message-blocks.tsx | 147 +- packages/ui/src/message-content.tsx | 256 +- packages/ui/src/openbot-ui.css | 5797 ++++++++--------- packages/ui/src/overlay-components.tsx | 306 - packages/ui/src/participant-avatars.tsx | 61 + packages/ui/src/plugins-catalog.test.tsx | 2 +- packages/ui/src/plugins-catalog.tsx | 391 +- packages/ui/src/plugins-settings-view.tsx | 150 + packages/ui/src/primitive-components.tsx | 160 +- packages/ui/src/resource-scope-filter.tsx | 26 + packages/ui/src/rich-message-components.tsx | 227 +- packages/ui/src/routine-components.tsx | 329 + packages/ui/src/routine-settings.tsx | 189 +- packages/ui/src/routines-section.tsx | 57 +- packages/ui/src/screen-states.tsx | 164 + .../ui/src/session-participants-dialog.tsx | 174 + packages/ui/src/session-source.tsx | 25 + packages/ui/src/settings-shell.test.tsx | 31 + packages/ui/src/settings-shell.tsx | 234 + packages/ui/src/signals-settings.tsx | 253 +- packages/ui/src/transcript-components.tsx | 164 +- packages/ui/src/use-workspace-layout.ts | 7 +- packages/ui/src/workspace-shell.tsx | 16 +- packages/ui/stories/Activity.stories.tsx | 51 - packages/ui/stories/Avatars.stories.tsx | 35 + .../ui/stories/BasicPrimitives.stories.tsx | 61 + packages/ui/stories/Computer.stories.tsx | 4 +- .../ui/stories/ConnectorEnable.stories.tsx | 146 + packages/ui/stories/Content.stories.tsx | 229 +- packages/ui/stories/Controls.stories.tsx | 135 +- packages/ui/stories/Conversation.stories.tsx | 49 + packages/ui/stories/Header.stories.tsx | 105 + packages/ui/stories/Messages.stories.tsx | 311 +- packages/ui/stories/Overlays.stories.tsx | 85 +- packages/ui/stories/Primitives.stories.tsx | 46 - packages/ui/stories/Prompt.stories.tsx | 93 + packages/ui/stories/Queue.stories.tsx | 24 + packages/ui/stories/ScreenStates.stories.tsx | 60 + packages/ui/stories/SessionAccess.stories.tsx | 90 + packages/ui/stories/Settings.stories.tsx | 137 + .../ui/stories/SettingsRoutines.stories.tsx | 204 + .../ui/stories/SettingsSkills.stories.tsx | 95 + packages/ui/stories/SettingsTools.stories.tsx | 146 + packages/ui/stories/ToolEvents.stories.tsx | 195 + packages/ui/stories/Transcript.stories.tsx | 177 +- packages/ui/stories/Workspace.stories.tsx | 105 +- packages/ui/stories/chat-preview.css | 168 + packages/ui/stories/chat-preview.tsx | 263 + packages/ui/stories/find-in-chat-example.tsx | 170 + packages/ui/stories/session-fixtures.ts | 53 + packages/ui/tsconfig.json | 9 +- pnpm-lock.yaml | 516 +- tests/e2e/plugins.spec.ts | 31 +- tests/e2e/workspace.spec.ts | 320 +- 202 files changed, 16818 insertions(+), 15394 deletions(-) create mode 100644 .changeset/reusable-settings-and-connections.md delete mode 100644 apps/control-service/src/capability-approvals.test.ts delete mode 100644 apps/control-service/src/capability-approvals.ts create mode 100644 apps/control-service/src/tilde-proxy.test.ts delete mode 100644 cli/src/assets/agents/factory/tools/configure_connector.ts.hbs delete mode 100644 cli/src/assets/agents/factory/tools/propose_self_extension.ts.hbs delete mode 100644 cli/src/assets/agents/shared/skills/tilde-connectors/SKILL.md.hbs create mode 100644 docs/adrs/0044-reusable-kit-and-direct-resource-workflows.md create mode 100644 docs/reusable-chat-audit.md create mode 100644 docs/reusable-settings-kit.md create mode 100644 docs/updates/pending/reusable-dispatch-kit.md rename {cli/src/assets/agents/shared/skills => packages/agent-provider/src/tilde/assets/platform-skills}/tilde-chatkit/SKILL.md.hbs (52%) create mode 100644 packages/agent-provider/src/tilde/assets/platform-skills/tilde-connectors/SKILL.md.hbs rename {cli/src/assets/agents/shared/skills => packages/agent-provider/src/tilde/assets/platform-skills}/tilde-control-plane/SKILL.md.hbs (74%) rename {cli/src/assets/agents/shared/skills => packages/agent-provider/src/tilde/assets/platform-skills}/tilde-dev-tunnels/SKILL.md.hbs (65%) rename {cli/src/assets/agents/shared/skills => packages/agent-provider/src/tilde/assets/platform-skills}/tilde-memory/SKILL.md.hbs (73%) rename {cli/src/assets/agents/shared/skills => packages/agent-provider/src/tilde/assets/platform-skills}/tilde-skills/SKILL.md.hbs (74%) rename {cli/src/assets/agents/shared/skills => packages/agent-provider/src/tilde/assets/platform-skills}/tilde-state/SKILL.md.hbs (74%) rename {cli/src/assets/agents/shared/skills => packages/agent-provider/src/tilde/assets/platform-skills}/tilde-tools/SKILL.md.hbs (65%) create mode 100644 packages/agent-provider/src/tilde/platform-skills.ts create mode 100644 packages/client-runtime/src/chat-connectors.test.ts create mode 100644 packages/client-runtime/src/chat-connectors.ts create mode 100644 packages/client-runtime/src/chat/session-client.test.ts create mode 100644 packages/client-runtime/src/connections.test.ts create mode 100644 packages/client-runtime/src/connections.ts delete mode 100644 packages/client-runtime/src/contracts/capability-approvals.test.ts delete mode 100644 packages/client-runtime/src/contracts/capability-approvals.ts create mode 100644 packages/client-runtime/src/contracts/prompt.ts create mode 100644 packages/client-runtime/src/contracts/session.ts create mode 100644 packages/client-runtime/src/contracts/transcript.test.ts create mode 100644 packages/client-runtime/src/contracts/transcript.ts create mode 100644 packages/client-runtime/src/native-connector-setup.test.ts create mode 100644 packages/client-runtime/src/native-connector-setup.ts create mode 100644 packages/client-runtime/src/plugins.test.ts create mode 100644 packages/client-runtime/src/plugins.ts create mode 100644 packages/client-runtime/src/prompt.test.ts create mode 100644 packages/client-runtime/src/prompt.ts create mode 100644 packages/client-runtime/src/provider-setup.test.ts create mode 100644 packages/client-runtime/src/provider-setup.ts create mode 100644 packages/client-runtime/src/session.test.ts create mode 100644 packages/client-runtime/src/session.ts delete mode 100644 packages/sdk/src/self-extension.ts delete mode 100644 packages/sdk/test/self-extension.test.ts create mode 100644 packages/ui/.storybook/manager.ts create mode 100644 packages/ui/.storybook/styles.d.ts rename packages/ui/src/{agent-activity.test.ts => activity-queue.test.ts} (90%) create mode 100644 packages/ui/src/activity-queue.tsx delete mode 100644 packages/ui/src/agent-activity.tsx create mode 100644 packages/ui/src/assets/channels/LICENSE.txt create mode 100644 packages/ui/src/assets/channels/NOTICE.md create mode 100644 packages/ui/src/assets/channels/github.svg create mode 100644 packages/ui/src/assets/channels/slack.svg create mode 100644 packages/ui/src/assets/channels/whatsapp.svg delete mode 100644 packages/ui/src/beautiful-ui/atoms/stream-text.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/approval-card.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/chat-composer.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/code-block.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/context-cards.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/diff-table.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/filter-table.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/fine-tune-card.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/flowchart.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/loading-state.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/prompt-bar.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/recommendation-card.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/records-table.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/search-list.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/sidebar-nav.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/streaming-text.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/task-rows.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/thinking-state.tsx delete mode 100644 packages/ui/src/beautiful-ui/upstream/tool-chips.tsx delete mode 100644 packages/ui/src/capability-approval-components.test.ts delete mode 100644 packages/ui/src/capability-approval-components.tsx create mode 100644 packages/ui/src/chat-connector-dialog.tsx create mode 100644 packages/ui/src/chat-events.test.tsx create mode 100644 packages/ui/src/chat-events.tsx create mode 100644 packages/ui/src/chat-find-highlight.ts create mode 100644 packages/ui/src/chat-prompt.tsx create mode 100644 packages/ui/src/connector-enable-card.tsx create mode 100644 packages/ui/src/connectors.css create mode 100644 packages/ui/src/connectors.ts create mode 100644 packages/ui/src/form-dialog.tsx create mode 100644 packages/ui/src/markdown-prompt-input.tsx create mode 100644 packages/ui/src/participant-avatars.tsx create mode 100644 packages/ui/src/plugins-settings-view.tsx create mode 100644 packages/ui/src/resource-scope-filter.tsx create mode 100644 packages/ui/src/routine-components.tsx create mode 100644 packages/ui/src/screen-states.tsx create mode 100644 packages/ui/src/session-participants-dialog.tsx create mode 100644 packages/ui/src/session-source.tsx create mode 100644 packages/ui/src/settings-shell.test.tsx create mode 100644 packages/ui/src/settings-shell.tsx delete mode 100644 packages/ui/stories/Activity.stories.tsx create mode 100644 packages/ui/stories/Avatars.stories.tsx create mode 100644 packages/ui/stories/BasicPrimitives.stories.tsx create mode 100644 packages/ui/stories/ConnectorEnable.stories.tsx create mode 100644 packages/ui/stories/Conversation.stories.tsx create mode 100644 packages/ui/stories/Header.stories.tsx delete mode 100644 packages/ui/stories/Primitives.stories.tsx create mode 100644 packages/ui/stories/Prompt.stories.tsx create mode 100644 packages/ui/stories/Queue.stories.tsx create mode 100644 packages/ui/stories/ScreenStates.stories.tsx create mode 100644 packages/ui/stories/SessionAccess.stories.tsx create mode 100644 packages/ui/stories/Settings.stories.tsx create mode 100644 packages/ui/stories/SettingsRoutines.stories.tsx create mode 100644 packages/ui/stories/SettingsSkills.stories.tsx create mode 100644 packages/ui/stories/SettingsTools.stories.tsx create mode 100644 packages/ui/stories/ToolEvents.stories.tsx create mode 100644 packages/ui/stories/chat-preview.css create mode 100644 packages/ui/stories/chat-preview.tsx create mode 100644 packages/ui/stories/find-in-chat-example.tsx create mode 100644 packages/ui/stories/session-fixtures.ts diff --git a/.changeset/reusable-settings-and-connections.md b/.changeset/reusable-settings-and-connections.md new file mode 100644 index 00000000..2ae39f08 --- /dev/null +++ b/.changeset/reusable-settings-and-connections.md @@ -0,0 +1,62 @@ +--- +"@tryopenbot/agent-provider": minor +"@tryopenbot/agent-service-provider": minor +"@tryopenbot/auth-provider": minor +"openbot": minor +"@tryopenbot/computer-service-provider": minor +"@tryopenbot/client-runtime": minor +"@tryopenbot/computer-tools": minor +"@tryopenbot/computer-service": minor +"@tryopenbot/computer-service-proto": minor +"@tryopenbot/configuration": minor +"@tryopenbot/desktop": minor +"@tryopenbot/utilities": minor +"@tryopenbot/platform-integrations": minor +"@tryopenbot/control-service-provider": minor +"@tryopenbot/runtime-provider": minor +"@tryopenbot/control-service": minor +"@tryopenbot/ui": minor +"@tryopenbot/web": minor +"@tryopenbot/git-provider": minor +--- + +Expose reusable settings, form primitives, controlled connections, and host-themed connector consumption. Add framework-neutral plugin, provider setup, prompt/attachment, session search, participation and transcript controllers. + +Compose chat controls through `ChatPrompt`: transparent floating dock, embedded non-scrolling queue, floating replies, centered scroll, actionable statuses, and circular attachment controls. Add participant avatars, editable session headers, source badges and native roster and existing-identity addition controls. Gate nonmember messaging by session source and confirm native API joins through the authoritative roster. + +Separate bubbled messages from unbubbled events and respect native tool-summary projection. Match dialog action heights and increase body bottom spacing. Organize Storybook into Chat Controls, Messages, Events and Primitives with paired isolated and full-chat examples. Remove unused timeline/combined activity, voice, rolling text, model picker and custom scrolling exports. + +Refine prompt overflow and badge shadows, crossfade replies with queue/notices, move people controls beside names, and show source brand icons. Add rich Markdown prompt editing with responsive growth and circular attachment chips outside message bubbles. Search highlights rich text and loads earlier matches; participant search uses a floating autocomplete and direct native addition of existing identities. + +Keep attachment chips directly beneath their own message with a tighter gap, flowing left-to-right for agents and right-to-left for users. + +Open document and gallery story examples from actual attachment chips, and allow FileCard previews to delegate to a shared gallery. + +Use a single inline audio player for sound attachments, remove fullscreen audio and link-hover previews, and keep link/diagram examples outside bubbles. Add shared transcript day grouping, centered date labels, tighter consecutive messages and 24-hour hover timestamps. Audit actual Chat catalog adoption and remaining runtime workflow gaps. + +Flash the focused search-result bubble blue three times instead of outlining its row, with a runtime focus token to replay deliberate result navigation without retriggering on unrelated history updates. + +Remove message action controls and quoted-reply runtime state, align source-only prompt typography, render sound attachments inside bubbles, and use warning-styled ordinary unknown messages. Remove redundant composed-content/message-actions stories and tighten follow-on spacing across story wrappers. + +Remove unused generic permission-request and local-tool permission cards, their dock, public types, styles and approval stories. Keep the capability-approval renderer and stories. + +Consolidate transcript loading/errors and the scroll/new-message prompt control. Remove transcript notices and generic reasoning cards. Use one unboxed generic tool-call row for summaries and expandable full details, preserving specialized tool presentations. + +Prune unused Beautiful UI demos, StreamText and glimm. Remove their exports and +Patterns catalog; use the retained production Tools pattern for chained calls, +with persistent leading chevrons, content-sized parameter chips and output-only +expansion. Anchor send notices beneath their message and center system events. + +Remove unused ComputerHandoffCard source, types, styling and Storybook examples. + +Expose reusable settings provider cards, tool/skill rows, filters, routine list/ +table/status/action/delete and provider-connection components. Keep Dispatch's +existing compositions and permit host-owned actions, descriptions and agent-free +layouts. Remove unused Agent Exchange overlays. Preserve full tool/reasoning +output in scroll areas capped at 400px. + +Retire capability proposals and default account-picker tools. Provision native +Tilde resource skills and the managed connection process for every agent. Render +native pending connector setup as an event card with controlled continuation +modals. Add scoped resource inventories and inner-call rendering for tool batches. +Expose form-dialog and screen-state compositions for external application reuse. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d716c82f..c9db3742 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,7 +9,8 @@ copyright and license status. - Retrieved: 2026-08-17, directly from the site's published component source (the payload behind its "Copy code" affordance) - Upstream description: copy-paste components for AI-native interfaces -- Files: `packages/ui/src/beautiful-ui/upstream/` +- Retained files: the theme/license in `packages/ui/src/beautiful-ui/upstream/` + and production reconstructions/forks in its sibling `atoms/` and `blocks/` directories. - License: MIT, copyright (c) 2026 Shane Levine, published at ; preserved in `packages/ui/src/beautiful-ui/upstream/LICENSE` @@ -20,7 +21,8 @@ An earlier 2026-08-12 retrieval came from the it. Per-file SHA-256 values at retrieval and the small recorded OpenBot modifications (analytics removal, import-path rewrites) are documented in `packages/ui/src/beautiful-ui/PROVENANCE.md`. OpenBot-specific composition is -kept outside the upstream directory. +kept outside the upstream directory. Unused React demos were removed in September +2026; the retained tool/reasoning forks and attribution remain. ## shadcn/ui @@ -41,12 +43,6 @@ kept outside the upstream directory. utilities remapped as above; a type cast added for the `streamdown`/`@streamdown/*` shiki version skew. -## glimm - -- Source: -- License: MIT -- Used by the vendored Beautiful UI `prompt-bar` component; declared as a - regular npm dependency of `packages/ui`. ## Cua Driver and GUI Automation skill diff --git a/apps/control-service/README.md b/apps/control-service/README.md index 6fee1abe..24769b3c 100644 --- a/apps/control-service/README.md +++ b/apps/control-service/README.md @@ -12,9 +12,9 @@ The portable Hono control application. It serves health, exposes raw allowlisted room roster/invitation semantics for an exact Client Runtime operation allowlist and exposes only the short-lived ChatKit realtime ticket needed for a direct browser WebSocket. - `registerTildeProxy(app, options)` preserves request and response bodies for a strict allowlist of Tilde-owned settings operations while keeping the installation API key out of clients. -- `registerCapabilityApprovalRoutes(app, options)` forwards an exact approval/hash/generation - decision using only the owner bearer already verified by `requireOwner`; it never substitutes - the installation API key and returns only the tokenless approval projection consumed by clients. +- The `user-tools/` bridge uses only the verified owner bearer for setup and inventory reads. + Personal account operations retain the native user namespace; the installation key is never + substituted when a human credential is required. Capability-proposal routes are removed. - `registerComputerPreview(app, provider, options)` exposes the narrow owner preview redirect without making Computer service browser-accessible. - `registerConnectorAuthorizedRoute(app)` serves only the public OAuth completion page that bounces desktop flows to the `openbot://` deep link. Connector resources and setup use native Tilde APIs through `registerTildeProxy`. diff --git a/apps/control-service/src/app.test.ts b/apps/control-service/src/app.test.ts index 1b08de5e..559109c7 100644 --- a/apps/control-service/src/app.test.ts +++ b/apps/control-service/src/app.test.ts @@ -656,6 +656,42 @@ describe("bare OpenBot server", () => { expect(upstreamCalls).toBe(0); }); + it("bridges only a read of the configured team's people directory", async () => { + const urls: string[] = []; + const app = createApp({ + tildeChatProxy: { + apiKey: "test-key", + orgId: "org-one", + teamId: "team-one", + baseUrl: "https://tilde.test", + fetch: async (input) => { + urls.push( + typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + ); + return Response.json({ items: [] }); + }, + }, + }); + const response = await app.request( + "https://openbot.test/api/chat/_identity/team-members?user_type=human&page_size=100", + ); + expect(response.status).toBe(200); + expect(urls[0]).toBe( + "https://tilde.test/api/v1/identity/teams/team-one/members?user_type=human&page_size=100", + ); + expect( + ( + await app.request("https://openbot.test/api/chat/_identity/team-members", { + method: "POST", + }) + ).status, + ).toBe(404); + expect( + (await app.request("https://openbot.test/api/chat/_identity/teams/another-team/members")) + .status, + ).toBe(404); + }); + it("allows only the room operations consumed by Client Runtime", async () => { const calls: Array<[string, string]> = []; const chatApp = createApp({ diff --git a/apps/control-service/src/app.ts b/apps/control-service/src/app.ts index 59720f06..fc611ab3 100644 --- a/apps/control-service/src/app.ts +++ b/apps/control-service/src/app.ts @@ -10,7 +10,6 @@ import type { ComputerProvider } from "@tryopenbot/computer-service-provider"; import { registerAgentCreation, type AgentCreationOptions } from "./agent-create.js"; import { registerTildeChatProxy, type TildeChatProxyOptions } from "./chat-proxy.js"; import { registerTildeProxy, type TildeProxyOptions } from "./tilde-proxy.js"; -import { registerCapabilityApprovalRoutes } from "./capability-approvals.js"; import { registerConnectorAuthorizedRoute } from "./connector-authorized.js"; import { registerComputerPreview } from "./computer-preview.js"; import { registerOwnerAuth, requireOwner } from "./auth.js"; @@ -40,7 +39,6 @@ export function createApp(options: AppOptions = {}): Hono { const middleware = requireOwner(options.authProvider, options); app.use("/api/chat/*", middleware); app.use("/api/tilde/*", middleware); - app.use("/api/capability-approvals/*", middleware); app.use("/api/computer/*", middleware); app.use("/api/agents", middleware); app.use("/api/agents/*", middleware); @@ -55,7 +53,6 @@ export function createApp(options: AppOptions = {}): Hono { registerAgentCreation(app, { environment: options.environment, ...options.agentCreation }); registerTildeChatProxy(app, options.tildeChatProxy); registerTildeProxy(app, options.tildeProxy ?? options.tildeChatProxy); - registerCapabilityApprovalRoutes(app, options.tildeProxy ?? options.tildeChatProxy); registerConnectorAuthorizedRoute(app); if (existsSync(webRoot)) { const cacheHeaders = ( diff --git a/apps/control-service/src/capability-approvals.test.ts b/apps/control-service/src/capability-approvals.test.ts deleted file mode 100644 index 66cb3d61..00000000 --- a/apps/control-service/src/capability-approvals.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import type { AuthProvider } from "@tryopenbot/auth-provider"; -import { createApp } from "./app.js"; - -function testAuthProvider(): AuthProvider { - return { - initialization: { id: "test-auth", label: "Test auth", questions: [] }, - deployable: { plan: async () => ({ summary: "test" }), deploy: async () => ({}) }, - nativeClientConfiguration: () => ({ - authorizationEndpoint: "https://identity.test/authorize", - tokenEndpoint: "https://identity.test/token", - clientId: "client-one", - scope: "openid offline_access openbot:control", - }), - authorizationUrl: () => new URL("https://identity.test/authorize"), - exchangeCode: async () => ({ accessToken: "human-token", expiresIn: 3600 }), - refresh: async () => ({ accessToken: "human-token", expiresIn: 3600 }), - verify: async () => ({ subject: "owner-one", groups: [], scope: ["openbot:control"] }), - } as unknown as AuthProvider; -} - -describe("capability approval proxy", () => { - it("forwards the authenticated human bearer and exact binding", async () => { - const upstream = vi.fn(async (_input: URL | string, init?: RequestInit) => { - expect(new Headers(init?.headers).get("authorization")).toBe("Bearer human-token"); - expect(JSON.parse(expectStringBody(init?.body))).toEqual({ - approval_id: "approval-a", - proposal_hash: "hash-a", - proposal_generation: 1, - decision: "approve", - }); - return Response.json({ - id: "proposal-a", - status: "approved", - error_message: "provider secret-shaped diagnostic", - desired_state: { client_secret: "must-not-reach-browser" }, - title: "Add Stripe", - rationale: "Revenue", - category: "connector", - preview: { - permissions: [], - credentials: [], - cost_summary: "$0", - security_summary: "Read-only", - rollback_plan: "Remove", - }, - approval: { - approval_id: "approval-a", - proposal_id: "proposal-a", - proposal_hash: "hash-a", - proposal_generation: 1, - status: "completed", - title: "Add Stripe", - instructions: "Revenue", - }, - }); - }); - const app = createApp({ - authProvider: testAuthProvider(), - tildeProxy: { - apiKey: "machine", - orgId: "org", - teamId: "team", - baseUrl: "https://tilde.test", - fetch: upstream as typeof fetch, - }, - }); - const response = await app.request( - "https://openbot.test/api/capability-approvals/proposal-a/decision", - { - method: "POST", - headers: { authorization: "Bearer human-token", "content-type": "application/json" }, - body: JSON.stringify({ - approval_id: "approval-a", - proposal_hash: "hash-a", - proposal_generation: 1, - decision: "approve", - }), - }, - ); - expect(response.status).toBe(200); - const responseText = await response.text(); - expect(responseText).not.toContain("must-not-reach-browser"); - expect(responseText).not.toContain("secret-shaped diagnostic"); - expect(JSON.parse(responseText)).toEqual({ - id: "proposal-a", - title: "Add Stripe", - rationale: "Revenue", - category: "connector", - status: "approved", - preview: { - permissions: [], - credentials: [], - cost_summary: "$0", - security_summary: "Read-only", - rollback_plan: "Remove", - }, - approval: { - approval_id: "approval-a", - proposal_id: "proposal-a", - proposal_hash: "hash-a", - proposal_generation: 1, - status: "completed", - title: "Add Stripe", - instructions: "Revenue", - }, - }); - expect(upstream).toHaveBeenCalledOnce(); - }); - - it("does not accept a machine key in place of an owner bearer", async () => { - const upstream = vi.fn(); - const app = createApp({ - authProvider: testAuthProvider(), - tildeProxy: { - apiKey: "machine", - orgId: "org", - teamId: "team", - baseUrl: "https://tilde.test", - fetch: upstream, - }, - }); - const response = await app.request( - "https://openbot.test/api/capability-approvals/proposal-a/decision", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - approval_id: "approval-a", - proposal_hash: "hash-a", - proposal_generation: 1, - decision: "approve", - }), - }, - ); - expect(response.status).toBe(403); - expect(upstream).not.toHaveBeenCalled(); - }); - - it("does not expose upstream errors to the owner client", async () => { - const app = createApp({ - authProvider: testAuthProvider(), - tildeProxy: { - apiKey: "machine", - orgId: "org", - teamId: "team", - baseUrl: "https://tilde.test", - fetch: vi - .fn() - .mockResolvedValue( - Response.json({ error: "provider client_secret was rejected" }, { status: 409 }), - ), - }, - }); - const response = await app.request( - "https://openbot.test/api/capability-approvals/proposal-a/decision", - { - method: "POST", - headers: { authorization: "Bearer human-token", "content-type": "application/json" }, - body: JSON.stringify({ - approval_id: "approval-a", - proposal_hash: "hash-a", - proposal_generation: 1, - decision: "approve", - }), - }, - ); - expect(response.status).toBe(409); - await expect(response.json()).resolves.toEqual({ error: "Capability decision failed" }); - }); - - it("reloads durable decision state with the verified owner bearer", async () => { - const upstream = vi.fn(async (_input: URL | string, init?: RequestInit) => { - expect(new Headers(init?.headers).get("authorization")).toBe("Bearer human-token"); - expect(init?.method).toBeUndefined(); - return Response.json({ - id: "proposal-a", - status: "executed", - title: "Add Stripe", - rationale: "Revenue", - category: "connector", - preview: { - permissions: [], - credentials: [], - cost_summary: "$0", - security_summary: "Read-only", - rollback_plan: "Remove", - }, - approval: { - approval_id: "approval-a", - proposal_id: "proposal-a", - proposal_hash: "hash-a", - proposal_generation: 1, - status: "completed", - title: "Add Stripe", - instructions: "Revenue", - }, - }); - }); - const app = createApp({ - authProvider: testAuthProvider(), - tildeProxy: { - apiKey: "machine", - orgId: "org", - teamId: "team", - baseUrl: "https://tilde.test", - fetch: upstream as typeof fetch, - }, - }); - const response = await app.request("https://openbot.test/api/capability-approvals/proposal-a", { - headers: { authorization: "Bearer human-token" }, - }); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ id: "proposal-a", status: "executed" }); - }); - - it("rejects an upstream approval bound to a different proposal", async () => { - const app = createApp({ - authProvider: testAuthProvider(), - tildeProxy: { - apiKey: "machine", - orgId: "org", - teamId: "team", - baseUrl: "https://tilde.test", - fetch: vi.fn().mockResolvedValue( - Response.json({ - id: "proposal-b", - status: "pending", - title: "Add Stripe", - rationale: "Revenue", - category: "connector", - preview: { - permissions: [], - credentials: [], - cost_summary: "$0", - security_summary: "Read-only", - rollback_plan: "Remove", - }, - approval: { - approval_id: "approval-a", - proposal_id: "proposal-b", - proposal_hash: "hash-a", - proposal_generation: 1, - status: "pending", - title: "Add Stripe", - instructions: "Revenue", - }, - }), - ), - }, - }); - const response = await app.request("https://openbot.test/api/capability-approvals/proposal-a", { - headers: { authorization: "Bearer human-token" }, - }); - expect(response.status).toBe(502); - await expect(response.json()).resolves.toEqual({ error: "Invalid capability response" }); - }); -}); - -function expectStringBody(body: unknown): string { - expect(typeof body).toBe("string"); - return body as string; -} diff --git a/apps/control-service/src/capability-approvals.ts b/apps/control-service/src/capability-approvals.ts deleted file mode 100644 index 8503bbd7..00000000 --- a/apps/control-service/src/capability-approvals.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { Hono } from "hono"; -import type { TildeProxyOptions } from "./tilde-proxy.js"; - -/** Register the owner-only proxy for exact capability-change decisions. */ -export function registerCapabilityApprovalRoutes(app: Hono, configured?: TildeProxyOptions): void { - app.get("/api/capability-approvals/:proposalId", async (context) => { - const options = configured ?? optionsFromEnvironment(); - if (!options) - return context.json({ error: "Tilde capability approvals are not configured" }, 503); - const { ownerAccessToken: accessToken } = context.var as { ownerAccessToken?: string }; - if (!accessToken) return context.json({ error: "Authentication required" }, 401); - const response = await requestProposal( - options, - accessToken, - context.req.param("proposalId"), - context.req.raw.signal, - ); - if (!response.ok) - return context.json({ error: "Capability lookup failed" }, response.status as 400); - const proposal = await safeProposal(response, context.req.param("proposalId")); - if (!proposal) return context.json({ error: "Invalid capability response" }, 502); - context.header("cache-control", "no-store"); - return context.json(proposal); - }); - - app.post("/api/capability-approvals/:proposalId/decision", async (context) => { - const options = configured ?? optionsFromEnvironment(); - if (!options) - return context.json({ error: "Tilde capability approvals are not configured" }, 503); - const { ownerAccessToken: accessToken } = context.var as { ownerAccessToken?: string }; - if (!accessToken) return context.json({ error: "Authentication required" }, 401); - const body = await context.req.json().catch(() => undefined); - if (!validBody(body)) return context.json({ error: "Invalid capability decision" }, 400); - const url = new URL( - `/api/v1/team/${encodeURIComponent(options.teamId)}/chatkit/self-extension-proposals/${encodeURIComponent(context.req.param("proposalId"))}/decision`, - options.baseUrl ?? "https://api.trytilde.ai", - ); - const response = await (options.fetch ?? fetch)(url, { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - authorization: `Bearer ${accessToken}`, - "x-tilde-org-id": options.orgId, - "x-tilde-team-id": options.teamId, - }, - body: JSON.stringify(body), - signal: context.req.raw.signal, - }); - if (!response.ok) { - return context.json({ error: "Capability decision failed" }, response.status as 400); - } - const proposal = await safeProposal(response, context.req.param("proposalId")); - if (!proposal) return context.json({ error: "Invalid capability response" }, 502); - context.header("cache-control", "no-store"); - return context.json(proposal); - }); -} - -function requestProposal( - options: TildeProxyOptions, - accessToken: string, - proposalId: string, - signal: AbortSignal, -): Promise { - const url = new URL( - `/api/v1/team/${encodeURIComponent(options.teamId)}/chatkit/self-extension-proposals/${encodeURIComponent(proposalId)}`, - options.baseUrl ?? "https://api.trytilde.ai", - ); - return (options.fetch ?? fetch)(url, { - headers: { - accept: "application/json", - authorization: `Bearer ${accessToken}`, - "x-tilde-org-id": options.orgId, - "x-tilde-team-id": options.teamId, - }, - signal, - }); -} - -async function safeProposal( - response: Response, - expectedProposalId: string, -): Promise | undefined> { - const raw = record(await response.json().catch(() => undefined)); - const preview = record(raw?.preview); - const approval = record(raw?.approval); - const id = text(raw?.id); - const title = text(raw?.title); - const rationale = text(raw?.rationale); - const category = text(raw?.category); - const status = text(raw?.status); - const approvalId = text(approval?.approval_id); - const proposalId = text(approval?.proposal_id); - const proposalHash = text(approval?.proposal_hash); - const proposalGeneration = approval?.proposal_generation; - if ( - !id || - id !== expectedProposalId || - !title || - !rationale || - !category || - !status || - !approvalId || - !proposalId || - !proposalHash || - proposalId !== id || - !Number.isSafeInteger(proposalGeneration) - ) - return; - const continuation = safeContinuation(raw?.continuation); - return { - id, - title, - rationale, - category, - status, - preview: { - permissions: safePermissions(preview?.permissions), - credentials: safeCredentialRequirements(preview?.credentials), - cost_summary: text(preview?.cost_summary), - security_summary: text(preview?.security_summary), - rollback_plan: text(preview?.rollback_plan), - }, - approval: { - approval_id: approvalId, - proposal_id: proposalId, - proposal_hash: proposalHash, - proposal_generation: proposalGeneration, - status: text(approval?.status), - title: text(approval?.title), - instructions: text(approval?.instructions), - }, - ...(continuation ? { continuation } : {}), - }; -} - -function safeContinuation(value: unknown): Record | undefined { - const continuation = record(value); - if (text(continuation?.kind) !== "provider_setup") return; - const setupItemId = text(continuation?.setup_item_id); - const resourceId = text(continuation?.resource_id); - if (!setupItemId || !resourceId) return; - return { - kind: "provider_setup", - setup_item_id: setupItemId, - resource_id: resourceId, - instructions: text(continuation?.instructions), - }; -} - -function safePermissions(value: unknown): Array> { - return Array.isArray(value) - ? value.flatMap((item) => { - const permission = record(item); - return permission - ? [ - { - permission: text(permission.permission), - plane: text(permission.plane), - principals: strings(permission.principals), - reason: text(permission.reason), - }, - ] - : []; - }) - : []; -} - -function safeCredentialRequirements(value: unknown): Array> { - return Array.isArray(value) - ? value.flatMap((item) => { - const requirement = record(item); - return requirement - ? [ - { - credential_type: text(requirement.credential_type), - purpose: text(requirement.purpose), - brokered_by: text(requirement.brokered_by), - required_fields: strings(requirement.required_fields), - }, - ] - : []; - }) - : []; -} - -function record(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function text(value: unknown): string { - return typeof value === "string" ? value : ""; -} - -function strings(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - -function validBody(value: unknown): value is Record { - if (typeof value !== "object" || value === null) return false; - const body = value as Record; - return ( - typeof body.approval_id === "string" && - body.approval_id.length > 0 && - typeof body.proposal_hash === "string" && - body.proposal_hash.length > 0 && - Number.isSafeInteger(body.proposal_generation) && - (body.decision === "approve" || body.decision === "reject") - ); -} - -function optionsFromEnvironment(): TildeProxyOptions | undefined { - const apiKey = process.env.TILDE_API_KEY?.trim(); - const orgId = process.env.TILDE_ORG_ID?.trim(); - const teamId = process.env.TILDE_TEAM_ID?.trim(); - if (!apiKey || !orgId || !teamId) return; - return { apiKey, orgId, teamId, baseUrl: process.env.TILDE_BASE_URL }; -} diff --git a/apps/control-service/src/chat-proxy.ts b/apps/control-service/src/chat-proxy.ts index 0fe2283f..4674efff 100644 --- a/apps/control-service/src/chat-proxy.ts +++ b/apps/control-service/src/chat-proxy.ts @@ -25,6 +25,9 @@ const methods = (...values: AllowedMethod[]): ReadonlySet => new /** Exact ChatKit operations consumed by Client Runtime. */ const allowedTeamRoutes: readonly AllowedRoute[] = [ + { pattern: /^chat-channels$/, methods: methods("GET") }, + { pattern: /^sessions\/[^/]+\/join$/, methods: methods("POST") }, + { pattern: /^_identity\/team-members$/, methods: methods("GET") }, { pattern: /^workspace\/sidebar$/, methods: methods("GET") }, { pattern: /^workspace\/bootstrap$/, methods: methods("GET") }, { pattern: /^workspace\/search$/, methods: methods("GET") }, @@ -312,6 +315,8 @@ function resolveUpstreamPath( relativePath: string, options: Pick, ): string | undefined { + if (relativePath === "_identity/team-members") + return `/api/v1/identity/teams/${encodeURIComponent(options.teamId)}/members`; if (!relativePath.startsWith(rootChatKitPrefix)) { return `/api/v1/team/${encodeURIComponent(options.teamId)}/chatkit/${relativePath}`; } diff --git a/apps/control-service/src/tilde-proxy.test.ts b/apps/control-service/src/tilde-proxy.test.ts new file mode 100644 index 00000000..6174f08a --- /dev/null +++ b/apps/control-service/src/tilde-proxy.test.ts @@ -0,0 +1,47 @@ +import { Hono } from "hono"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { registerTildeProxy } from "./tilde-proxy.js"; + +function fixture(owner: boolean) { + const app = new Hono(); + if (owner) + app.use("*", async (context, next) => { + context.set("ownerAccessToken", "fixture-owner-token"); + await next(); + }); + const fetch = vi.fn(async () => Response.json({ ok: true })); + registerTildeProxy(app, { + apiKey: "fixture-installation-key", + orgId: "org-one", + teamId: "team-one", + baseUrl: "https://tilde.test", + fetch, + }); + return { app, fetch }; +} + +describe("owner resource bridge", () => { + it("uses the human credential for personal deletion and preserves native scope", async () => { + const { app, fetch } = fixture(true); + const response = await app.request( + "https://app.test/api/tilde/user-tools/personal/user-one/mcp/tool-group/account-one", + { method: "DELETE" }, + ); + expect(response.status).toBe(200); + const [url, init] = fetch.mock.calls[0]!; + expect(url instanceof Request ? url.url : url instanceof URL ? url.href : url).toBe( + "https://tilde.test/api/v1/user/user-one/mcp/tool-group/account-one", + ); + const headers = new Headers(init?.headers); + expect(headers.get("authorization")).toBe("Bearer fixture-owner-token"); + expect(headers.has("x-api-key")).toBe(false); + }); + it("never substitutes the installation key for an absent human credential", async () => { + const { app, fetch } = fixture(false); + const response = await app.request( + "https://app.test/api/tilde/user-tools/mcp/tool-providers?scope=personal", + ); + expect(response.status).toBe(401); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/control-service/src/tilde-proxy.ts b/apps/control-service/src/tilde-proxy.ts index ca796b22..cdbe120c 100644 --- a/apps/control-service/src/tilde-proxy.ts +++ b/apps/control-service/src/tilde-proxy.ts @@ -63,6 +63,25 @@ const allowedRoutes: readonly AllowedRoute[] = [ }, ]; +const userSetupRoutes: readonly AllowedRoute[] = [ + { pattern: /^personal\/[^/]+\/mcp\/tool-group\/[^/]+$/, methods: methods("GET", "DELETE") }, + { pattern: /^mcp\/tool-providers$/, methods: methods("GET") }, + { pattern: /^skills$/, methods: methods("GET") }, + { pattern: /^provider-setup\/[^/]+\/resume$/, methods: methods("POST") }, + { pattern: /^credential\/setup-items$/, methods: methods("GET") }, + { pattern: /^credential\/setup-items\/[^/]+$/, methods: methods("GET") }, + { + pattern: /^credential\/setup-items\/[^/]+\/(?:start|resume|complete)$/, + methods: methods("POST"), + }, + { + pattern: /^credential\/source\/[^/]+\/(?:resource-server|user-credential)(?:\/encrypt)?$/, + methods: methods("POST"), + }, + { pattern: /^mcp\/mcp-server$/, methods: methods("GET", "POST") }, + { pattern: /^mcp\/mcp-server\/[^/]+$/, methods: methods("GET") }, +]; + export interface TildeProxyOptions { apiKey: string; orgId: string; @@ -81,22 +100,51 @@ export function registerTildeProxy(app: Hono, configuredOptions?: TildeProxyOpti 503, ); - const relativePath = safeRelativePath(context.req.path.slice(proxyPrefix.length)); + const suppliedPath = context.req.path.slice(proxyPrefix.length); + const userSetup = suppliedPath.startsWith("user-tools/"); + const relativePath = safeRelativePath( + userSetup ? suppliedPath.slice("user-tools/".length) : suppliedPath, + ); const method = context.req.method as AllowedMethod; - if (!relativePath || !isAllowed(relativePath, method)) + const ownerToken = (context.var as { ownerAccessToken?: string }).ownerAccessToken; + if (userSetup && !ownerToken) + return context.json( + { error: "An authenticated user is required for personal tool setup" }, + 401, + ); + if ( + !relativePath || + !( + isAllowed(relativePath, method) || + (userSetup && + userSetupRoutes.some( + (route) => route.pattern.test(relativePath) && route.methods.has(method), + )) + ) + ) return context.json({ error: "Unsupported Tilde operation" }, 404); const incomingUrl = new URL(context.req.url); + const personal = userSetup + ? /^personal\/([^/]+)\/(mcp\/tool-group\/[^/]+)$/.exec(relativePath) + : null; const upstreamUrl = new URL( - `/api/v1/team/${encodeURIComponent(options.teamId)}/${relativePath}`, + personal + ? `/api/v1/user/${personal[1]}/${personal[2]}` + : `/api/v1/team/${encodeURIComponent(options.teamId)}/${relativePath}`, options.baseUrl ?? defaultTildeBaseUrl, ); upstreamUrl.search = incomingUrl.search; try { + const headersForUpstream = upstreamHeaders(context, options); + if (userSetup) { + headersForUpstream.delete("x-api-key"); + headersForUpstream.set("authorization", `Bearer ${ownerToken}`); + } const upstream = await (options.fetch ?? globalThis.fetch)(upstreamUrl, { method, - headers: upstreamHeaders(context, options), + headers: headersForUpstream, body: await requestBody(context), signal: context.req.raw.signal, redirect: "manual", diff --git a/apps/web/src/runtime.ts b/apps/web/src/runtime.ts index 4600fadf..54dfed34 100644 --- a/apps/web/src/runtime.ts +++ b/apps/web/src/runtime.ts @@ -1,3 +1,4 @@ +import { createWebAttachments } from "./web-attachments.js"; import { createClientAuthAdapter, createOpenBotClient, @@ -52,4 +53,12 @@ const agentSetupPersistence = { }, }; -export const openBotRuntime = createOpenBotRuntime({ client, auth, agentSetupPersistence }); +const webAttachments = createWebAttachments(); +export const openBotRuntime = createOpenBotRuntime({ + client, + auth, + agentSetupPersistence, + attachments: webAttachments.platform, +}); +export const registerPromptFiles = (files: Iterable) => + openBotRuntime.prompt.addAttachments(webAttachments.register(files)); diff --git a/apps/web/src/screens/composer-layout.test.ts b/apps/web/src/screens/composer-layout.test.ts index 0a0a870c..08cb6092 100644 --- a/apps/web/src/screens/composer-layout.test.ts +++ b/apps/web/src/screens/composer-layout.test.ts @@ -3,14 +3,13 @@ import { shouldExpandComposer } from "./composer-layout.js"; describe("composer layout", () => { it("does not jump to the expanded layout at a character-count threshold", () => { - expect(shouldExpandComposer("a".repeat(80), false, false)).toBe(false); - expect(shouldExpandComposer("a".repeat(81), false, false)).toBe(false); - expect(shouldExpandComposer("a".repeat(200), false, false)).toBe(false); + expect(shouldExpandComposer("a".repeat(80), false)).toBe(false); + expect(shouldExpandComposer("a".repeat(81), false)).toBe(false); + expect(shouldExpandComposer("a".repeat(200), false)).toBe(false); }); it("expands for explicitly multiline or supplemental content", () => { - expect(shouldExpandComposer("first line\nsecond line", false, false)).toBe(true); - expect(shouldExpandComposer("message", true, false)).toBe(true); - expect(shouldExpandComposer("message", false, true)).toBe(true); + expect(shouldExpandComposer("first line\nsecond line", false)).toBe(true); + expect(shouldExpandComposer("message", true)).toBe(true); }); }); diff --git a/apps/web/src/screens/composer-layout.ts b/apps/web/src/screens/composer-layout.ts index e70ca695..e3f5d9cc 100644 --- a/apps/web/src/screens/composer-layout.ts +++ b/apps/web/src/screens/composer-layout.ts @@ -1,7 +1,3 @@ -export function shouldExpandComposer( - draft: string, - hasAttachments: boolean, - hasReply: boolean, -): boolean { - return draft.includes("\n") || hasAttachments || hasReply; +export function shouldExpandComposer(draft: string, hasAttachments: boolean): boolean { + return draft.includes("\n") || hasAttachments; } diff --git a/apps/web/src/screens/openbot-app.tsx b/apps/web/src/screens/openbot-app.tsx index f7242831..8c3e6136 100644 --- a/apps/web/src/screens/openbot-app.tsx +++ b/apps/web/src/screens/openbot-app.tsx @@ -1,24 +1,16 @@ +import { type FormEvent, useEffect, useCallback, useMemo, useRef, useState } from "react"; import { - type FormEvent, - type ReactNode, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - type AttachmentCompletion, type ChatAgent, - type ChatMessage, - type CapabilityChangeApproval, - decideCapabilityChange, - getCapabilityChange, + type ConnectorSetupRequest, connectorAuthorizedReturnUrl, - type ConnectorProvider, - type CreateConnectorAccountResult, + isChatFindShortcut, + projectChatTranscript, + layoutChatTranscript, + promptStatus, + queuedTurnText, + userSessionForAgent, errorMessage, - waitForConnectorAccountActive, + createChatConnectorRuntime, latestMessagePreview, messageText, type QueuedTurn, @@ -27,29 +19,24 @@ import { import { useNavigate, useSearch } from "@tanstack/react-router"; import { useStore } from "zustand"; import { - ActivityQueue, AddAgentDialog, AgentSetupDialog, AgentWorkspacePanel, - ChatComposer, + ChatPrompt, + TranscriptTimeSeparator, + ChatMessage as ChatMessageView, + useChatFindHighlight, + ChatEventSurface, + SessionParticipantsDialog, ChatHeader, ChatPane, - type ConnectorCredentialSourceView, type ConnectorPartActions, type ConnectorSelectionView, - ConnectorSetupDialog, - type ConnectorSetupSubmit, + ChatConnectorDialog, ConversationSkeleton, ConversationSurface, - ConversationMessage, - MarkdownText, MessageContent, - type MessagePart, - splitMessageSegments, - ToolsBlock, - ScrollToLatestButton, ThinkingIndicator, - ThreadOverlay, WorkspaceSidebar, type WorkspaceSearchResult, WorkspaceShell, @@ -57,8 +44,7 @@ import { } from "@tryopenbot/ui"; import type { WorkspaceSearch } from "../router.js"; import { AgentDetailsContainer } from "./agent-details.js"; -import { openBotRuntime } from "../runtime.js"; -import { optimisticParts, type PendingFile, uploadAttachments } from "../web-attachments.js"; +import { openBotRuntime, registerPromptFiles } from "../runtime.js"; import { useClientWorkspace } from "../workspaces.js"; import { shouldExpandComposer } from "./composer-layout.js"; import { rankWorkspaceSearchHits, searchHitId } from "./search-results.js"; @@ -73,6 +59,7 @@ export function OpenBotApp() { const conversation = useStore(openBotRuntime.store, (state) => state.conversation); const agentSetup = useStore(openBotRuntime.store, (state) => state.agentSetup); const chatSearch = useStore(openBotRuntime.store, (state) => state.search); + const sessionHeader = useStore(openBotRuntime.session.store); const { agents, nextAgentToken, selectedAgentId: agentId, loading } = sidebar; const { selectedSessionId: sessionId, @@ -86,31 +73,70 @@ export function OpenBotApp() { turnStatus, error, } = conversation; - const [draft, setDraft] = useState(""); - const [files, setFiles] = useState([]); + const promptState = useStore(openBotRuntime.prompt.store); + const draft = promptState.draft; + const setDraft = openBotRuntime.prompt.setDraft; + const files = promptState.attachments; const [dragging, setDragging] = useState(false); const [search, setSearch] = useState(""); const [searchOpen, setSearchOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const [messageMenuId, setMessageMenuId] = useState(""); - const [replyingTo, setReplyingTo] = useState(null); - const [threadRootId, setThreadRootId] = useState(""); // Modal open-state lives in the URL so redirects and deep links can target // it directly; see WorkspaceSearch in router.tsx. const workspaceSearch = useSearch({ strict: false }) as WorkspaceSearch; const createAgentOpen = workspaceSearch.dialog === "new-agent"; - const [connectorSetup, setConnectorSetup] = useState(null); - const connectorWatchRef = useRef(null); + const chatConnectors = useMemo( + () => + createChatConnectorRuntime(openBotRuntime.client, { + openAuthorization: (url) => { + window.open(url, "_blank", "noopener"); + }, + returnUrl: () => + connectorAuthorizedReturnUrl( + window.location.origin, + navigator.userAgent.includes("Electron") ? "electron" : "web", + ), + saveSecretOutputs: (outputs) => { + const url = URL.createObjectURL( + new Blob([JSON.stringify(outputs, null, 2)], { type: "application/json" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "provider-credentials.json"; + link.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + }, + onSetupComplete: async (request) => { + setConnectorRoute(undefined); + await openBotRuntime.actions.sendMessage({ + text: `Credential setup completed for ${request.provider_name}. tool_group_instance_id=${request.resource_id}${request.target ? `; target_mcp_server_instance_id=${request.target.mcp_server_instance_id}; target_kind=${request.target.kind}` : ""}. Verify the account and finish enabling only the required functions on the chosen target, then continue the task.`, + }); + }, + onComplete: async (accountId, mcpServerId) => { + setConnectorRoute(undefined); + await openBotRuntime.actions.sendMessage({ + text: `Connector enabled for my tools. tool_group_instance_id=${accountId}; mcp_server_instance_id=${mcpServerId}. Continue the original task using this user tools MCP, not your agent-owned MCP.`, + }); + }, + }), + [], + ); + const connectorState = useStore(chatConnectors.store); + const connectorSetupState = useStore(chatConnectors.setup.store); + useEffect(() => () => chatConnectors.dispose(), [chatConnectors]); + useEffect(() => { + chatConnectors.close(); + }, [chatConnectors, sessionId, auth.session?.user.subject]); + const pendingConnectorRequestRef = useRef(null); const pendingConnectorSelectionRef = useRef(null); const conversationRef = useRef(null); const fileInputRef = useRef(null); - const composerInputRef = useRef(null); + const composerInputRef = useRef(null); const scrollSnapshotsRef = useRef>(readScrollSnapshots()); const restoredSessionRef = useRef(""); const stickToBottomRef = useRef(true); const previousMessageIdRef = useRef(""); const [showScrollLatest, setShowScrollLatest] = useState(false); - const electron = navigator.userAgent.includes("Electron"); const layout = useWorkspaceLayout({ floatingWorkspace: true }); const navigate = useNavigate(); const setCreateAgentOpen = (open: boolean): void => { @@ -148,15 +174,7 @@ export function OpenBotApp() { const clientWorkspace = useClientWorkspace(); const selectedAgent = agents.find((agent) => agent.id === agentId); - const hasContent = Boolean(draft.trim() || files.length); - const composerExpanded = shouldExpandComposer(draft, files.length > 0, Boolean(replyingTo)); - - useLayoutEffect(() => { - const input = composerInputRef.current; - if (!input) return; - input.style.height = "0px"; - input.style.height = `${Math.min(100, Math.max(28, input.scrollHeight))}px`; - }, [draft]); + const composerExpanded = shouldExpandComposer(draft, files.length > 0); useEffect(() => { const element = conversationRef.current; @@ -194,7 +212,6 @@ export function OpenBotApp() { () => messages.filter((message) => !queuedMessageIds.has(message.id)), [messages, queuedMessageIds], ); - const threadRoot = visibleMessages.find((message) => message.id === threadRootId); const sidebarChats = useMemo(() => { const chats = agents.flatMap((agent) => { @@ -253,8 +270,6 @@ export function OpenBotApp() { const session = agent.sessions.items.find((candidate) => candidate.id === selectedSessionId); if (!session) continue; clearFiles(); - setReplyingTo(null); - setThreadRootId(""); restoredSessionRef.current = ""; void openBotRuntime.actions.selectSession(agent.id, session); return; @@ -299,65 +314,14 @@ export function OpenBotApp() { function selectAgent(agent: ChatAgent): void { clearFiles(); - setReplyingTo(null); - setThreadRootId(""); restoredSessionRef.current = ""; void openBotRuntime.actions.selectAgent(agent.id); } - async function send(event: FormEvent): Promise { + async function send(event: FormEvent): Promise { event.preventDefault(); - const authoredText = draft.trim(); - if (!hasContent || !agentId || (submitting && !agentBusy)) return; - const text = replyingTo - ? `> ${messageText(replyingTo).replaceAll("\n", "\n> ")}\n\n${authoredText}`.trim() - : authoredText; - const outgoingFiles = files; - let activeSessionId = sessionId; - try { - if (outgoingFiles.length > 0) - activeSessionId = await openBotRuntime.actions.ensureSession( - titleFrom(text, outgoingFiles), - ); - setDraft(""); - setReplyingTo(null); - setThreadRootId(""); - clearFiles(); - - const attachmentIds: string[] = []; - const attachmentCompletions: AttachmentCompletion[] = []; - if (outgoingFiles.length > 0 && activeSessionId) { - for (const pending of outgoingFiles) - setFiles((current) => [...current, { ...pending, status: "uploading", progress: 0 }]); - const uploaded = await uploadAttachments( - openBotRuntime.client, - activeSessionId, - outgoingFiles.map((pending) => pending.file), - (index, progress) => setFileState(outgoingFiles[index]!.id, { progress }), - ); - for (const [index, result] of uploaded.entries()) { - const pending = outgoingFiles[index]!; - attachmentIds.push(result.attachment.id); - attachmentCompletions.push(result.completion); - setFileState(pending.id, { - status: "uploaded", - progress: 1, - attachmentId: result.attachment.id, - }); - } - } - - await openBotRuntime.actions.sendMessage({ - text, - attachmentIds, - attachmentCompletions, - optimisticParts: optimisticParts(text, outgoingFiles), - title: titleFrom(text, outgoingFiles), - }); - clearFiles(); - } catch (reason) { - openBotRuntime.actions.setError(errorMessage(reason)); - } + if (!agentId || (submitting && !agentBusy)) return; + await openBotRuntime.prompt.send(); } async function stop(): Promise { @@ -369,6 +333,25 @@ export function OpenBotApp() { } } + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!isChatFindShortcut(event) || !sessionId || document.querySelector('[role="dialog"]')) + return; + event.preventDefault(); + openBotRuntime.session.openFind(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [sessionId]); + + useChatFindHighlight({ + rootRef: conversationRef, + query: sessionHeader.find.open ? sessionHeader.find.query : "", + messageId: sessionHeader.find.items[sessionHeader.find.activeIndex]?.message?.id, + focusNonce: sessionHeader.find.resultFocusNonce, + revision: messages, + }); + // Cmd/Ctrl+I and Cmd/Ctrl+L focus the prompt. useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -395,36 +378,10 @@ export function OpenBotApp() { }); function addFiles(incoming: FileList | File[]): void { - const additions = [...incoming].map((file) => ({ - id: crypto.randomUUID(), - file, - progress: 0, - status: "ready" as const, - ...(file.type.startsWith("image/") ? { previewUrl: URL.createObjectURL(file) } : {}), - })); - setFiles((current) => [...current, ...additions].slice(0, 10)); + registerPromptFiles(incoming); } - function clearFiles(): void { - setFiles((current) => { - for (const pending of current) { - if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl); - } - return []; - }); - } - - function setFileState(id: string, patch: Partial): void { - setFiles((current) => current.map((file) => (file.id === id ? { ...file, ...patch } : file))); - } - - async function removeFile(pending: PendingFile): Promise { - if (pending.attachmentId && sessionId) { - await openBotRuntime.client - .deleteAttachment(sessionId, pending.attachmentId) - .catch(() => undefined); - } - setFiles((current) => current.filter((file) => file.id !== pending.id)); + openBotRuntime.prompt.clearAttachments(); } async function loadOlderMessages(): Promise { @@ -488,27 +445,51 @@ export function OpenBotApp() { openBotRuntime.actions.clearSearch(); } + const resolveAttachmentUrl = useCallback( + (chatId: string, attachmentId: string) => + openBotRuntime.client.getAttachmentDownloadUrl(chatId, attachmentId), + [], + ); + const rewriteUrl = useCallback((url: string) => openBotRuntime.client.rewriteTildeUrl(url), []); const composer = ( - void openBotRuntime.session.join()} + queue={{ + items: queuedTurns.map((turn) => ({ + id: turn.id, + text: queuedTurnText(turn), + queuePosition: turn.queue_position, + pending: turn.id.startsWith("optimistic-queue-"), + })), + onEdit: (id) => { + const turn = queuedTurns.find((candidate) => candidate.id === id); + if (turn) void editQueuedTurn(turn); + }, + onReorder: (id, queuePosition) => + void mutateQueue(() => openBotRuntime.actions.reorderQueuedTurn(id, queuePosition)), + onRemove: (id) => void mutateQueue(() => openBotRuntime.actions.removeQueuedTurn(id)), + onRunNow: (id) => void mutateQueue(() => openBotRuntime.actions.steerQueuedTurn(id)), + }} + showScrollToBottom={showScrollLatest} + onScrollToBottom={scrollToLatest} + status={promptStatus({ + error: promptState.error || sessionHeader.joinError || error, + unreachable: conversation.streamStatus === "Reconnecting", + })} agentAvailable={Boolean(agentId)} busy={agentBusy} - submitting={submitting} + submitting={submitting || promptState.phase !== "idle"} dragging={dragging} expanded={composerExpanded} draft={draft} error={error} - reply={ - replyingTo - ? { - label: `Replying to ${replyingTo.role === "user" ? "yourself" : selectedAgent?.display_name || "agent"}`, - text: messageText(replyingTo) || "Message", - } - : undefined - } attachments={files.map((pending) => ({ id: pending.id, - name: pending.file.name, - size: pending.file.size, + name: pending.name, + size: pending.sizeBytes, + removable: promptState.phase !== "sending", progress: pending.progress, status: pending.status, error: pending.error, @@ -520,14 +501,7 @@ export function OpenBotApp() { onDraftChange={setDraft} onDragStateChange={setDragging} onFilesAdded={addFiles} - onRemoveAttachment={(id) => { - const pending = files.find((candidate) => candidate.id === id); - if (pending) void removeFile(pending); - }} - onCancelReply={() => { - setReplyingTo(null); - setThreadRootId(""); - }} + onRemoveAttachment={(id) => void openBotRuntime.prompt.removeAttachment(id)} onStop={() => void stop()} /> ); @@ -542,87 +516,38 @@ export function OpenBotApp() { } const connectorActions: ConnectorPartActions = { - busy: Boolean(connectorSetup?.submitting), - onSelectAccount: (selection, account) => { - void openBotRuntime.client - .bindConnector(agentId, account.id) - .catch((reason) => openBotRuntime.actions.setError(errorMessage(reason))); + onSetupRequired: (request) => { + pendingConnectorRequestRef.current = request; + setConnectorRoute(request.provider_type_id); + }, + busy: connectorSetupState.status === "submitting" || connectorState.binding, + onSelectAccount: (_selection, account) => { + void chatConnectors.selectAccount(account.id); }, onAddAccount: (selection) => { - // Route the modal open through the URL so back/close and redirects work. pendingConnectorSelectionRef.current = selection; setConnectorRoute(selection.providerTypeId); }, }; - const capabilityApprovalActions = { - loadCurrent: (approval: CapabilityChangeApproval) => getCapabilityChange("", approval.id), - onDecision: async ( - approval: CapabilityChangeApproval, - decision: "approve" | "reject", - ): Promise => { - let updated: CapabilityChangeApproval; - try { - updated = await decideCapabilityChange("", approval, decision); - } catch { - throw new Error("The capability decision could not be recorded. Please try again."); - } - try { - await openBotRuntime.actions.sendMessage({ - text: `Capability change ${decision === "approve" ? "approved" : "declined"} by the authenticated owner. proposal_id=${updated.id}. Continue the original task from this durable decision and use only server-provided setup continuations.`, - }); - } catch { - openBotRuntime.actions.setError( - "The capability decision was recorded, but the agent could not be resumed.", - ); - } - return updated; - }, - }; - function openConnectorSetup(selection: ConnectorSelectionView): void { - setConnectorSetup({ selection, loading: selection.credentialSources.length === 0 }); - if (selection.credentialSources.length > 0) return; - // Payloads opened by URL (or older tool outputs) carry no credential - // sources; recover them from the catalog. - void openBotRuntime.client - .listConnectorProviders() - .then((providers) => { - const provider = providers.find( - (candidate) => candidate.type_id === selection.providerTypeId, - ); - setConnectorSetup((current) => - current && current.selection === selection - ? { - ...current, - loading: false, - ...(provider - ? { - selection: { - ...selection, - providerName: provider.name, - ...(provider.icon_url ? { iconUrl: provider.icon_url } : {}), - credentialSources: credentialSourceViews(provider), - }, - } - : { error: `No connector catalog entry for ${selection.providerName}` }), - } - : current, - ); - }) - .catch((reason) => { - setConnectorSetup((current) => - current && current.selection === selection - ? { ...current, loading: false, error: errorMessage(reason) } - : current, - ); - }); - } - - function closeConnectorSetup(): void { - connectorWatchRef.current?.abort(); - connectorWatchRef.current = null; - setConnectorSetup(null); + const userId = auth.session?.user.subject; + if (!userId) return; + void chatConnectors.open( + { + provider_type_id: selection.providerTypeId, + provider_name: selection.providerName, + icon_url: selection.iconUrl, + target_user_id: selection.targetUserId, + accounts: selection.accounts.map((account) => ({ + id: account.id, + display_name: account.displayName, + status: account.status, + })), + }, + userId, + ); } + const closeConnectorSetup = chatConnectors.close; // The `?connector=` search param is the source of truth for the // setup modal, so OAuth returns and deep links can open it directly and the @@ -630,10 +555,17 @@ export function OpenBotApp() { useEffect(() => { const providerTypeId = workspaceSearch.connector; if (!providerTypeId) { - if (connectorSetup) closeConnectorSetup(); + if (connectorState.open) closeConnectorSetup(); + return; + } + if (connectorState.open && connectorState.selection?.provider_type_id === providerTypeId) + return; + const nativeRequest = pendingConnectorRequestRef.current; + pendingConnectorRequestRef.current = null; + if (nativeRequest && auth.session?.user.subject) { + void chatConnectors.openRequest(nativeRequest, auth.session.user.subject); return; } - if (connectorSetup?.selection.providerTypeId === providerTypeId) return; const pending = pendingConnectorSelectionRef.current; pendingConnectorSelectionRef.current = null; openConnectorSetup( @@ -649,59 +581,6 @@ export function OpenBotApp() { // eslint-disable-next-line react-hooks/exhaustive-deps -- the URL param drives this modal }, [workspaceSearch.connector]); - async function submitConnectorSetup(input: ConnectorSetupSubmit): Promise { - if (!connectorSetup) return; - const selection = connectorSetup.selection; - setConnectorSetup({ ...connectorSetup, submitting: true, error: undefined }); - try { - const result = await openBotRuntime.client.createConnectorAccount({ - providerTypeId: selection.providerTypeId, - credentialSourceTypeId: input.credentialSourceTypeId, - displayName: input.displayName, - ...(input.resourceServerValues ? { resourceServerValues: input.resourceServerValues } : {}), - ...(input.userCredentialValues ? { userCredentialValues: input.userCredentialValues } : {}), - returnUrl: connectorAuthorizedReturnUrl( - window.location.origin, - electron ? "electron" : "web", - ), - }); - if (result.status === "authorize" && result.authorization_url) { - window.open(result.authorization_url, "_blank", "noopener"); - setConnectorSetup({ - selection, - submitting: false, - result, - authorizationUrl: result.authorization_url, - }); - // Close the loop without a manual "Done": once Tilde flips the account - // to active after the OAuth return, hand back to the agent directly. - const watcher = new AbortController(); - connectorWatchRef.current?.abort(); - connectorWatchRef.current = watcher; - void waitForConnectorAccountActive(openBotRuntime.client, { - providerTypeId: selection.providerTypeId, - accountId: result.account.id, - signal: watcher.signal, - }).then((account) => { - if (!account || watcher.signal.aborted) return; - void finishConnectorSetup({ ...result, status: "created", account }); - }); - return; - } - await finishConnectorSetup(result); - } catch (reason) { - setConnectorSetup((current) => - current ? { ...current, submitting: false, error: errorMessage(reason) } : current, - ); - } - } - - async function finishConnectorSetup(result: CreateConnectorAccountResult): Promise { - await openBotRuntime.client.bindConnector(agentId, result.account.id); - closeConnectorSetup(); - setConnectorRoute(undefined); - } - return ( void openBotRuntime.session.openParticipants() : undefined + } + find={ + sessionHeader.find.open + ? { + query: sessionHeader.find.query, + loading: sessionHeader.find.status === "loading", + error: sessionHeader.find.error, + matchCount: sessionHeader.find.items.length, + currentOrdinal: sessionHeader.find.activeIndex + 1, + focusNonce: sessionHeader.find.focusNonce, + onQueryChange: openBotRuntime.session.setFindQuery, + onStepNext: () => void openBotRuntime.session.stepFind(1), + onStepPrevious: () => void openBotRuntime.session.stepFind(-1), + onClose: openBotRuntime.session.closeFind, + } + : undefined + } busy={Boolean(selectedAgent && agentBusy)} computerOpen={layout.workspaceOpen} onOpenSidebar={() => setMobileSidebarOpen(true)} @@ -787,170 +714,56 @@ export function OpenBotApp() { Load earlier messages ) : null} - {(() => { - const rendered: ReactNode[] = []; - // An agent run (reasoning + tool calls) that spans adjacent - // messages merges into one grouped tool-chips block. - let pendingRun: { key: string; parts: MessagePart[] } | null = null; - const flushRun = () => { - if (!pendingRun) return; - rendered.push( -
- -
, + {layoutChatTranscript( + projectChatTranscript(visibleMessages, participantEvents, { + viewerUserId: auth.session?.user.subject, + participants: sessionHeader.participants, + }), + ).map((item) => { + if (item.kind === "day") + return ; + if (item.kind === "participant") + return ( + + + {item.event.data.participant.display_name}{" "} + {item.event.type === "participant.joined" ? "joined" : "left"} + + ); - pendingRun = null; - }; - const resolveAttachmentUrl = (sessionKey: string, attachmentId: string) => - openBotRuntime.client.getAttachmentDownloadUrl(sessionKey, attachmentId); - const rewriteUrl = (value: string) => - openBotRuntime.client.rewriteTildeUrl(value); - let participantEventIndex = 0; - const renderParticipantEventsBefore = (timestamp: number) => { - while (participantEventIndex < participantEvents.length) { - const event = participantEvents[participantEventIndex]; - if (!event || Date.parse(event.occurred_at) > timestamp) break; - flushRun(); - const participant = event.data.participant; - const name = participant.display_name || participant.participant_handle; - rendered.push( -
- {name}{" "} - {event.type === "participant.joined" ? "joined" : "left"} -
, - ); - participantEventIndex += 1; - } - }; - - visibleMessages.forEach((message, index) => { - renderParticipantEventsBefore(Date.parse(message.created_at)); - const previous = visibleMessages[index - 1]; - const next = visibleMessages[index + 1]; - const continuedPrevious = previous?.role === message.role; - const continuedNext = next?.role === message.role; - const parts = message.parts ?? []; - const messageActions = { - menuOpen: messageMenuId === message.id, - onReply: () => { - setReplyingTo(message); - composerInputRef.current?.focus(); - }, - onToggleMenu: () => { - setMessageMenuId((current) => (current === message.id ? "" : message.id)); - }, - onStartThread: () => { - setThreadRootId(message.id); - setReplyingTo(message); - setMessageMenuId(""); - composerInputRef.current?.focus(); - }, - onCopy: () => { - void navigator.clipboard.writeText(messageText(message)); - setMessageMenuId(""); - }, - }; - - // Messages split into standalone blocks: text in bubbles; - // agent runs and attachments as their own rows. - const segments = parts.length > 0 ? splitMessageSegments(parts) : []; - if (segments.length === 0) { - flushRun(); - rendered.push( - - - , - ); - return; - } - - const lastText = segments.reduce( - (last, segment, at) => (segment.kind === "text" ? at : last), - -1, - ); - segments.forEach((segment, at) => { - const key = `${message.id}:${at}`; - if (segment.kind === "run") { - if (pendingRun && message.role !== "user") { - pendingRun.parts.push(...segment.parts); - } else { - flushRun(); - pendingRun = { key, parts: [...segment.parts] }; - } - return; + const message = item.message; + const content = ( + 0 ? segments[at - 1]?.kind === "text" : continuedPrevious - } - continuedNext={ - at < segments.length - 1 - ? segments[at + 1]?.kind === "text" - : continuedNext - } - {...(at === lastText ? messageActions : {})} - > - - , - ); - return; - } - if (segment.kind === "files") { - rendered.push( - - - , - ); - return; + resolveAttachmentUrl={resolveAttachmentUrl} + rewriteUrl={rewriteUrl} + /> + ); + if (item.kind === "event") + return ( + + {content} + + ); + return ( + - - , - ); - }); - }); - renderParticipantEventsBefore(Number.POSITIVE_INFINITY); - flushRun(); - return rendered; - })()} + resolveAttachmentUrl={resolveAttachmentUrl} + rewriteUrl={rewriteUrl} + /> + ); + })} {agentBusy ? ( {turnStatus || `${selectedAgent?.display_name || "Agent"} is working…`} @@ -960,59 +773,7 @@ export function OpenBotApp() { ) : null} ) : null} - {selectedAgent && showScrollLatest ? ( - - ) : null} - {selectedAgent && !threadRoot ? ( - <> - ({ - id: turn.id, - text: queuedTurnText(turn), - queuePosition: turn.queue_position, - pending: turn.id.startsWith("optimistic-queue-"), - }))} - onEdit={(id) => { - const turn = queuedTurns.find((candidate) => candidate.id === id); - if (turn) void editQueuedTurn(turn); - }} - onReorder={(id, queuePosition) => - void mutateQueue(() => openBotRuntime.actions.reorderQueuedTurn(id, queuePosition)) - } - onRemove={(id) => void mutateQueue(() => openBotRuntime.actions.removeQueuedTurn(id))} - onRunNow={(id) => void mutateQueue(() => openBotRuntime.actions.steerQueuedTurn(id))} - /> - {composer} - - ) : null} - {selectedAgent ? ( - { - setThreadRootId(""); - setReplyingTo(null); - }} - open={Boolean(threadRoot)} - > - {threadRoot ? ( -
- - - openBotRuntime.client.getAttachmentDownloadUrl( - selectedSessionId, - attachmentId, - ) - } - rewriteUrl={(value) => openBotRuntime.client.rewriteTildeUrl(value)} - /> - -
- ) : null} -
- ) : null} + {selectedAgent ? composer : null} - {connectorSetup && !connectorSetup.loading ? ( - void submitConnectorSetup(input)} - onReopenAuthorization={() => { - if (connectorSetup.authorizationUrl) - window.open(connectorSetup.authorizationUrl, "_blank", "noopener"); - }} - onClose={() => { - if (connectorSetup.result && connectorSetup.authorizationUrl) { - void finishConnectorSetup(connectorSetup.result); - return; - } - setConnectorRoute(undefined); - }} - /> - ) : null} + void chatConnectors.downloadOutputs()} + onClose={() => { + chatConnectors.close(); + setConnectorRoute(undefined); + }} + onAddAccount={chatConnectors.addAccount} + onSelectAccount={(id) => void chatConnectors.selectAccount(id)} + onSelectTarget={(id) => void chatConnectors.selectTarget(id)} + onSubmit={(input) => void chatConnectors.submit(input)} + onResume={(input) => void chatConnectors.resume(input)} + onReopenAuthorization={chatConnectors.reopenAuthorization} + /> + void openBotRuntime.session.searchParticipants(query)} + onClose={openBotRuntime.session.closeParticipants} + onAdd={(candidate) => void openBotRuntime.session.addParticipant(candidate)} + onRemove={(id) => void openBotRuntime.session.removeParticipant(id)} + /> ({ id: agent.id, @@ -1107,56 +872,3 @@ function saveScrollSnapshot( const recent = Object.fromEntries(Object.entries(snapshotsRef.current).slice(-50)); localStorage.setItem(SCROLL_STORAGE_KEY, JSON.stringify(recent)); } - -function titleFrom(text: string, files: PendingFile[]): string { - const value = text || files[0]?.file.name || "New chat"; - return value.length > 80 ? `${value.slice(0, 77)}...` : value; -} - -interface ConnectorSetupState { - selection: ConnectorSelectionView; - loading?: boolean; - submitting?: boolean; - error?: string | undefined; - authorizationUrl?: string; - result?: CreateConnectorAccountResult; -} - -/** Map the runtime's wire-shaped provider onto the UI's credential-source view. */ -function credentialSourceViews(provider: ConnectorProvider): ConnectorCredentialSourceView[] { - return provider.credential_sources.map((source) => ({ - typeId: source.type_id, - name: source.name, - ...(source.documentation ? { documentation: source.documentation } : {}), - requiresBrokering: source.requires_brokering, - supportsAutoDisplayName: source.supports_auto_display_name ?? false, - ...(source.display_name_description - ? { displayNameDescription: source.display_name_description } - : {}), - resourceServerSchema: source.resource_server_schema, - userCredentialSchema: source.user_credential_schema, - })); -} - -function queuedTurnText(turn: QueuedTurn): string { - const messages = turn.chat_request.messages; - if (!Array.isArray(messages)) return "Queued agent turn"; - const latest = messages.filter((message) => record(message).role === "user").at(-1); - return unknownText(record(latest).content ?? record(latest).parts) || "Queued agent turn"; -} - -function unknownText(value: unknown): string { - if (typeof value === "string") return value; - if (Array.isArray(value)) return value.map(unknownText).filter(Boolean).join("\n"); - if (typeof value !== "object" || value === null) return ""; - const item = record(value); - if (typeof item.text === "string") return item.text; - const nested = item.content ?? item.parts; - return nested === undefined ? "" : unknownText(nested); -} - -function record(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} diff --git a/apps/web/src/screens/settings-app.tsx b/apps/web/src/screens/settings-app.tsx index 78bb057a..6edded98 100644 --- a/apps/web/src/screens/settings-app.tsx +++ b/apps/web/src/screens/settings-app.tsx @@ -1,130 +1,35 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; -import { motion } from "motion/react"; import { useStore } from "zustand"; import { errorMessage, type SignalInstance } from "@tryopenbot/client-runtime"; import { connectorAuthorizedReturnUrl, - waitForConnectorAccountActive, + createChatConnectorRuntime, type ChatAgent, - type PluginsCatalog as PluginsCatalogSnapshot, } from "@tryopenbot/client-runtime"; import { - BackIcon, BotSelectionDialog, - ClockIcon, - ConnectorSetupDialog, + PluginsSettingsView, + ChatConnectorDialog, Dialog, DialogContent, DialogTitle, + AppearanceSettings, + SettingsShell, + SettingsContent, + type SettingsSection, getThemePreference, - MenuIcon, - PluginsIcon, - PluginsCatalog, RoutineEditor, RoutineProvidersSettings, RoutineSettings, - resolvePluginIconUrl, - Sheet, - SheetContent, - SheetTitle, - SettingsIcon, setThemePreference, - type ConnectorSetupSubmit, - SignalsIcon, type ThemePreference, } from "@tryopenbot/ui"; import { openBotRuntime } from "../runtime.js"; import { SignalConnectContainer } from "./agent-details.js"; -const settingsSections = [ - { id: "general", label: "General", icon: SettingsIcon, to: "/settings/general" }, -] as const; - -const pluginSections = [ - { id: "tools", label: "Tools", icon: PluginsIcon, to: "/settings/plugins/tools" }, - { id: "skills", label: "Skills", icon: SignalsIcon, to: "/settings/plugins/skills" }, - { id: "routines", label: "Routines", icon: ClockIcon, to: "/settings/plugins/routines" }, -] as const; - -const sections = [...settingsSections, ...pluginSections] as const; - -const themeOptions: readonly { value: ThemePreference; label: string }[] = [ - { value: "system", label: "System" }, - { value: "light", label: "Light" }, - { value: "dark", label: "Dark" }, -]; - -const pageTransition = { duration: 0.18, ease: [0.23, 1, 0.32, 1] } as const; - export interface SettingsAppProps { - section?: (typeof sections)[number]["id"]; -} - -interface SettingsContentProps { - children: ReactNode; - width: "constrained" | "wide"; -} - -function SettingsContent({ children, width }: SettingsContentProps) { - const maxWidth = width === "wide" ? "max-w-[1280px]" : "max-w-[640px]"; - return ( -
- {children} -
- ); -} - -function assignmentIds(current: readonly string[], agentId: string, enabled: boolean): string[] { - return enabled ? [...new Set([...current, agentId])] : current.filter((id) => id !== agentId); -} - -function updateToolAssignment( - catalog: PluginsCatalogSnapshot, - accountId: string, - agentId: string, - enabled: boolean, -): PluginsCatalogSnapshot { - return { - ...catalog, - tools: catalog.tools.map((entry) => ({ - ...entry, - accounts: entry.accounts.map((account) => - account.id === accountId - ? { - ...account, - assigned_agent_ids: assignmentIds(account.assigned_agent_ids, agentId, enabled), - } - : account, - ), - })), - }; -} - -function updateSkillAssignment( - catalog: PluginsCatalogSnapshot, - skillId: string, - agentId: string, - enabled: boolean, -): PluginsCatalogSnapshot { - return { - ...catalog, - skills: catalog.skills.map((provider) => ({ - ...provider, - skills: provider.skills.map((skill) => - skill.id === skillId - ? { - ...skill, - assigned_agent_ids: assignmentIds(skill.assigned_agent_ids, agentId, enabled), - } - : skill, - ), - })), - }; + section?: SettingsSection; } function PluginsSettings({ @@ -134,257 +39,82 @@ function PluginsSettings({ agents: readonly ChatAgent[]; kind: "tools" | "skills"; }) { - const [catalog, setCatalog] = useState({ tools: [], skills: [] }); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [setup, setSetup] = useState<{ - providerId: string; - submitting: boolean; - error?: string; - authorizationUrl?: string; - } | null>(null); - const [createdAccountId, setCreatedAccountId] = useState(null); - const [createdAccountPendingAgentId, setCreatedAccountPendingAgentId] = useState( - null, + const plugins = openBotRuntime.plugins; + const state = useStore(plugins.store); + const connection = useMemo( + () => + createChatConnectorRuntime(openBotRuntime.client, { + openAuthorization: (url) => { + window.open(url, "_blank", "noopener"); + }, + returnUrl: () => + connectorAuthorizedReturnUrl( + window.location.origin, + navigator.userAgent.includes("Electron") ? "electron" : "web", + ), + onProviderComplete: (id) => { + if (id) plugins.accountCreated(id); + else void plugins.refresh(); + }, + saveSecretOutputs: (outputs) => { + const url = URL.createObjectURL( + new Blob([JSON.stringify(outputs, null, 2)], { type: "application/json" }), + ); + const link = document.createElement("a"); + link.href = url; + link.download = "provider-credentials.json"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }, + }), + [plugins], ); - const setupWatcher = useRef(null); - const agentIds = useMemo(() => agents.map((agent) => agent.id), [agents]); - const agentIdsKey = agentIds.join("\0"); - - async function refresh(): Promise { - setError(""); - try { - setCatalog(await openBotRuntime.client.getPluginsCatalog()); - } catch (reason) { - setError(reason instanceof Error ? reason.message : "Could not load plugins"); - } finally { - setLoading(false); - } - } - + const connectionState = useStore(connection.store); + const setupState = useStore(connection.setup.store); + const agentIdsKey = agents.map((agent) => agent.id).join("\0"); useEffect(() => { - setLoading(true); - void refresh(); - // Bot identity, not array identity, controls the remote snapshot. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [agentIdsKey]); - - useEffect( - () => () => { - setupWatcher.current?.abort(); - }, - [], - ); - - function closeSetup(): void { - setupWatcher.current?.abort(); - setupWatcher.current = null; - setSetup(null); - } - - async function mutateToolAssignment( - accountId: string, - agentId: string, - enabled: boolean, - ): Promise { - const previouslyEnabled = catalog.tools.some((entry) => - entry.accounts.some( - (account) => account.id === accountId && account.assigned_agent_ids.includes(agentId), - ), - ); - setError(""); - setCatalog((current) => updateToolAssignment(current, accountId, agentId, enabled)); - try { - await openBotRuntime.client.setToolAccountForAgent(accountId, agentId, enabled); - return true; - } catch (reason) { - setCatalog((current) => updateToolAssignment(current, accountId, agentId, previouslyEnabled)); - setError(reason instanceof Error ? reason.message : "Could not update tool"); - return false; - } - } - - async function updateTool(accountId: string, agentId: string, enabled: boolean): Promise { - await mutateToolAssignment(accountId, agentId, enabled); - } - - async function deleteToolAccounts(accountIds: readonly string[]): Promise { - setError(""); - try { - await openBotRuntime.client.deleteConnectorAccounts(accountIds); - setCatalog((current) => ({ - ...current, - tools: current.tools.map((entry) => ({ - ...entry, - accounts: entry.accounts.filter((account) => !accountIds.includes(account.id)), - })), - })); - } catch (reason) { - await refresh(); - const message = reason instanceof Error ? reason.message : "Could not delete tool account"; - setError(message); - throw new Error(message); - } - } - - async function assignCreatedAccount(agentId: string): Promise { - if (!createdAccountId || createdAccountPendingAgentId) return; - setCreatedAccountPendingAgentId(agentId); - const added = await mutateToolAssignment(createdAccountId, agentId, true); - setCreatedAccountPendingAgentId(null); - if (added) setCreatedAccountId(null); - } - - async function updateSkill(skillId: string, agentId: string, enabled: boolean) { - const previouslyEnabled = catalog.skills.some((provider) => - provider.skills.some( - (skill) => skill.id === skillId && skill.assigned_agent_ids.includes(agentId), - ), - ); - setError(""); - setCatalog((current) => updateSkillAssignment(current, skillId, agentId, enabled)); - try { - await openBotRuntime.client.setSkillForAgent(skillId, agentId, enabled); - } catch (reason) { - setCatalog((current) => updateSkillAssignment(current, skillId, agentId, previouslyEnabled)); - setError(reason instanceof Error ? reason.message : "Could not update skill"); - } - } - - async function submitSetup(input: ConnectorSetupSubmit): Promise { - if (!setup) return; - const current = setup; - setSetup({ ...current, submitting: true, error: undefined }); - try { - const result = await openBotRuntime.client.createConnectorAccount({ - providerTypeId: current.providerId, - credentialSourceTypeId: input.credentialSourceTypeId, - displayName: input.displayName, - ...(input.resourceServerValues ? { resourceServerValues: input.resourceServerValues } : {}), - ...(input.userCredentialValues ? { userCredentialValues: input.userCredentialValues } : {}), - returnUrl: connectorAuthorizedReturnUrl( - window.location.origin, - navigator.userAgent.includes("Electron") ? "electron" : "web", - ), - }); - if (result.status === "authorize" && result.authorization_url) { - window.open(result.authorization_url, "_blank", "noopener"); - setSetup({ ...current, submitting: false, authorizationUrl: result.authorization_url }); - setupWatcher.current?.abort(); - const watcher = new AbortController(); - setupWatcher.current = watcher; - const active = await waitForConnectorAccountActive(openBotRuntime.client, { - providerTypeId: current.providerId, - accountId: result.account.id, - signal: watcher.signal, - }); - if (!active) return; - } - closeSetup(); - setCreatedAccountId(result.account.id); - void refresh(); - } catch (reason) { - setSetup((value) => - value - ? { - ...value, - submitting: false, - error: reason instanceof Error ? reason.message : "Could not add account", - } - : value, - ); - } - } - - const setupProvider = setup - ? catalog.tools.find(({ provider }) => provider.type_id === setup.providerId)?.provider - : undefined; - const setupProviderIconUrl = setupProvider - ? resolvePluginIconUrl( - setupProvider.icon_url, - setupProvider.icon_slug, - setupProvider.type_id, - setupProvider.name, - ) - : undefined; - + void plugins.refresh(true); + }, [plugins, agentIdsKey]); + useEffect(() => () => connection.dispose(), [connection]); return ( <> - ({ id: agent.id, name: agent.display_name }))} + void plugins.setScope(scope)} + agents={agents} kind={kind} - toolProviders={catalog.tools - .filter(({ provider }) => !provider.categories?.includes("system")) - .map(({ provider, accounts }) => ({ - id: provider.type_id, - name: provider.name, - description: provider.documentation ?? "", - categories: provider.categories ?? [], - ...(provider.icon_url ? { iconUrl: provider.icon_url } : {}), - ...(provider.icon_slug ? { iconKey: provider.icon_slug } : {}), - canAddAccount: provider.can_add_account ?? true, - accounts: accounts.map((account) => ({ - id: account.id, - accountName: account.display_name, - assignedAgentIds: account.assigned_agent_ids, - })), - }))} - skillProviders={catalog.skills.map((provider) => ({ - id: provider.id, - name: provider.name, - description: provider.description, - categories: provider.categories, - ...(provider.icon_url ? { iconUrl: provider.icon_url } : {}), - ...(provider.icon_key ? { iconKey: provider.icon_key } : {}), - skills: provider.skills.map((skill) => ({ - id: skill.id, - name: skill.name, - description: skill.description, - assignedAgentIds: skill.assigned_agent_ids, - })), - }))} - loading={loading} - {...(error ? { error } : {})} - onAddToolAccount={(providerId) => setSetup({ providerId, submitting: false })} - onDeleteToolAccounts={deleteToolAccounts} - onSetSkill={updateSkill} - onSetToolAccount={updateTool} - /> - {setup && setupProvider ? ( - ({ - typeId: source.type_id, - name: source.name, - requiresBrokering: source.requires_brokering, - ...(source.documentation ? { documentation: source.documentation } : {}), - supportsAutoDisplayName: source.supports_auto_display_name ?? false, - ...(source.display_name_description - ? { displayNameDescription: source.display_name_description } - : {}), - resourceServerSchema: source.resource_server_schema, - userCredentialSchema: source.user_credential_schema, - }))} - submitting={setup.submitting} - {...(setup.error ? { error: setup.error } : {})} - {...(setup.authorizationUrl ? { authorizationUrl: setup.authorizationUrl } : {})} - onClose={closeSetup} - onReopenAuthorization={() => { - if (setup.authorizationUrl) window.open(setup.authorizationUrl, "_blank", "noopener"); - }} - onSubmit={(input) => void submitSetup(input)} - /> - ) : null} - ({ id: agent.id, name: agent.display_name }))} - onClose={() => { - if (!createdAccountPendingAgentId) setCreatedAccountId(null); + setup={null} + onAddToolAccount={(id) => { + const provider = state.catalog.tools.find( + (group) => group.provider.type_id === id, + )?.provider; + if (provider) connection.openProvider(provider); }} - onSelect={(agentId) => void assignCreatedAccount(agentId)} - open={createdAccountId !== null} - pendingAgentIds={createdAccountPendingAgentId ? [createdAccountPendingAgentId] : []} - title="Add account to bot" + onDeleteToolAccounts={plugins.deleteAccounts} + onSetToolAccount={async (...args) => { + await plugins.setTool(...args); + }} + onSetSkill={async (...args) => { + await plugins.setSkill(...args); + }} + onCloseSetup={connection.close} + onReopenAuthorization={connection.reopenAuthorization} + onSubmitSetup={(input) => { + const provider = connectionState.provider; + if (provider) void connection.submit({ ...input, providerTypeId: provider.type_id }); + }} + onCloseAssignment={plugins.dismissAssignment} + onAssignAccount={(id) => void plugins.assignCreatedAccount(id)} + /> + void connection.downloadOutputs()} + onSubmit={(input) => void connection.submit(input)} + onResume={(input) => void connection.resume(input)} + onReopenAuthorization={() => connection.reopenAuthorization()} /> ); @@ -393,184 +123,36 @@ function PluginsSettings({ export function SettingsApp({ section = "general" }: SettingsAppProps = {}) { const navigate = useNavigate(); const [theme, setTheme] = useState(() => getThemePreference()); - const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false); const agents = useStore(openBotRuntime.store, (state) => state.sidebar.agents); const macDesktop = window.openbotDesktop?.platform === "mac"; return ( - void navigate({ to: "/" })} + onNavigate={(to) => void navigate({ to })} > -