@openhands/agent-canvas 1.0.0-alpha.8 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/bin/agent-canvas.mjs +22 -2
- package/build/assets/{QueryClientProvider-B7kl84Kj.js → QueryClientProvider-CkGuhXg-.js} +1 -1
- package/build/assets/{Trans-1j65oy9O.js → Trans-Cvm_-SMi.js} +1 -1
- package/build/assets/acp-providers-CbiRekh9.js +1 -0
- package/build/assets/{acp-route-guard-CQTmeJwM.js → acp-route-guard-B2yoBZ_4.js} +1 -1
- package/build/assets/{active-backend-context-TVbjnvmP.js → active-backend-context-cCM1vYYZ.js} +1 -1
- package/build/assets/add-backend-modal-DIUQzMPa.js +1 -0
- package/build/assets/agent-server-client-options-Bc5ZorQZ.js +1 -0
- package/build/assets/agent-server-compatibility-BlkUsrX2.js +1 -0
- package/build/assets/agent-server-conversation-service.api-DFvqqEDo.js +5 -0
- package/build/assets/{agent-settings-B247S9G3.js → agent-settings-CnGSCmK8.js} +1 -1
- package/build/assets/{alert-banner-BWoqueRw.js → alert-banner-DtzAX654.js} +1 -1
- package/build/assets/{analytics-consent-form-modal-C7sXfxRh.js → analytics-consent-form-modal-CHZ3I37v.js} +1 -1
- package/build/assets/api-key-entry-screen-B2gynaCp.js +1 -0
- package/build/assets/{app-settings-BVeSaty9.js → app-settings-Db9ITeJH.js} +1 -1
- package/build/assets/{automation-detail-g5-RZ0da.js → automation-detail-Di7EOIZD.js} +1 -1
- package/build/assets/{automations-list-DHoq_0MM.js → automations-list-IsIWdDiw.js} +1 -1
- package/build/assets/backend-form-modal-Dnk33xA_.js +1 -0
- package/build/assets/{backend-synced-settings-badge-nAfiUWvM.js → backend-synced-settings-badge-Dc6c7GT4.js} +1 -1
- package/build/assets/{base-modal-CQRvRHu1.js → base-modal-_dYTw1ri.js} +1 -1
- package/build/assets/{brand-button-C2nEKopC.js → brand-button-Br7f0kZJ.js} +1 -1
- package/build/assets/browser-D810xUYt.js +5 -0
- package/build/assets/browser-store-Couc4S5D.js +1 -0
- package/build/assets/browser-tab-B-aIqXRl.js +1 -0
- package/build/assets/{checkmark-BJJrZUF8.js → checkmark-DL7acQA7.js} +1 -1
- package/build/assets/{chevron-left-small-CSh-sE9L.js → chevron-left-small-CVWf8TI6.js} +1 -1
- package/build/assets/{circle-plus-check-toggle-qs8Va1cC.js → circle-plus-check-toggle-P7ZZToV4.js} +1 -1
- package/build/assets/{clock-ZR4Kn-_Y.js → clock-BRjCgHTc.js} +1 -1
- package/build/assets/{close-BdmyeRqS.js → close-B5LROHR3.js} +1 -1
- package/build/assets/{combobox-caret-B53O9Hsq.js → combobox-caret-to1O8irE.js} +1 -1
- package/build/assets/{condenser-settings-A35V3yng.js → condenser-settings-wnEKhBof.js} +1 -1
- package/build/assets/{confirmation-modal-C9-La0h3.js → confirmation-modal-Dau3w_sa.js} +1 -1
- package/build/assets/{context-menu-list-item-Buu9nc0q.js → context-menu-list-item-CWNFpuiC.js} +1 -1
- package/build/assets/conversation-HlncOV7n.js +19 -0
- package/build/assets/conversation-MtnkpqA9.js +1 -0
- package/build/assets/conversation-panel-DxnM6tRe.js +1 -0
- package/build/assets/{conversation-service.api-C8pYCyV6.js → conversation-service.api-nb5W1PqR.js} +1 -1
- package/build/assets/{conversation-tab-empty-state-D8dNvo-V.js → conversation-tab-empty-state-DyssnnWa.js} +1 -1
- package/build/assets/conversation-websocket-context-C8_PkGLi.js +3 -0
- package/build/assets/{copy-C7Ti2d8C.js → copy-DYgmUdIw.js} +1 -1
- package/build/assets/{custom-toast-handlers-BOc3qeQ7.js → custom-toast-handlers-C-SZFmto.js} +1 -1
- package/build/assets/declaration-BNMqORFE.js +1 -0
- package/build/assets/{device-verify-CMusn8nX.js → device-verify-DqDlphsG.js} +1 -1
- package/build/assets/{dist-DZHSA2e6.js → dist-C6t0EXL7.js} +1 -1
- package/build/assets/{edit-automation-modal-Dnjxbjn7.js → edit-automation-modal-BGzR3nfZ.js} +1 -1
- package/build/assets/{ellipsis-button-ugUATsNo.js → ellipsis-button-ZyLMPURn.js} +1 -1
- package/build/assets/{entry.client-D9uR9Blz.js → entry.client-1VMHpktY.js} +2 -2
- package/build/assets/{enum-filter-dropdown-1vpOGySB.js → enum-filter-dropdown-CEgCdu4A.js} +1 -1
- package/build/assets/{environment-switch-overlay-CTCTQikP.js → environment-switch-overlay-XL8yCGP6.js} +1 -1
- package/build/assets/{extensions-hub-BSUseHVF.js → extensions-hub-C651jsVh.js} +1 -1
- package/build/assets/{extensions-navigation-CT1kc1u_.js → extensions-navigation-BYR8Giqq.js} +1 -1
- package/build/assets/files-tab-BhnLgimi.js +1 -0
- package/build/assets/{folder-0WSMImNX.js → folder-ZZJVGgd7.js} +1 -1
- package/build/assets/git-control-bar-branch-button-M34A5_vX.js +27 -0
- package/build/assets/{git-provider-icon-DYE9n7fs.js → git-provider-icon-D5dCNy-k.js} +1 -1
- package/build/assets/home-CYQv7yc_.js +1 -0
- package/build/assets/{i18n-DjAGhTis.js → i18n-CTohRuoO.js} +1 -1
- package/build/assets/install-server-modal-f31_CLrW.js +1 -0
- package/build/assets/{launch-hZ0ifhcV.js → launch-DHEUYn2A.js} +1 -1
- package/build/assets/{lesson-plan-DRYG5SLI.js → lesson-plan-dH5Bj0pN.js} +1 -1
- package/build/assets/{link-external-Df8J52xI.js → link-external-D2POYx4c.js} +1 -1
- package/build/assets/{llm-client-ChQzg4wX.js → llm-client-DaH1TuyR.js} +1 -1
- package/build/assets/llm-settings-Bql-vydt.js +1 -0
- package/build/assets/llm-settings-C_tal6Ds.js +1 -0
- package/build/assets/{loading-spinner-C04FGh14.js → loading-spinner-BPtYORNK.js} +1 -1
- package/build/assets/{manage-backends-modal-rYeyGx7j.js → manage-backends-modal-l7RkKfwX.js} +1 -1
- package/build/assets/{manage-workspaces-modal-C5EuW8m1.js → manage-workspaces-modal-DhKF_8z3.js} +1 -1
- package/build/assets/manifest-9fee01b9.js +1 -0
- package/build/assets/{markdown-renderer-CEX4Becj.js → markdown-renderer-DMzf2i4x.js} +1 -1
- package/build/assets/mcp-D2onbwVk.js +9 -0
- package/build/assets/{messages-T2ewVkbp.js → messages-BMzyOW2V.js} +1 -1
- package/build/assets/{modal-backdrop-DTYGVmOR.js → modal-backdrop-BAbgYsqB.js} +1 -1
- package/build/assets/{modal-body-YElmM1dV.js → modal-body-BI6Ru2Qr.js} +1 -1
- package/build/assets/{modal-close-button-C_GpQt9F.js → modal-close-button-t1Gh3qmL.js} +1 -1
- package/build/assets/{model-selector-DeMmw-Xa.js → model-selector-SM9IUz-q.js} +1 -1
- package/build/assets/{mutation-Cz7N4XAo.js → mutation-D0OogFCz.js} +1 -1
- package/build/assets/{navigation-context-DeIPtGPp.js → navigation-context-D0YWpT8d.js} +1 -1
- package/build/assets/{navigation-link-C9JD4PYD.js → navigation-link-Cn7KP3c5.js} +1 -1
- package/build/assets/{openhands-logo-CI5Fhn1W.js → openhands-logo-CnrF6LKb.js} +1 -1
- package/build/assets/{option-service.api-DsI1UW7N.js → option-service.api-KvY_mZMY.js} +1 -1
- package/build/assets/{organization-service.api-COwMPFg5.js → organization-service.api-DzYTHTYC.js} +1 -1
- package/build/assets/{path-utils-CqJboYxo.js → path-utils-YohAYyMv.js} +1 -1
- package/build/assets/{plan-components-DEjMuDDG.js → plan-components-atxXCF0R.js} +1 -1
- package/build/assets/{planner-tab-BrntFmb1.js → planner-tab-CFc-hV07.js} +1 -1
- package/build/assets/{profiles-client-BGkKEV9j.js → profiles-client-D6IkTJof.js} +1 -1
- package/build/assets/{providers-DXvCAN_u.js → providers-Bx6EfrzZ.js} +1 -1
- package/build/assets/{proxy-CurRmrqf.js → proxy-CxydCnis.js} +1 -1
- package/build/assets/{query-client-config-Ba7qAAoO.js → query-client-config-B7u9asM0.js} +1 -1
- package/build/assets/{recommended-automations-launcher-BI9NhG8Y.js → recommended-automations-launcher-sgvfU62c.js} +3 -3
- package/build/assets/root-BXWU99D-.js +2 -0
- package/build/assets/{root-layout-BLjAEgle.js → root-layout-DVepR4To.js} +2 -2
- package/build/assets/sdk-section-page-DOIKvwSL.js +1 -0
- package/build/assets/{sdk-settings-schema-QBYH-ONX.js → sdk-settings-schema-DsUf9wu1.js} +1 -1
- package/build/assets/{search-Cq_cFrDt.js → search-27Owlc3A.js} +1 -1
- package/build/assets/{secrets-service-Bwd5DeUs.js → secrets-service-BsnKFc2x.js} +1 -1
- package/build/assets/secrets-settings-Bz_UohPJ.js +1 -0
- package/build/assets/{server-client-C3mC8Hl3.js → server-client-DyAQ3NZ_.js} +1 -1
- package/build/assets/{settings-D7E2U5tK.js → settings-BYkVX7vW.js} +1 -1
- package/build/assets/{settings-client-CwjfwoiB.js → settings-client-C73C7IgV.js} +1 -1
- package/build/assets/{settings-dropdown-input-VwAXNrOb.js → settings-dropdown-input-BJYvGdg-.js} +1 -1
- package/build/assets/{settings-gear-BJwWR1ej.js → settings-gear-C77PgE_O.js} +1 -1
- package/build/assets/{settings-index-J-3BNR0W.js → settings-index-Dz0BmdJD.js} +1 -1
- package/build/assets/{settings-input-DBywAnA7.js → settings-input-Bn7F5C75.js} +1 -1
- package/build/assets/{settings-list-classes-BOS092DR.js → settings-list-classes-Bf80tWtc.js} +1 -1
- package/build/assets/{settings-modal-B8vgWDTe.js → settings-modal-Brzgh5Yw.js} +1 -1
- package/build/assets/{settings-section-header-context-32x6WTyL.js → settings-section-header-context-BgZe5YkE.js} +1 -1
- package/build/assets/{settings-service.api-FvJGK45W.js → settings-service.api-CZ3uWx4v.js} +1 -1
- package/build/assets/{settings-switch-DTKmHC8F.js → settings-switch-BeIKrWms.js} +1 -1
- package/build/assets/{shared-conversation-a0QV8o99.js → shared-conversation-DChOdb0t.js} +1 -1
- package/build/assets/{sidebar-mobile-menu-toggle-DTUNI1WQ.js → sidebar-mobile-menu-toggle-BWuf4PRH.js} +1 -1
- package/build/assets/{sidebar-nav-link-CnWoZcwc.js → sidebar-nav-link-BGjiJq-4.js} +1 -1
- package/build/assets/{skill-card-pill-row-tZ599jli.js → skill-card-pill-row-DF1axQCG.js} +1 -1
- package/build/assets/{skills-ZyAO5dyK.js → skills-ChIKZPK4.js} +1 -1
- package/build/assets/{skills-plugins-BSRz041I.js → skills-plugins-CcI_19lM.js} +1 -1
- package/build/assets/{skills-settings-DOnMn9q1.js → skills-settings-DlA5hlXw.js} +1 -1
- package/build/assets/{status-CsatcFbK.js → status-hp6M6E7E.js} +1 -1
- package/build/assets/{styled-tooltip-CS3mB_1X.js → styled-tooltip-CBzrri6o.js} +1 -1
- package/build/assets/{switch-skeleton-C-CfhYYV.js → switch-skeleton-DnC9wLp7.js} +1 -1
- package/build/assets/{task-list-tab-Day9nhRT.js → task-list-tab-DUJn1sgz.js} +1 -1
- package/build/assets/{terminal-ro4SNjUU.js → terminal-CRf9S0Z2.js} +1 -1
- package/build/assets/{terminal-LNa-iU5c.js → terminal-RmuaSdhJ.js} +1 -1
- package/build/assets/{toggle-switch-k-IZCDbt.js → toggle-switch-Pvyp2RAN.js} +1 -1
- package/build/assets/{typography-vVUMoNUg.js → typography-gpuWmrQO.js} +1 -1
- package/build/assets/{u-check-circle-DplbarS5.js → u-check-circle-IUIfACQQ.js} +1 -1
- package/build/assets/{u-check-circle-half-yDuiSZHC.js → u-check-circle-half-C1YxB6py.js} +1 -1
- package/build/assets/{u-circuit-C9tYkpeK.js → u-circuit-BmVikJHu.js} +1 -1
- package/build/assets/{u-edit-KAUlufD8.js → u-edit-CFvXHqZk.js} +1 -1
- package/build/assets/use-active-conversation-Db3IWSPK.js +1 -0
- package/build/assets/{use-agent-settings-schema-Bvp5UzV8.js → use-agent-settings-schema-33Un7UF2.js} +1 -1
- package/build/assets/{use-agent-state-DE5dlEXJ.js → use-agent-state-Bn8vS5sY.js} +1 -1
- package/build/assets/{use-cloud-current-user-id-DWVar4st.js → use-cloud-current-user-id-CvkXFnTT.js} +1 -1
- package/build/assets/use-config-Co1O8-Ey.js +1 -0
- package/build/assets/{use-create-conversation-DW7AGgLA.js → use-create-conversation-CKS3EAHu.js} +1 -1
- package/build/assets/{use-event-store-CQZCcVz-.js → use-event-store-BT_gV3ut.js} +1 -1
- package/build/assets/use-get-secrets-DuhdIA59.js +1 -0
- package/build/assets/{use-handle-plan-click-DpgEQDAV.js → use-handle-plan-click-C9zJpK8A.js} +1 -1
- package/build/assets/use-is-authed-BggE5wPj.js +1 -0
- package/build/assets/{use-is-creating-conversation-DhDeeWfA.js → use-is-creating-conversation-BZ5hB_Bg.js} +1 -1
- package/build/assets/{use-launch-skill-in-chat-DVGPFrbI.js → use-launch-skill-in-chat-fNN_xGZG.js} +1 -1
- package/build/assets/{use-llm-profiles-D3-KXwQ0.js → use-llm-profiles-DDOol3gK.js} +1 -1
- package/build/assets/use-runtime-is-ready-CQCE3xZC.js +1 -0
- package/build/assets/{use-save-settings-CEEKSTWG.js → use-save-settings-VUrj_QNG.js} +1 -1
- package/build/assets/{use-settings-DQ7Oo1Hj.js → use-settings-DQIZmIov.js} +1 -1
- package/build/assets/{use-settings-nav-items-YmrXrjn9.js → use-settings-nav-items-1ZvovKSr.js} +1 -1
- package/build/assets/use-skills-DAMLFjKU.js +1 -0
- package/build/assets/{use-task-list-Bs90uF2N.js → use-task-list-CLJbuJgM.js} +1 -1
- package/build/assets/use-unified-vscode-url-sZt29HrC.js +1 -0
- package/build/assets/use-user-conversation-DfgEB6RW.js +1 -0
- package/build/assets/{useMutation-B4OUESdw.js → useMutation-DqrumCWD.js} +1 -1
- package/build/assets/{useTranslation-CpIcQBq6.js → useTranslation-DCOdSSMl.js} +1 -1
- package/build/assets/{utils-D-HX7JCe.js → utils-i18rdUj2.js} +1 -1
- package/build/assets/v4-CNn21NXa.js +1 -0
- package/build/assets/{vendor~browser-Dr71AdrG.js → vendor~browser-BNjNhjFU.js} +1 -1
- package/build/assets/{vendor~browser-tab-BiVxfjJo.js → vendor~browser-tab-BgwV1mxF.js} +1 -1
- package/build/assets/{vendor~conversation-panel~conversation-BlCIz9XQ.js → vendor~conversation-panel~conversation-a9SyrrhV.js} +1 -1
- package/build/assets/{vendor~files-tab-DtLR-QD9.js → vendor~files-tab-BGKayPiK.js} +1 -1
- package/build/assets/{vendor~home~conversation-panel~conversation~shared-conversation~planner-tab~files-tab-Ds9quNZ9.js → vendor~home~conversation-panel~conversation~shared-conversation~planner-tab~files-tab-smY2r837.js} +1 -1
- package/build/assets/{vendor~home~mcp~automations-list-C5PoHCy6.js → vendor~home~mcp~automations-list-Ccy2I0KU.js} +1 -1
- package/build/assets/{vendor~home~mcp~automations-list-BUBGGGYz.js → vendor~home~mcp~automations-list-DoPfwaXj.js} +1 -1
- package/build/assets/{vendor~home~mcp~llm-settings~agent-settings~condenser-settings~verification-settings~app-se~ocm3mykx-CGlZoBKa.js → vendor~home~mcp~llm-settings~agent-settings~condenser-settings~verification-settings~app-se~ocm3mykx-DbfELDJu.js} +2 -2
- package/build/assets/{vendor~home~mcp~llm-settings~agent-settings~condenser-settings~verification-settings~app-se~ocm3mykx-DE11mPxp.js → vendor~home~mcp~llm-settings~agent-settings~condenser-settings~verification-settings~app-se~ocm3mykx-Z3nsiNNq.js} +1 -1
- package/build/assets/{vendor~launch-Dg--Ssk6.js → vendor~launch-vdeRTWFu.js} +1 -1
- package/build/assets/{vendor~root-layout~conversation-panel~conversation~shared-conversation-DrXgiSCq.js → vendor~root-layout~conversation-panel~conversation~shared-conversation-DW31UyBp.js} +1 -1
- package/build/assets/{vendor~root-layout~home~conversation-panel~conversation~extensions-hub~skills-settings~skil~iguv7bgw-8b8V5bfO.js → vendor~root-layout~home~conversation-panel~conversation~extensions-hub~skills-settings~skil~iguv7bgw-BkQGKpye.js} +1 -1
- package/build/assets/{vendor~root-layout~home~conversation-panel~conversation~extensions-hub~skills-settings~skil~iguv7bgw-Dy7L6fMG.js → vendor~root-layout~home~conversation-panel~conversation~extensions-hub~skills-settings~skil~iguv7bgw-DzIXV3Ui.js} +1 -1
- package/build/assets/{vendor~root-layout~home~conversation-panel~conversation~extensions-hub~skills-settings~skil~k776hupu-D40EXhZx.js → vendor~root-layout~home~conversation-panel~conversation~extensions-hub~skills-settings~skil~k776hupu-Bbs7UJ5U.js} +2 -2
- package/build/assets/{vendor~root-layout~home~conversation-panel~conversation~launch~extensions-hub~skills-settin~dp08i1qy-CHrEOFl6.js → vendor~root-layout~home~conversation-panel~conversation~launch~extensions-hub~skills-settin~dp08i1qy-DTwbEEcX.js} +1 -1
- package/build/assets/{vendor~root-layout~home~conversation-panel~conversation~shared-conversation~alert-banner~pl~rqjteh0a-BP1SKG0F.js → vendor~root-layout~home~conversation-panel~conversation~shared-conversation~alert-banner~pl~rqjteh0a-d2oallMa.js} +1 -1
- package/build/assets/{vendor~root~root-layout~home~conversation-panel~conversation~launch~extensions-hub~skills-s~kyz9p27j-CyUbhpbm.js → vendor~root~root-layout~home~conversation-panel~conversation~launch~extensions-hub~skills-s~f2l2lr17-CDXvdvb2.js} +1 -1
- package/build/assets/{verification-settings-BtlTiHP8.js → verification-settings-CsbvQcYS.js} +1 -1
- package/build/assets/{vscode-tab-C0ShhiSU.js → vscode-tab-Zb-QbTuV.js} +1 -1
- package/build/assets/{waiting-for-runtime-message-DWPl_Yby.js → waiting-for-runtime-message-CntjExbU.js} +1 -1
- package/build/assets/{x-mark-CWI0f9yI.js → x-mark-CrpjscNc.js} +1 -1
- package/build/index.html +4 -4
- package/build/locales/ar/openhands.json +7 -0
- package/build/locales/ca/openhands.json +7 -0
- package/build/locales/de/openhands.json +7 -0
- package/build/locales/en/openhands.json +7 -0
- package/build/locales/es/openhands.json +7 -0
- package/build/locales/fr/openhands.json +7 -0
- package/build/locales/it/openhands.json +7 -0
- package/build/locales/ja/openhands.json +7 -0
- package/build/locales/ko-KR/openhands.json +7 -0
- package/build/locales/no/openhands.json +7 -0
- package/build/locales/pt/openhands.json +7 -0
- package/build/locales/tr/openhands.json +7 -0
- package/build/locales/uk/openhands.json +7 -0
- package/build/locales/zh-CN/openhands.json +7 -0
- package/build/locales/zh-TW/openhands.json +7 -0
- package/config/defaults.json +0 -4
- package/dist/api/agent-server-adapter.cjs +1 -1
- package/dist/api/agent-server-adapter.cjs.map +1 -1
- package/dist/api/agent-server-adapter.js +2 -1
- package/dist/api/agent-server-adapter.js.map +1 -1
- package/dist/api/agent-server-compatibility.cjs +1 -1
- package/dist/api/agent-server-compatibility.cjs.map +1 -1
- package/dist/api/agent-server-compatibility.d.ts +16 -0
- package/dist/api/agent-server-compatibility.js +31 -20
- package/dist/api/agent-server-compatibility.js.map +1 -1
- package/dist/api/agent-server-config.cjs +1 -1
- package/dist/api/agent-server-config.cjs.map +1 -1
- package/dist/api/agent-server-config.d.ts +45 -0
- package/dist/api/agent-server-config.js +49 -21
- package/dist/api/agent-server-config.js.map +1 -1
- package/dist/api/backend-registry/storage.cjs +1 -1
- package/dist/api/backend-registry/storage.cjs.map +1 -1
- package/dist/api/backend-registry/storage.js +34 -32
- package/dist/api/backend-registry/storage.js.map +1 -1
- package/dist/api/conversation-service/agent-server-conversation-service.api.cjs +1 -1
- package/dist/api/conversation-service/agent-server-conversation-service.api.cjs.map +1 -1
- package/dist/api/conversation-service/agent-server-conversation-service.api.d.ts +5 -4
- package/dist/api/conversation-service/agent-server-conversation-service.api.js +70 -76
- package/dist/api/conversation-service/agent-server-conversation-service.api.js.map +1 -1
- package/dist/api/skills-service.cjs +1 -1
- package/dist/api/skills-service.cjs.map +1 -1
- package/dist/api/skills-service.d.ts +1 -1
- package/dist/api/skills-service.js +2 -2
- package/dist/api/skills-service.js.map +1 -1
- package/dist/components/features/backends/api-key-entry-screen.d.ts +10 -0
- package/dist/components/features/backends/backend-form-modal.cjs +1 -1
- package/dist/components/features/backends/backend-form-modal.cjs.map +1 -1
- package/dist/components/features/backends/backend-form-modal.d.ts +23 -2
- package/dist/components/features/backends/backend-form-modal.js +185 -173
- package/dist/components/features/backends/backend-form-modal.js.map +1 -1
- package/dist/components/features/browser/browser.cjs +1 -1
- package/dist/components/features/browser/browser.cjs.map +1 -1
- package/dist/components/features/browser/browser.js +10 -16
- package/dist/components/features/browser/browser.js.map +1 -1
- package/dist/components/features/conversation-panel/skills-modal.cjs +1 -1
- package/dist/components/features/conversation-panel/skills-modal.cjs.map +1 -1
- package/dist/components/features/conversation-panel/skills-modal.js +1 -1
- package/dist/components/features/conversation-panel/skills-modal.js.map +1 -1
- package/dist/components/features/mcp-page/install-server-modal.cjs +1 -1
- package/dist/components/features/mcp-page/install-server-modal.cjs.map +1 -1
- package/dist/components/features/mcp-page/install-server-modal.js +123 -116
- package/dist/components/features/mcp-page/install-server-modal.js.map +1 -1
- package/dist/components/features/mcp-page/installed-server-card.cjs +1 -1
- package/dist/components/features/mcp-page/installed-server-card.cjs.map +1 -1
- package/dist/components/features/mcp-page/installed-server-card.js +40 -40
- package/dist/components/features/mcp-page/installed-server-card.js.map +1 -1
- package/dist/components/features/mcp-page/marketplace-card.cjs +1 -1
- package/dist/components/features/mcp-page/marketplace-card.cjs.map +1 -1
- package/dist/components/features/mcp-page/marketplace-card.js +2 -3
- package/dist/components/features/mcp-page/marketplace-card.js.map +1 -1
- package/dist/components/features/mcp-page/marketplace-section.cjs +1 -1
- package/dist/components/features/mcp-page/marketplace-section.cjs.map +1 -1
- package/dist/components/features/mcp-page/marketplace-section.js +21 -21
- package/dist/components/features/mcp-page/marketplace-section.js.map +1 -1
- package/dist/components/features/onboarding/steps/setup-acp-secrets-step.d.ts +27 -0
- package/dist/components/features/settings/llm-profiles/llm-settings-local-view.cjs +1 -1
- package/dist/components/features/settings/llm-profiles/llm-settings-local-view.js +2 -0
- package/dist/components/features/settings/sdk-settings/sdk-section-page.cjs +1 -1
- package/dist/components/features/settings/sdk-settings/sdk-section-page.cjs.map +1 -1
- package/dist/components/features/settings/sdk-settings/sdk-section-page.d.ts +10 -1
- package/dist/components/features/settings/sdk-settings/sdk-section-page.js +87 -84
- package/dist/components/features/settings/sdk-settings/sdk-section-page.js.map +1 -1
- package/dist/constants/acp-providers.cjs +1 -1
- package/dist/constants/acp-providers.cjs.map +1 -1
- package/dist/constants/acp-providers.d.ts +25 -0
- package/dist/constants/acp-providers.js +1 -0
- package/dist/constants/acp-providers.js.map +1 -1
- package/dist/contexts/conversation-websocket-context.cjs +3 -3
- package/dist/contexts/conversation-websocket-context.cjs.map +1 -1
- package/dist/contexts/conversation-websocket-context.js +177 -165
- package/dist/contexts/conversation-websocket-context.js.map +1 -1
- package/dist/hooks/chat/use-model-interceptor.cjs.map +1 -1
- package/dist/hooks/chat/use-model-interceptor.js.map +1 -1
- package/dist/hooks/chat/use-slash-command.cjs +1 -1
- package/dist/hooks/chat/use-slash-command.cjs.map +1 -1
- package/dist/hooks/chat/use-slash-command.js +1 -1
- package/dist/hooks/chat/use-slash-command.js.map +1 -1
- package/dist/hooks/mutation/use-switch-llm-profile.cjs.map +1 -1
- package/dist/hooks/mutation/use-switch-llm-profile.d.ts +1 -1
- package/dist/hooks/mutation/use-switch-llm-profile.js.map +1 -1
- package/dist/hooks/query/use-config.cjs +1 -1
- package/dist/hooks/query/use-config.cjs.map +1 -1
- package/dist/hooks/query/use-config.js +10 -10
- package/dist/hooks/query/use-config.js.map +1 -1
- package/dist/hooks/query/use-conversation-skills.cjs +2 -0
- package/dist/hooks/query/use-conversation-skills.cjs.map +1 -0
- package/dist/hooks/query/use-conversation-skills.d.ts +7 -0
- package/dist/hooks/query/use-conversation-skills.js +8 -0
- package/dist/hooks/query/use-conversation-skills.js.map +1 -0
- package/dist/hooks/query/use-local-git-info.cjs +3 -3
- package/dist/hooks/query/use-local-git-info.cjs.map +1 -1
- package/dist/hooks/query/use-local-git-info.js +24 -25
- package/dist/hooks/query/use-local-git-info.js.map +1 -1
- package/dist/hooks/query/use-skills.cjs +1 -1
- package/dist/hooks/query/use-skills.cjs.map +1 -1
- package/dist/hooks/query/use-skills.d.ts +6 -1
- package/dist/hooks/query/use-skills.js +3 -3
- package/dist/hooks/query/use-skills.js.map +1 -1
- package/dist/i18n/declaration.cjs +1 -1
- package/dist/i18n/declaration.cjs.map +1 -1
- package/dist/i18n/declaration.d.ts +7 -0
- package/dist/i18n/declaration.js +1 -1
- package/dist/i18n/declaration.js.map +1 -1
- package/dist/i18n/translation.cjs +2 -2
- package/dist/i18n/translation.cjs.map +1 -1
- package/dist/i18n/translation.js +119 -0
- package/dist/i18n/translation.js.map +1 -1
- package/dist/locales/ar/openhands.json +7 -0
- package/dist/locales/ca/openhands.json +7 -0
- package/dist/locales/de/openhands.json +7 -0
- package/dist/locales/en/openhands.json +7 -0
- package/dist/locales/es/openhands.json +7 -0
- package/dist/locales/fr/openhands.json +7 -0
- package/dist/locales/it/openhands.json +7 -0
- package/dist/locales/ja/openhands.json +7 -0
- package/dist/locales/ko-KR/openhands.json +7 -0
- package/dist/locales/no/openhands.json +7 -0
- package/dist/locales/pt/openhands.json +7 -0
- package/dist/locales/tr/openhands.json +7 -0
- package/dist/locales/uk/openhands.json +7 -0
- package/dist/locales/zh-CN/openhands.json +7 -0
- package/dist/locales/zh-TW/openhands.json +7 -0
- package/dist/package.cjs +1 -1
- package/dist/package.cjs.map +1 -1
- package/dist/package.js +3 -3
- package/dist/package.js.map +1 -1
- package/dist/routes/conversation.cjs +1 -1
- package/dist/routes/conversation.cjs.map +1 -1
- package/dist/routes/conversation.js +61 -63
- package/dist/routes/conversation.js.map +1 -1
- package/dist/routes/mcp.cjs +1 -1
- package/dist/routes/mcp.cjs.map +1 -1
- package/dist/routes/mcp.js +64 -64
- package/dist/routes/mcp.js.map +1 -1
- package/dist/stores/browser-store.cjs +1 -1
- package/dist/stores/browser-store.cjs.map +1 -1
- package/dist/stores/browser-store.js +1 -1
- package/dist/stores/browser-store.js.map +1 -1
- package/dist/stores/use-event-store.cjs +1 -1
- package/dist/stores/use-event-store.cjs.map +1 -1
- package/dist/stores/use-event-store.d.ts +22 -0
- package/dist/stores/use-event-store.js +9 -1
- package/dist/stores/use-event-store.js.map +1 -1
- package/dist/ui/context-menu.d.ts +1 -1
- package/dist/ui/help-link.d.ts +1 -1
- package/dist/utils/mcp-marketplace-utils.cjs +1 -1
- package/dist/utils/mcp-marketplace-utils.cjs.map +1 -1
- package/dist/utils/mcp-marketplace-utils.d.ts +13 -22
- package/dist/utils/mcp-marketplace-utils.js +46 -28
- package/dist/utils/mcp-marketplace-utils.js.map +1 -1
- package/dist/utils/sdk-settings-schema.cjs +1 -1
- package/dist/utils/sdk-settings-schema.cjs.map +1 -1
- package/dist/utils/sdk-settings-schema.d.ts +1 -0
- package/dist/utils/sdk-settings-schema.js +1 -1
- package/dist/utils/sdk-settings-schema.js.map +1 -1
- package/package.json +3 -3
- package/scripts/dev-safe.mjs +94 -57
- package/scripts/dev-static.mjs +2 -3
- package/scripts/dev-with-automation.mjs +98 -67
- package/scripts/static-server.mjs +77 -35
- package/tools/canvas_ui_tool.py +4 -0
- package/build/assets/acp-providers-DauuOsW9.js +0 -1
- package/build/assets/add-backend-modal-KMmPQNZU.js +0 -1
- package/build/assets/agent-server-client-options-DT2GP6VJ.js +0 -1
- package/build/assets/agent-server-compatibility-2aOx5iWd.js +0 -1
- package/build/assets/agent-server-conversation-service.api-DSl9G5UR.js +0 -5
- package/build/assets/backend-form-modal-K6IMCr3p.js +0 -1
- package/build/assets/browser-DKG63inJ.js +0 -5
- package/build/assets/browser-store-C3AqxAO7.js +0 -1
- package/build/assets/browser-tab-B_BuTvrO.js +0 -1
- package/build/assets/conversation-BD5WemJI.js +0 -19
- package/build/assets/conversation-C47K62n8.js +0 -1
- package/build/assets/conversation-panel-Dn-S56Gk.js +0 -1
- package/build/assets/conversation-websocket-context-Ywrxd_9p.js +0 -3
- package/build/assets/declaration-D378OjpZ.js +0 -1
- package/build/assets/files-tab-B3A1NDlZ.js +0 -1
- package/build/assets/git-control-bar-branch-button-CcIpmyfM.js +0 -27
- package/build/assets/home-dIzxi5Dd.js +0 -1
- package/build/assets/install-server-modal-z5VaHeXd.js +0 -1
- package/build/assets/llm-settings-2036m7Wt.js +0 -1
- package/build/assets/llm-settings-CcHqGOYL.js +0 -1
- package/build/assets/manifest-97e839da.js +0 -1
- package/build/assets/mcp-C06YssEI.js +0 -9
- package/build/assets/root-BS1Td78t.js +0 -2
- package/build/assets/sdk-section-page-CJW0G04-.js +0 -1
- package/build/assets/secrets-settings-MLXqOtX2.js +0 -1
- package/build/assets/use-active-conversation-D15D9GgR.js +0 -1
- package/build/assets/use-config-BSu_53GL.js +0 -1
- package/build/assets/use-conversation-id-DajhCn2A.js +0 -1
- package/build/assets/use-is-authed-hXC8vxgT.js +0 -1
- package/build/assets/use-runtime-is-ready-XFbT16BD.js +0 -1
- package/build/assets/use-skills-Xe0vjPMt.js +0 -1
- package/build/assets/use-unified-vscode-url-BOsIOd-b.js +0 -1
- package/build/assets/use-user-conversation-Mc0mQgkl.js +0 -1
- /package/build/assets/{automation-XLxhq3I8.js → automation-IdgZq6ZK.js} +0 -0
- /package/build/assets/{common-SMkEaBSr.js → common-DR1t-EeP.js} +0 -0
- /package/build/assets/{conversation-state-store-Bc0slAjL.js → conversation-state-store-u5jepov0.js} +0 -0
- /package/build/assets/{dist-yMQV8IUk.js → dist-BxBP7tFD.js} +0 -0
- /package/build/assets/{git-status-mapper-BI8FyUVp.js → git-status-mapper-DnL9OC8_.js} +0 -0
- /package/build/assets/{handle-capture-consent-BfZATzpI.js → handle-capture-consent-3XrjZ8wi.js} +0 -0
- /package/build/assets/{iconBase-C7N9pPOs.js → iconBase-DE30Zj_-.js} +0 -0
- /package/build/assets/{settings-D5am1n6X.js → settings-D_H-qsRm.js} +0 -0
- /package/build/assets/{settings-like-page-layout-classes-Bn-M9oOa.js → settings-like-page-layout-classes-I0BDBEoq.js} +0 -0
- /package/build/assets/{settings-utils-BsvSU3OM.js → settings-utils-B6Nl07io.js} +0 -0
- /package/build/assets/{sidebar-store-cOeaKmIm.js → sidebar-store-Uy3v0AOV.js} +0 -0
- /package/build/assets/{use-breakpoint-B86yKT9n.js → use-breakpoint-DbJ6FkQ-.js} +0 -0
- /package/build/assets/{use-click-outside-element-835W9pC6.js → use-click-outside-element-DffgWWoZ.js} +0 -0
- /package/build/assets/{vendor~browser-BpdPBhgZ.js → vendor~browser-DDiZgqD3.js} +0 -0
- /package/build/assets/{vendor~conversation-panel~conversation~alert-banner-Df7_G0zR.js → vendor~conversation-panel~conversation~alert-banner-DbvX3OcM.js} +0 -0
- /package/build/assets/{vendor~entry.client~root~root-layout~home~conversation-panel~conversation~launch~extensions~b4cctr4k-B7YVdv1X.js → vendor~entry.client~root~root-layout~home~conversation-panel~conversation~launch~extensions~g56ukk6u-DsSvIDZQ.js} +0 -0
- /package/build/assets/{vendor~entry.client~root~root-layout~home~conversation-panel~conversation~launch~extensions~i9dbt75i-CI82Did1.js → vendor~entry.client~root~root-layout~home~conversation-panel~conversation~launch~extensions~hkqzh1hb-BZ0HXuHD.js} +0 -0
- /package/build/assets/{vendor~root-layout~home~conversation-panel~conversation~launch~extensions-hub~skills-settin~pfbaerbd-zhv9fooy.js → vendor~root-layout~home~conversation-panel~conversation~launch~extensions-hub~skills-settin~ninslayh-D9P8e98a.js} +0 -0
- /package/build/assets/{vendor~terminal-BUxzHKcC.js → vendor~terminal-DUrOWGFE.js} +0 -0
- /package/build/assets/{vscode-url-helper-jesbpos5.js → vscode-url-helper-Cwy1A62q.js} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"conversation-websocket-context.js","names":[],"sources":["../../src/contexts/conversation-websocket-context.tsx"],"sourcesContent":["import React, {\n createContext,\n useContext,\n useEffect,\n useLayoutEffect,\n useState,\n useCallback,\n useMemo,\n useRef,\n} from \"react\";\nimport { ConversationClient } from \"@openhands/typescript-client/clients\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { usePostHog } from \"posthog-js/react\";\nimport { useWebSocket, WebSocketHookOptions } from \"#/hooks/use-websocket\";\nimport { SERVER_CONNECTION_ERROR_MESSAGE } from \"#/constants/server-connection-error\";\nimport { useEventStore } from \"#/stores/use-event-store\";\nimport { useErrorMessageStore } from \"#/stores/error-message-store\";\nimport { useOptimisticUserMessageStore } from \"#/stores/optimistic-user-message-store\";\nimport { useConversationStateStore } from \"#/stores/conversation-state-store\";\nimport { useCommandStore } from \"#/stores/command-store\";\nimport { useBrowserStore } from \"#/stores/browser-store\";\nimport {\n isAgentServerEvent,\n isAgentErrorEvent,\n isUserMessageEvent,\n isActionEvent,\n isConversationStateUpdateEvent,\n isFullStateConversationStateUpdateEvent,\n isAgentStatusConversationStateUpdateEvent,\n isStatsConversationStateUpdateEvent,\n isExecuteBashActionEvent,\n isExecuteBashObservationEvent,\n isDisplayableErrorEvent,\n isPlanningFileEditorObservationEvent,\n isBrowserObservationEvent,\n isBrowserNavigateActionEvent,\n isSwitchLLMObservationEvent,\n isCanvasUIActionEvent,\n} from \"#/types/agent-server/type-guards\";\nimport { handleCanvasUIAction } from \"#/services/canvas-ui\";\nimport { ConversationStateUpdateEventStats } from \"#/types/agent-server/core/events/conversation-state-event\";\nimport type {\n ConversationErrorEvent,\n ServerErrorEvent,\n} from \"#/types/agent-server/core/events/conversation-state-event\";\nimport { handleActionEventCacheInvalidation } from \"#/utils/cache-utils\";\nimport { buildWebSocketUrl } from \"#/utils/websocket-url\";\nimport type {\n AppConversation,\n SendMessageRequest,\n} from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport EventService from \"#/api/event-service/event-service.api\";\nimport { getAgentServerClientOptions } from \"#/api/agent-server-client-options\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { trackError } from \"#/utils/error-handler\";\nimport { useReadConversationFile } from \"#/hooks/mutation/use-read-conversation-file\";\nimport useMetricsStore from \"#/stores/metrics-store\";\nimport { useConversationHistory } from \"#/hooks/query/use-conversation-history\";\nimport { setConversationState } from \"#/utils/conversation-local-storage\";\nimport { recordModelSwitchMessage } from \"#/hooks/chat/record-model-switch-message\";\nimport {\n invalidateConversationQueries,\n updateConversationLlmModelInCache,\n} from \"#/hooks/mutation/conversation-mutation-utils\";\n\nexport type WebSocketConnectionState =\n | \"CONNECTING\"\n | \"OPEN\"\n | \"CLOSED\"\n | \"CLOSING\";\n\ninterface SendMessageResult {\n queued: boolean; // true if message was queued for later delivery, false if sent immediately\n}\n\ninterface ConversationWebSocketContextType {\n connectionState: WebSocketConnectionState;\n sendMessage: (message: SendMessageRequest) => Promise<SendMessageResult>;\n isLoadingHistory: boolean;\n reconnect: () => void;\n}\n\nconst ConversationWebSocketContext = createContext<\n ConversationWebSocketContextType | undefined\n>(undefined);\n\n/**\n * Extract the text body of an echoed user `MessageEvent` for matching against\n * the optimistic pending-message queue. The server wraps the original\n * `args.content` string in one or more `TextContent` entries (alongside any\n * `ImageContent` entries for inline images), so concatenating the `text`\n * fields gives us back the exact prompt we sent.\n */\nfunction extractMessageEventText(\n event: import(\"#/types/agent-server/core/events/message-event\").MessageEvent,\n): string {\n return event.llm_message.content\n .filter(\n (part): part is { type: \"text\"; text: string } => part.type === \"text\",\n )\n .map((part) => part.text)\n .join(\"\");\n}\n\nexport function ConversationWebSocketProvider({\n children,\n conversationId,\n conversationUrl,\n sessionApiKey,\n subConversations,\n subConversationIds,\n}: {\n children: React.ReactNode;\n conversationId?: string;\n conversationUrl?: string | null;\n sessionApiKey?: string | null;\n subConversations?: AppConversation[];\n subConversationIds?: string[];\n}) {\n // Separate connection state tracking for each WebSocket\n const [mainConnectionState, setMainConnectionState] =\n useState<WebSocketConnectionState>(\"CONNECTING\");\n const [planningConnectionState, setPlanningConnectionState] =\n useState<WebSocketConnectionState>(\"CONNECTING\");\n\n // Track if we've ever successfully connected for each connection\n // Don't show errors until after first successful connection\n const hasConnectedRefMain = React.useRef(false);\n const hasConnectedRefPlanning = React.useRef(false);\n\n const posthog = usePostHog();\n const queryClient = useQueryClient();\n const addEvent = useEventStore((state) => state.addEvent);\n const addEvents = useEventStore((state) => state.addEvents);\n const { setErrorMessage, removeErrorMessage, clearConnectionError } =\n useErrorMessageStore();\n const consumeMatchingPendingMessage = useOptimisticUserMessageStore(\n (state) => state.consumeMatchingPendingMessage,\n );\n const { setExecutionStatus } = useConversationStateStore();\n const { appendInput, appendOutput } = useCommandStore();\n\n // History loading state.\n // - Main conversation history is now loaded via REST (`useConversationHistory`),\n // so its loading state mirrors the REST query state (see below).\n // - Planning sub-conversation history still streams over the WebSocket using\n // `resend_mode='all'`, so we keep the count-based detection for it.\n const [isLoadingHistoryPlanning, setIsLoadingHistoryPlanning] =\n useState(true);\n const [expectedEventCountPlanning, setExpectedEventCountPlanning] = useState<\n number | null\n >(null);\n\n const { setPlanContent } = useConversationStore();\n\n // Hook for reading conversation file\n const { mutate: readConversationFile } = useReadConversationFile();\n\n // Track planning-agent received events (still WS-driven).\n const receivedEventCountRefPlanning = useRef(0);\n\n // Track the latest PlanningFileEditorObservation for Plan.md during history replay\n const latestPlanningFileEventRef = useRef<{\n path: string;\n conversationId: string;\n } | null>(null);\n\n const isPlanFilePath = (path: string | null): boolean =>\n path?.toUpperCase().endsWith(\"PLAN.MD\") ?? false;\n\n const handleNonErrorEvent = useCallback(() => {\n // A normal event means connectivity recovered: clear a transient connection\n // error, but keep sticky conversation errors (e.g. a wrong API key).\n clearConnectionError();\n }, [clearConnectionError]);\n\n // Helper function to update metrics from stats event\n const updateMetricsFromStats = useCallback(\n (event: ConversationStateUpdateEventStats) => {\n if (event.value.usage_to_metrics?.agent) {\n const agentMetrics = event.value.usage_to_metrics.agent;\n const metrics = {\n cost: agentMetrics.accumulated_cost,\n max_budget_per_task: agentMetrics.max_budget_per_task ?? null,\n usage: agentMetrics.accumulated_token_usage\n ? {\n prompt_tokens:\n agentMetrics.accumulated_token_usage.prompt_tokens,\n completion_tokens:\n agentMetrics.accumulated_token_usage.completion_tokens,\n cache_read_tokens:\n agentMetrics.accumulated_token_usage.cache_read_tokens,\n cache_write_tokens:\n agentMetrics.accumulated_token_usage.cache_write_tokens,\n context_window:\n agentMetrics.accumulated_token_usage.context_window,\n per_turn_token:\n agentMetrics.accumulated_token_usage.per_turn_token,\n }\n : null,\n };\n useMetricsStore.getState().setMetrics(metrics);\n }\n },\n [],\n );\n\n // Initial REST history load: fetch the most recent events and seed the\n // store. Older events are paginated in via `useLoadOlderEvents` when the\n // user scrolls to the top of the chat. The WebSocket connection waits for\n // this query so it can subscribe with `resend_mode='since'` and avoid\n // re-streaming everything REST already returned.\n const {\n data: preloadedHistory,\n isPending: isPreloadingHistory,\n isError: isPreloadHistoryError,\n } = useConversationHistory(conversationId);\n\n const isLoadingHistoryMain = !!conversationId && isPreloadingHistory;\n\n useLayoutEffect(() => {\n if (!preloadedHistory || preloadedHistory.events.length === 0) {\n return;\n }\n addEvents(preloadedHistory.events);\n }, [preloadedHistory, addEvents]);\n\n /**\n * Timestamp of the latest event we already have from REST. Used as\n * `after_timestamp` when opening the WebSocket so the server only resends\n * events strictly after this point. `null` until the REST query settles\n * (we hold the WS connection open until then to avoid an `all` resend).\n */\n const initialAfterTimestamp = useMemo<string | null>(() => {\n if (isPreloadingHistory) return null;\n const events = preloadedHistory?.events ?? [];\n const latest = events[events.length - 1];\n if (!latest || !(\"timestamp\" in latest) || !latest.timestamp) return null;\n return latest.timestamp;\n }, [preloadedHistory, isPreloadingHistory]);\n\n // Build WebSocket URL from props.\n //\n // We deliberately wait for the initial REST history fetch to settle before\n // opening the socket so the WS subscription can use `resend_mode='since'`\n // with a meaningful `after_timestamp`. Without this gate, the WS would open\n // immediately and either replay the entire conversation (when falling back\n // to `resend_mode='all'`) or miss events that arrived between REST and WS.\n const wsUrl = useMemo(() => {\n if (!conversationId || !conversationUrl) {\n return null;\n }\n // Don't connect while we're still fetching the initial history. If the\n // REST query errored we fall through and connect with `resend_mode='all'`\n // so the user still sees live events.\n if (isPreloadingHistory && !isPreloadHistoryError) {\n return null;\n }\n return buildWebSocketUrl(conversationId, conversationUrl);\n }, [\n conversationId,\n conversationUrl,\n isPreloadingHistory,\n isPreloadHistoryError,\n ]);\n\n const planningAgentWsUrl = useMemo(() => {\n if (!subConversations?.length) {\n return null;\n }\n\n // Currently, there is only one sub-conversation and it uses the planning agent.\n const planningAgentConversation = subConversations[0];\n\n if (\n !planningAgentConversation?.id ||\n !planningAgentConversation.conversation_url\n ) {\n return null;\n }\n\n return buildWebSocketUrl(\n planningAgentConversation.id,\n planningAgentConversation.conversation_url,\n );\n }, [subConversations]);\n\n // Merged connection state - reflects combined status of both connections\n const connectionState = useMemo<WebSocketConnectionState>(() => {\n // If planning agent connection doesn't exist, use main connection state\n if (!planningAgentWsUrl) {\n return mainConnectionState;\n }\n\n // If either is connecting, merged state is connecting\n if (\n mainConnectionState === \"CONNECTING\" ||\n planningConnectionState === \"CONNECTING\"\n ) {\n return \"CONNECTING\";\n }\n\n // If both are open, merged state is open\n if (mainConnectionState === \"OPEN\" && planningConnectionState === \"OPEN\") {\n return \"OPEN\";\n }\n\n // If both are closed, merged state is closed\n if (\n mainConnectionState === \"CLOSED\" &&\n planningConnectionState === \"CLOSED\"\n ) {\n return \"CLOSED\";\n }\n\n // If either is closing, merged state is closing\n if (\n mainConnectionState === \"CLOSING\" ||\n planningConnectionState === \"CLOSING\"\n ) {\n return \"CLOSING\";\n }\n\n // Default to closed if states don't match expected patterns\n return \"CLOSED\";\n }, [mainConnectionState, planningConnectionState, planningAgentWsUrl]);\n\n useEffect(() => {\n if (\n expectedEventCountPlanning !== null &&\n receivedEventCountRefPlanning.current >= expectedEventCountPlanning &&\n isLoadingHistoryPlanning\n ) {\n setIsLoadingHistoryPlanning(false);\n }\n }, [\n expectedEventCountPlanning,\n isLoadingHistoryPlanning,\n receivedEventCountRefPlanning,\n ]);\n\n // Call API once after history loading completes if we tracked any PlanningFileEditorObservation events\n useEffect(() => {\n if (!isLoadingHistoryPlanning && latestPlanningFileEventRef.current) {\n const { path, conversationId: currentPlanningConversationId } =\n latestPlanningFileEventRef.current;\n\n readConversationFile(\n {\n conversationId: currentPlanningConversationId,\n filePath: path,\n },\n {\n onSuccess: (fileContent) => {\n setPlanContent(fileContent);\n },\n onError: (error) => {\n console.warn(\"Failed to read conversation file:\", error);\n },\n },\n );\n\n // Clear the ref after calling the API\n latestPlanningFileEventRef.current = null;\n }\n }, [isLoadingHistoryPlanning, readConversationFile, setPlanContent]);\n\n useEffect(() => {\n hasConnectedRefMain.current = false;\n setIsLoadingHistoryPlanning(!!subConversationIds?.length);\n setExpectedEventCountPlanning(null);\n receivedEventCountRefPlanning.current = 0;\n // Reset the tracked event ref when sub-conversations change\n latestPlanningFileEventRef.current = null;\n }, [subConversationIds]);\n\n // Reset hasConnected flags when the conversation changes.\n useEffect(() => {\n hasConnectedRefMain.current = false;\n hasConnectedRefPlanning.current = false;\n // Reset the tracked event ref when conversation changes\n latestPlanningFileEventRef.current = null;\n }, [conversationId]);\n\n // Merged loading history state - true if either connection is still loading\n const isLoadingHistory = useMemo(\n () => isLoadingHistoryMain || isLoadingHistoryPlanning,\n [isLoadingHistoryMain, isLoadingHistoryPlanning],\n );\n\n // Separate message handlers for each connection\n const handleMainMessage = useCallback(\n (messageEvent: MessageEvent) => {\n try {\n const event = JSON.parse(messageEvent.data);\n\n // History loading for the main conversation is REST-driven now;\n // every WS message is a new event we add to the store.\n\n // Use type guard to validate v1 event structure\n if (isAgentServerEvent(event)) {\n const isDuplicateEvent = useEventStore\n .getState()\n .eventIds.has(event.id);\n const switchLLMObservation =\n !isDuplicateEvent && isSwitchLLMObservationEvent(event)\n ? event\n : null;\n addEvent(event);\n\n // Handle displayable error events - show error banner\n // AgentErrorEvent errors are displayed inline in the chat, not as banners\n if (isDisplayableErrorEvent(event)) {\n const errorEvent = event as\n | ConversationErrorEvent\n | ServerErrorEvent;\n trackError({\n message: errorEvent.detail,\n source: \"conversation\",\n metadata: {\n eventId: errorEvent.id,\n errorCode: errorEvent.code,\n },\n posthog,\n });\n setErrorMessage(errorEvent.detail);\n } else {\n handleNonErrorEvent();\n }\n\n // LLM errors render inline in the chat (see ErrorEventMessage); track\n // them for analytics but keep them out of the banner above the chat box.\n if (isAgentErrorEvent(event)) {\n trackError({\n message: event.error,\n source: \"agent\",\n metadata: {\n eventId: event.id,\n toolName: event.tool_name,\n toolCallId: event.tool_call_id,\n },\n posthog,\n });\n }\n\n // Clear optimistic user message when a user message is confirmed.\n // We match by the echoed text content (with FIFO fallback inside the\n // store), so an echo for \"second\" pops \"second\" — not whichever\n // pending entry happens to be oldest — protecting against any\n // out-of-order delivery between conversations or sub-agents.\n if (isUserMessageEvent(event)) {\n if (conversationId) {\n consumeMatchingPendingMessage(\n conversationId,\n extractMessageEventText(event),\n );\n // Clear draft from localStorage - message was successfully delivered\n setConversationState(conversationId, { draftMessage: null });\n }\n }\n\n // Handle cache invalidation for ActionEvent\n if (isActionEvent(event)) {\n const currentConversationId =\n conversationId || \"test-conversation-id\"; // TODO: Get from context\n handleActionEventCacheInvalidation(\n event,\n currentConversationId,\n queryClient,\n );\n }\n\n // Handle conversation state updates\n // TODO: Tests\n if (isConversationStateUpdateEvent(event)) {\n if (isFullStateConversationStateUpdateEvent(event)) {\n setExecutionStatus(event.value.execution_status);\n }\n if (isAgentStatusConversationStateUpdateEvent(event)) {\n setExecutionStatus(event.value);\n }\n if (isStatsConversationStateUpdateEvent(event)) {\n updateMetricsFromStats(event);\n }\n }\n\n // Handle ExecuteBashAction events - add command as input to terminal\n if (isExecuteBashActionEvent(event)) {\n appendInput(event.action.command);\n }\n\n // Handle ExecuteBashObservation events - add output to terminal\n if (isExecuteBashObservationEvent(event)) {\n // Extract text content from the observation content array\n const textContent = event.observation.content\n .filter((c) => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\\n\");\n appendOutput(textContent);\n }\n\n // Handle BrowserObservation events - update browser store with screenshot\n if (isBrowserObservationEvent(event)) {\n const { screenshot_data: screenshotData } = event.observation;\n if (screenshotData) {\n const screenshotSrc = screenshotData.startsWith(\"data:\")\n ? screenshotData\n : `data:image/png;base64,${screenshotData}`;\n useBrowserStore.getState().setScreenshotSrc(screenshotSrc);\n }\n }\n\n // Handle BrowserNavigateAction events - update browser store with URL\n if (isBrowserNavigateActionEvent(event)) {\n useBrowserStore.getState().setUrl(event.action.url);\n }\n\n if (\n conversationId &&\n switchLLMObservation &&\n !switchLLMObservation.observation.is_error\n ) {\n recordModelSwitchMessage(\n conversationId,\n switchLLMObservation.observation.profile_name,\n );\n\n if (switchLLMObservation.observation.active_model) {\n updateConversationLlmModelInCache(\n queryClient,\n conversationId,\n switchLLMObservation.observation.active_model,\n );\n }\n\n invalidateConversationQueries(queryClient, conversationId);\n }\n\n // Handle canvas_ui custom-tool ActionEvents - drive the frontend\n // (navigate to a file, switch tabs, show a preview). The tool\n // executes server-side as a no-op; the actual UI change happens\n // here on the client.\n if (isCanvasUIActionEvent(event)) {\n handleCanvasUIAction(event.action);\n }\n }\n } catch (error) {\n console.warn(\"Failed to parse WebSocket message as JSON:\", error);\n }\n },\n [\n addEvent,\n setErrorMessage,\n consumeMatchingPendingMessage,\n queryClient,\n conversationId,\n setExecutionStatus,\n appendInput,\n appendOutput,\n updateMetricsFromStats,\n handleNonErrorEvent,\n posthog,\n ],\n );\n\n const handlePlanningMessage = useCallback(\n (messageEvent: MessageEvent) => {\n try {\n const event = JSON.parse(messageEvent.data);\n\n // Track received events for history loading (count ALL events from WebSocket)\n // Always count when loading, even if we don't have the expected count yet\n if (isLoadingHistoryPlanning) {\n receivedEventCountRefPlanning.current += 1;\n\n if (\n expectedEventCountPlanning !== null &&\n receivedEventCountRefPlanning.current >= expectedEventCountPlanning\n ) {\n setIsLoadingHistoryPlanning(false);\n }\n }\n\n // Use type guard to validate v1 event structure\n if (isAgentServerEvent(event)) {\n // Mark this event as coming from the planning agent\n const eventWithPlanningFlag = {\n ...event,\n isFromPlanningAgent: true,\n };\n addEvent(eventWithPlanningFlag);\n\n // Handle displayable error events - show error banner\n // AgentErrorEvent errors are displayed inline in the chat, not as banners\n if (isDisplayableErrorEvent(event)) {\n const errorEvent = event as\n | ConversationErrorEvent\n | ServerErrorEvent;\n trackError({\n message: errorEvent.detail,\n source: \"planning_conversation\",\n metadata: {\n eventId: errorEvent.id,\n errorCode: errorEvent.code,\n },\n posthog,\n });\n setErrorMessage(errorEvent.detail);\n } else {\n handleNonErrorEvent();\n }\n\n // LLM errors render inline in the chat (see ErrorEventMessage); track\n // them for analytics but keep them out of the banner above the chat box.\n if (isAgentErrorEvent(event)) {\n trackError({\n message: event.error,\n source: \"planning_agent\",\n metadata: {\n eventId: event.id,\n toolName: event.tool_name,\n toolCallId: event.tool_call_id,\n },\n posthog,\n });\n }\n\n // Clear optimistic user message when a user message is confirmed.\n // Always scope to the main `conversationId` (where the user types)\n // and match on the echoed content so the planning sub-agent's own\n // events can never consume a main-conversation pending entry.\n if (isUserMessageEvent(event)) {\n if (conversationId) {\n consumeMatchingPendingMessage(\n conversationId,\n extractMessageEventText(event),\n );\n setConversationState(conversationId, { draftMessage: null });\n }\n }\n\n // Handle cache invalidation for ActionEvent\n if (isActionEvent(event)) {\n const planningAgentConversation = subConversations?.[0];\n const currentConversationId =\n planningAgentConversation?.id || \"test-conversation-id\"; // TODO: Get from context\n handleActionEventCacheInvalidation(\n event,\n currentConversationId,\n queryClient,\n );\n }\n\n // Handle conversation state updates\n // TODO: Tests\n if (isConversationStateUpdateEvent(event)) {\n if (isFullStateConversationStateUpdateEvent(event)) {\n setExecutionStatus(event.value.execution_status);\n }\n if (isAgentStatusConversationStateUpdateEvent(event)) {\n setExecutionStatus(event.value);\n }\n if (isStatsConversationStateUpdateEvent(event)) {\n updateMetricsFromStats(event);\n }\n }\n\n // Handle ExecuteBashAction events - add command as input to terminal\n if (isExecuteBashActionEvent(event)) {\n appendInput(event.action.command);\n }\n\n // Handle ExecuteBashObservation events - add output to terminal\n if (isExecuteBashObservationEvent(event)) {\n // Extract text content from the observation content array\n const textContent = event.observation.content\n .filter((c) => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\\n\");\n appendOutput(textContent);\n }\n\n // Handle PlanningFileEditorObservation - only update plan for Plan.md\n if (isPlanningFileEditorObservationEvent(event)) {\n const { path } = event.observation;\n if (isPlanFilePath(path)) {\n const planningAgentConversation = subConversations?.[0];\n const planningConversationId = planningAgentConversation?.id;\n\n if (planningConversationId && path) {\n if (isLoadingHistoryPlanning) {\n latestPlanningFileEventRef.current = {\n path,\n conversationId: planningConversationId,\n };\n } else {\n readConversationFile(\n {\n conversationId: planningConversationId,\n filePath: path,\n },\n {\n onSuccess: (fileContent) => {\n setPlanContent(fileContent);\n },\n onError: (error) => {\n console.warn(\n \"Failed to read conversation file:\",\n error,\n );\n },\n },\n );\n }\n }\n }\n }\n }\n } catch (error) {\n console.warn(\"Failed to parse WebSocket message as JSON:\", error);\n }\n },\n [\n addEvent,\n isLoadingHistoryPlanning,\n expectedEventCountPlanning,\n setErrorMessage,\n consumeMatchingPendingMessage,\n queryClient,\n subConversations,\n conversationId,\n setExecutionStatus,\n appendInput,\n appendOutput,\n readConversationFile,\n setPlanContent,\n updateMetricsFromStats,\n handleNonErrorEvent,\n posthog,\n ],\n );\n\n // Separate WebSocket options for main connection\n const mainWebsocketOptions: WebSocketHookOptions = useMemo(() => {\n // History was already loaded over REST (`useConversationHistory`).\n // Subscribe with `resend_mode='since'` so the server only resends events\n // strictly after the latest one we already have. If REST returned no\n // events at all (brand-new conversation), fall back to `'all'` so any\n // events that may have been written between the REST call and the WS\n // handshake still show up. Dedup in the event store handles overlap.\n const queryParams: Record<string, string | boolean> = initialAfterTimestamp\n ? { resend_mode: \"since\", after_timestamp: initialAfterTimestamp }\n : { resend_mode: \"all\" };\n\n // Add session_api_key if available\n if (sessionApiKey) {\n queryParams.session_api_key = sessionApiKey;\n }\n\n return {\n queryParams,\n reconnect: { enabled: true },\n onOpen: () => {\n setMainConnectionState(\"OPEN\");\n hasConnectedRefMain.current = true; // Mark that we've successfully connected\n clearConnectionError(); // Clear a previous connection error; keep sticky conversation errors\n },\n onClose: () => {\n setMainConnectionState(\"CLOSED\");\n },\n onError: () => {\n setMainConnectionState(\"CLOSED\");\n // Only show error message if we've previously connected successfully\n if (hasConnectedRefMain.current) {\n setErrorMessage(SERVER_CONNECTION_ERROR_MESSAGE, \"connection\");\n }\n },\n onMessage: handleMainMessage,\n };\n }, [\n handleMainMessage,\n setErrorMessage,\n clearConnectionError,\n sessionApiKey,\n initialAfterTimestamp,\n ]);\n\n // Separate WebSocket options for planning agent connection\n const planningWebsocketOptions: WebSocketHookOptions = useMemo(() => {\n const queryParams: Record<string, string | boolean> = {\n resend_all: true,\n };\n\n // Add session_api_key if available\n if (sessionApiKey) {\n queryParams.session_api_key = sessionApiKey;\n }\n\n const planningAgentConversation = subConversations?.[0];\n\n return {\n queryParams,\n reconnect: { enabled: true },\n onOpen: async () => {\n setPlanningConnectionState(\"OPEN\");\n hasConnectedRefPlanning.current = true; // Mark that we've successfully connected\n clearConnectionError(); // Clear a previous connection error; keep sticky conversation errors\n\n // Fetch expected event count for history loading detection\n if (\n planningAgentConversation?.id &&\n planningAgentConversation.conversation_url\n ) {\n try {\n const count = await EventService.getEventCount(\n planningAgentConversation.id,\n planningAgentConversation.conversation_url,\n planningAgentConversation.session_api_key,\n );\n setExpectedEventCountPlanning(count);\n\n // If no events expected, mark as loaded immediately\n if (count === 0) {\n setIsLoadingHistoryPlanning(false);\n }\n } catch (error) {\n // Fall back to marking as loaded to avoid infinite loading state\n setIsLoadingHistoryPlanning(false);\n }\n }\n },\n onClose: () => {\n setPlanningConnectionState(\"CLOSED\");\n },\n onError: () => {\n setPlanningConnectionState(\"CLOSED\");\n // Only show error message if we've previously connected successfully\n if (hasConnectedRefPlanning.current) {\n setErrorMessage(SERVER_CONNECTION_ERROR_MESSAGE, \"connection\");\n }\n },\n onMessage: handlePlanningMessage,\n };\n }, [\n handlePlanningMessage,\n setErrorMessage,\n clearConnectionError,\n sessionApiKey,\n subConversations,\n ]);\n\n // Only attempt WebSocket connection when we have a valid URL\n // This prevents connection attempts during task polling phase\n const websocketUrl = wsUrl;\n const { socket: mainSocket, reconnect: reconnectMain } = useWebSocket(\n websocketUrl || \"\",\n mainWebsocketOptions,\n );\n\n const { socket: planningAgentSocket, reconnect: reconnectPlanning } =\n useWebSocket(planningAgentWsUrl || \"\", planningWebsocketOptions);\n\n const reconnect = useCallback(() => {\n removeErrorMessage();\n const currentMode = useConversationStore.getState().conversationMode;\n if (currentMode === \"plan\" && planningAgentWsUrl) {\n reconnectPlanning();\n return;\n }\n reconnectMain();\n }, [\n planningAgentWsUrl,\n reconnectMain,\n reconnectPlanning,\n removeErrorMessage,\n ]);\n\n // V1 send message function via WebSocket\n // Falls back to REST API queue when WebSocket is not connected\n const sendMessage = useCallback(\n async (message: SendMessageRequest): Promise<SendMessageResult> => {\n const currentMode = useConversationStore.getState().conversationMode;\n const currentSocket =\n currentMode === \"plan\" ? planningAgentSocket : mainSocket;\n\n if (currentSocket?.readyState !== WebSocket.OPEN) {\n // WebSocket not connected - queue message via REST API\n // Message will be delivered automatically when conversation becomes ready\n if (!conversationId) {\n const error = new Error(\"No conversation ID available\");\n setErrorMessage(error.message);\n throw error;\n }\n\n try {\n await new ConversationClient(getAgentServerClientOptions()).sendEvent(\n conversationId,\n {\n role: \"user\",\n content: message.content,\n },\n { run: true },\n );\n // Message queued successfully - it will be delivered when ready\n // Return queued: true so caller knows not to show optimistic UI\n return { queued: true };\n } catch (error) {\n const errorMessage =\n error instanceof Error\n ? error.message\n : \"Failed to queue message for delivery\";\n setErrorMessage(errorMessage);\n throw error;\n }\n }\n\n try {\n // Send message through WebSocket as JSON with run: true so the\n // agent loop starts automatically in async mode.\n currentSocket.send(JSON.stringify({ ...message, run: true }));\n return { queued: false };\n } catch (error) {\n const errorMessage =\n error instanceof Error ? error.message : \"Failed to send message\";\n setErrorMessage(errorMessage);\n throw error;\n }\n },\n [mainSocket, planningAgentSocket, setErrorMessage, conversationId],\n );\n\n // Track main socket state changes\n useEffect(() => {\n // Only process socket updates if we have a valid URL and socket\n if (mainSocket && wsUrl) {\n // Update state based on socket readyState\n const updateState = () => {\n switch (mainSocket.readyState) {\n case WebSocket.CONNECTING:\n setMainConnectionState(\"CONNECTING\");\n break;\n case WebSocket.OPEN:\n setMainConnectionState(\"OPEN\");\n break;\n case WebSocket.CLOSING:\n setMainConnectionState(\"CLOSING\");\n break;\n case WebSocket.CLOSED:\n setMainConnectionState(\"CLOSED\");\n break;\n default:\n setMainConnectionState(\"CLOSED\");\n break;\n }\n };\n\n updateState();\n }\n }, [mainSocket, wsUrl]);\n\n // Track planning agent socket state changes\n useEffect(() => {\n // Only process socket updates if we have a valid URL and socket\n if (planningAgentSocket && planningAgentWsUrl) {\n // Update state based on socket readyState\n const updateState = () => {\n switch (planningAgentSocket.readyState) {\n case WebSocket.CONNECTING:\n setPlanningConnectionState(\"CONNECTING\");\n break;\n case WebSocket.OPEN:\n setPlanningConnectionState(\"OPEN\");\n break;\n case WebSocket.CLOSING:\n setPlanningConnectionState(\"CLOSING\");\n break;\n case WebSocket.CLOSED:\n setPlanningConnectionState(\"CLOSED\");\n break;\n default:\n setPlanningConnectionState(\"CLOSED\");\n break;\n }\n };\n\n updateState();\n }\n }, [planningAgentSocket, planningAgentWsUrl]);\n\n const contextValue = useMemo(\n () => ({ connectionState, sendMessage, isLoadingHistory, reconnect }),\n [connectionState, sendMessage, isLoadingHistory, reconnect],\n );\n\n return (\n <ConversationWebSocketContext.Provider value={contextValue}>\n {children}\n </ConversationWebSocketContext.Provider>\n );\n}\n\nexport const useConversationWebSocket =\n (): ConversationWebSocketContextType | null => {\n const context = useContext(ConversationWebSocketContext);\n // Return null instead of throwing when not in provider\n // This allows the hook to be called conditionally based on conversation version\n return context || null;\n };\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"conversation-websocket-context.js","names":[],"sources":["../../src/contexts/conversation-websocket-context.tsx"],"sourcesContent":["import React, {\n createContext,\n useContext,\n useEffect,\n useLayoutEffect,\n useState,\n useCallback,\n useMemo,\n useRef,\n} from \"react\";\nimport { ConversationClient } from \"@openhands/typescript-client/clients\";\n\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport { usePostHog } from \"posthog-js/react\";\nimport { useWebSocket, WebSocketHookOptions } from \"#/hooks/use-websocket\";\nimport { SERVER_CONNECTION_ERROR_MESSAGE } from \"#/constants/server-connection-error\";\nimport { useEventStore } from \"#/stores/use-event-store\";\nimport { useErrorMessageStore } from \"#/stores/error-message-store\";\nimport { useOptimisticUserMessageStore } from \"#/stores/optimistic-user-message-store\";\nimport { useConversationStateStore } from \"#/stores/conversation-state-store\";\nimport { useCommandStore } from \"#/stores/command-store\";\nimport { useBrowserStore } from \"#/stores/browser-store\";\nimport {\n isAgentServerEvent,\n isAgentErrorEvent,\n isUserMessageEvent,\n isActionEvent,\n isConversationStateUpdateEvent,\n isFullStateConversationStateUpdateEvent,\n isAgentStatusConversationStateUpdateEvent,\n isStatsConversationStateUpdateEvent,\n isExecuteBashActionEvent,\n isExecuteBashObservationEvent,\n isDisplayableErrorEvent,\n isPlanningFileEditorObservationEvent,\n isBrowserObservationEvent,\n isBrowserNavigateActionEvent,\n isSwitchLLMObservationEvent,\n isCanvasUIActionEvent,\n} from \"#/types/agent-server/type-guards\";\nimport { handleCanvasUIAction } from \"#/services/canvas-ui\";\nimport { ConversationStateUpdateEventStats } from \"#/types/agent-server/core/events/conversation-state-event\";\nimport type {\n ConversationErrorEvent,\n ServerErrorEvent,\n} from \"#/types/agent-server/core/events/conversation-state-event\";\nimport { handleActionEventCacheInvalidation } from \"#/utils/cache-utils\";\nimport { buildWebSocketUrl } from \"#/utils/websocket-url\";\nimport type {\n AppConversation,\n SendMessageRequest,\n} from \"#/api/conversation-service/agent-server-conversation-service.types\";\nimport EventService from \"#/api/event-service/event-service.api\";\nimport { getAgentServerClientOptions } from \"#/api/agent-server-client-options\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { trackError } from \"#/utils/error-handler\";\nimport { useReadConversationFile } from \"#/hooks/mutation/use-read-conversation-file\";\nimport useMetricsStore from \"#/stores/metrics-store\";\nimport { useConversationHistory } from \"#/hooks/query/use-conversation-history\";\nimport { setConversationState } from \"#/utils/conversation-local-storage\";\nimport { recordModelSwitchMessage } from \"#/hooks/chat/record-model-switch-message\";\nimport {\n invalidateConversationQueries,\n updateConversationLlmModelInCache,\n} from \"#/hooks/mutation/conversation-mutation-utils\";\n\nexport type WebSocketConnectionState =\n | \"CONNECTING\"\n | \"OPEN\"\n | \"CLOSED\"\n | \"CLOSING\";\n\ninterface SendMessageResult {\n queued: boolean; // true if message was queued for later delivery, false if sent immediately\n}\n\ninterface ConversationWebSocketContextType {\n connectionState: WebSocketConnectionState;\n sendMessage: (message: SendMessageRequest) => Promise<SendMessageResult>;\n isLoadingHistory: boolean;\n reconnect: () => void;\n}\n\nconst ConversationWebSocketContext = createContext<\n ConversationWebSocketContextType | undefined\n>(undefined);\n\n/**\n * Extract the text body of an echoed user `MessageEvent` for matching against\n * the optimistic pending-message queue. The server wraps the original\n * `args.content` string in one or more `TextContent` entries (alongside any\n * `ImageContent` entries for inline images), so concatenating the `text`\n * fields gives us back the exact prompt we sent.\n */\nfunction extractMessageEventText(\n event: import(\"#/types/agent-server/core/events/message-event\").MessageEvent,\n): string {\n return event.llm_message.content\n .filter(\n (part): part is { type: \"text\"; text: string } => part.type === \"text\",\n )\n .map((part) => part.text)\n .join(\"\");\n}\n\nexport function ConversationWebSocketProvider({\n children,\n conversationId,\n conversationUrl,\n sessionApiKey,\n subConversations,\n subConversationIds,\n}: {\n children: React.ReactNode;\n conversationId?: string;\n conversationUrl?: string | null;\n sessionApiKey?: string | null;\n subConversations?: AppConversation[];\n subConversationIds?: string[];\n}) {\n // Separate connection state tracking for each WebSocket\n const [mainConnectionState, setMainConnectionState] =\n useState<WebSocketConnectionState>(\"CONNECTING\");\n const [planningConnectionState, setPlanningConnectionState] =\n useState<WebSocketConnectionState>(\"CONNECTING\");\n\n // Track if we've ever successfully connected for each connection\n // Don't show errors until after first successful connection\n const hasConnectedRefMain = React.useRef(false);\n const hasConnectedRefPlanning = React.useRef(false);\n\n const posthog = usePostHog();\n const queryClient = useQueryClient();\n const addEvent = useEventStore((state) => state.addEvent);\n const addEvents = useEventStore((state) => state.addEvents);\n const clearEventsForConversation = useEventStore(\n (state) => state.clearEventsForConversation,\n );\n const { setErrorMessage, removeErrorMessage, clearConnectionError } =\n useErrorMessageStore();\n const consumeMatchingPendingMessage = useOptimisticUserMessageStore(\n (state) => state.consumeMatchingPendingMessage,\n );\n const { setExecutionStatus } = useConversationStateStore();\n const { appendInput, appendOutput } = useCommandStore();\n const resetBrowserStore = useBrowserStore((state) => state.reset);\n\n // History loading state.\n // - Main conversation history is now loaded via REST (`useConversationHistory`),\n // so its loading state mirrors the REST query state (see below).\n // - Planning sub-conversation history still streams over the WebSocket using\n // `resend_mode='all'`, so we keep the count-based detection for it.\n const [isLoadingHistoryPlanning, setIsLoadingHistoryPlanning] =\n useState(true);\n const [expectedEventCountPlanning, setExpectedEventCountPlanning] = useState<\n number | null\n >(null);\n\n const { setPlanContent } = useConversationStore();\n\n // Hook for reading conversation file\n const { mutate: readConversationFile } = useReadConversationFile();\n\n // Track planning-agent received events (still WS-driven).\n const receivedEventCountRefPlanning = useRef(0);\n\n // Track the latest PlanningFileEditorObservation for Plan.md during history replay\n const latestPlanningFileEventRef = useRef<{\n path: string;\n conversationId: string;\n } | null>(null);\n\n const isPlanFilePath = (path: string | null): boolean =>\n path?.toUpperCase().endsWith(\"PLAN.MD\") ?? false;\n\n const handleNonErrorEvent = useCallback(() => {\n // A normal event means connectivity recovered: clear a transient connection\n // error, but keep sticky conversation errors (e.g. a wrong API key).\n clearConnectionError();\n }, [clearConnectionError]);\n\n // Helper function to update metrics from stats event\n const updateMetricsFromStats = useCallback(\n (event: ConversationStateUpdateEventStats) => {\n if (event.value.usage_to_metrics?.agent) {\n const agentMetrics = event.value.usage_to_metrics.agent;\n const metrics = {\n cost: agentMetrics.accumulated_cost,\n max_budget_per_task: agentMetrics.max_budget_per_task ?? null,\n usage: agentMetrics.accumulated_token_usage\n ? {\n prompt_tokens:\n agentMetrics.accumulated_token_usage.prompt_tokens,\n completion_tokens:\n agentMetrics.accumulated_token_usage.completion_tokens,\n cache_read_tokens:\n agentMetrics.accumulated_token_usage.cache_read_tokens,\n cache_write_tokens:\n agentMetrics.accumulated_token_usage.cache_write_tokens,\n context_window:\n agentMetrics.accumulated_token_usage.context_window,\n per_turn_token:\n agentMetrics.accumulated_token_usage.per_turn_token,\n }\n : null,\n };\n useMetricsStore.getState().setMetrics(metrics);\n }\n },\n [],\n );\n\n // Initial REST history load: fetch the most recent events and seed the\n // store. Older events are paginated in via `useLoadOlderEvents` when the\n // user scrolls to the top of the chat. The WebSocket connection waits for\n // this query so it can subscribe with `resend_mode='since'` and avoid\n // re-streaming everything REST already returned.\n const {\n data: preloadedHistory,\n isPending: isPreloadingHistory,\n isError: isPreloadHistoryError,\n } = useConversationHistory(conversationId);\n\n const isLoadingHistoryMain = !!conversationId && isPreloadingHistory;\n\n // Clear the (global, not conversation-scoped) event store when the active\n // conversation changes, BEFORE the preloaded-history effect below re-seeds\n // it. This MUST live here rather than in the route component: a parent's\n // passive effect runs *after* this child's layout effects, so clearing from\n // the route would wipe the freshly seeded history. On a conversation switch\n // the history page is already cached, so `preloadedHistory` is available\n // synchronously — without ordering the clear first, the user's already-echoed\n // message gets seeded then immediately wiped, leaving only the `since`\n // WebSocket resend (the agent's reply). Re-entering the same conversation is\n // a no-op, so the store survives navigating away to Settings and back.\n useLayoutEffect(() => {\n const nextId = conversationId ?? null;\n if (useEventStore.getState().loadedConversationId === nextId) {\n return;\n }\n // Single atomic action: clears the previous conversation's events and\n // records the new loaded id in one `set`, so no subscriber can observe a\n // half-applied state (events gone but the old id still reported).\n clearEventsForConversation(nextId);\n resetBrowserStore();\n }, [conversationId, clearEventsForConversation, resetBrowserStore]);\n\n useLayoutEffect(() => {\n if (!preloadedHistory || preloadedHistory.events.length === 0) {\n return;\n }\n addEvents(preloadedHistory.events);\n\n // The first user message of a cloud start-task conversation is persisted\n // server-side and reaches us via this REST preload, not over the WebSocket\n // (which subscribes with resend_mode='since' after the latest preloaded\n // timestamp). Consume any matching optimistic \"Sending…\" bubble here too —\n // mirroring the WS handler — so it doesn't linger as a duplicate of the echo.\n if (conversationId) {\n for (const event of preloadedHistory.events) {\n if (isUserMessageEvent(event)) {\n consumeMatchingPendingMessage(\n conversationId,\n extractMessageEventText(event),\n );\n }\n }\n }\n }, [\n preloadedHistory,\n addEvents,\n conversationId,\n consumeMatchingPendingMessage,\n ]);\n\n /**\n * Timestamp of the latest event we already have from REST. Used as\n * `after_timestamp` when opening the WebSocket so the server only resends\n * events strictly after this point. `null` until the REST query settles\n * (we hold the WS connection open until then to avoid an `all` resend).\n */\n const initialAfterTimestamp = useMemo<string | null>(() => {\n if (isPreloadingHistory) return null;\n const events = preloadedHistory?.events ?? [];\n const latest = events[events.length - 1];\n if (!latest || !(\"timestamp\" in latest) || !latest.timestamp) return null;\n return latest.timestamp;\n }, [preloadedHistory, isPreloadingHistory]);\n\n // Build WebSocket URL from props.\n //\n // We deliberately wait for the initial REST history fetch to settle before\n // opening the socket so the WS subscription can use `resend_mode='since'`\n // with a meaningful `after_timestamp`. Without this gate, the WS would open\n // immediately and either replay the entire conversation (when falling back\n // to `resend_mode='all'`) or miss events that arrived between REST and WS.\n const wsUrl = useMemo(() => {\n if (!conversationId || !conversationUrl) {\n return null;\n }\n // Don't connect while we're still fetching the initial history. If the\n // REST query errored we fall through and connect with `resend_mode='all'`\n // so the user still sees live events.\n if (isPreloadingHistory && !isPreloadHistoryError) {\n return null;\n }\n return buildWebSocketUrl(conversationId, conversationUrl);\n }, [\n conversationId,\n conversationUrl,\n isPreloadingHistory,\n isPreloadHistoryError,\n ]);\n\n const planningAgentWsUrl = useMemo(() => {\n if (!subConversations?.length) {\n return null;\n }\n\n // Currently, there is only one sub-conversation and it uses the planning agent.\n const planningAgentConversation = subConversations[0];\n\n if (\n !planningAgentConversation?.id ||\n !planningAgentConversation.conversation_url\n ) {\n return null;\n }\n\n return buildWebSocketUrl(\n planningAgentConversation.id,\n planningAgentConversation.conversation_url,\n );\n }, [subConversations]);\n\n // Merged connection state - reflects combined status of both connections\n const connectionState = useMemo<WebSocketConnectionState>(() => {\n // If planning agent connection doesn't exist, use main connection state\n if (!planningAgentWsUrl) {\n return mainConnectionState;\n }\n\n // If either is connecting, merged state is connecting\n if (\n mainConnectionState === \"CONNECTING\" ||\n planningConnectionState === \"CONNECTING\"\n ) {\n return \"CONNECTING\";\n }\n\n // If both are open, merged state is open\n if (mainConnectionState === \"OPEN\" && planningConnectionState === \"OPEN\") {\n return \"OPEN\";\n }\n\n // If both are closed, merged state is closed\n if (\n mainConnectionState === \"CLOSED\" &&\n planningConnectionState === \"CLOSED\"\n ) {\n return \"CLOSED\";\n }\n\n // If either is closing, merged state is closing\n if (\n mainConnectionState === \"CLOSING\" ||\n planningConnectionState === \"CLOSING\"\n ) {\n return \"CLOSING\";\n }\n\n // Default to closed if states don't match expected patterns\n return \"CLOSED\";\n }, [mainConnectionState, planningConnectionState, planningAgentWsUrl]);\n\n useEffect(() => {\n if (\n expectedEventCountPlanning !== null &&\n receivedEventCountRefPlanning.current >= expectedEventCountPlanning &&\n isLoadingHistoryPlanning\n ) {\n setIsLoadingHistoryPlanning(false);\n }\n }, [\n expectedEventCountPlanning,\n isLoadingHistoryPlanning,\n receivedEventCountRefPlanning,\n ]);\n\n // Call API once after history loading completes if we tracked any PlanningFileEditorObservation events\n useEffect(() => {\n if (!isLoadingHistoryPlanning && latestPlanningFileEventRef.current) {\n const { path, conversationId: currentPlanningConversationId } =\n latestPlanningFileEventRef.current;\n\n readConversationFile(\n {\n conversationId: currentPlanningConversationId,\n filePath: path,\n },\n {\n onSuccess: (fileContent) => {\n setPlanContent(fileContent);\n },\n onError: (error) => {\n console.warn(\"Failed to read conversation file:\", error);\n },\n },\n );\n\n // Clear the ref after calling the API\n latestPlanningFileEventRef.current = null;\n }\n }, [isLoadingHistoryPlanning, readConversationFile, setPlanContent]);\n\n useEffect(() => {\n hasConnectedRefMain.current = false;\n setIsLoadingHistoryPlanning(!!subConversationIds?.length);\n setExpectedEventCountPlanning(null);\n receivedEventCountRefPlanning.current = 0;\n // Reset the tracked event ref when sub-conversations change\n latestPlanningFileEventRef.current = null;\n }, [subConversationIds]);\n\n // Reset hasConnected flags when the conversation changes.\n useEffect(() => {\n hasConnectedRefMain.current = false;\n hasConnectedRefPlanning.current = false;\n // Reset the tracked event ref when conversation changes\n latestPlanningFileEventRef.current = null;\n }, [conversationId]);\n\n // Merged loading history state - true if either connection is still loading\n const isLoadingHistory = useMemo(\n () => isLoadingHistoryMain || isLoadingHistoryPlanning,\n [isLoadingHistoryMain, isLoadingHistoryPlanning],\n );\n\n // Separate message handlers for each connection\n const handleMainMessage = useCallback(\n (messageEvent: MessageEvent) => {\n try {\n const event = JSON.parse(messageEvent.data);\n\n // History loading for the main conversation is REST-driven now;\n // every WS message is a new event we add to the store.\n\n // Use type guard to validate v1 event structure\n if (isAgentServerEvent(event)) {\n const isDuplicateEvent = useEventStore\n .getState()\n .eventIds.has(event.id);\n const switchLLMObservation =\n !isDuplicateEvent && isSwitchLLMObservationEvent(event)\n ? event\n : null;\n addEvent(event);\n\n // Handle displayable error events - show error banner\n // AgentErrorEvent errors are displayed inline in the chat, not as banners\n if (isDisplayableErrorEvent(event)) {\n const errorEvent = event as\n | ConversationErrorEvent\n | ServerErrorEvent;\n trackError({\n message: errorEvent.detail,\n source: \"conversation\",\n metadata: {\n eventId: errorEvent.id,\n errorCode: errorEvent.code,\n },\n posthog,\n });\n setErrorMessage(errorEvent.detail);\n } else {\n handleNonErrorEvent();\n }\n\n // LLM errors render inline in the chat (see ErrorEventMessage); track\n // them for analytics but keep them out of the banner above the chat box.\n if (isAgentErrorEvent(event)) {\n trackError({\n message: event.error,\n source: \"agent\",\n metadata: {\n eventId: event.id,\n toolName: event.tool_name,\n toolCallId: event.tool_call_id,\n },\n posthog,\n });\n }\n\n // Clear optimistic user message when a user message is confirmed.\n // We match by the echoed text content (with FIFO fallback inside the\n // store), so an echo for \"second\" pops \"second\" — not whichever\n // pending entry happens to be oldest — protecting against any\n // out-of-order delivery between conversations or sub-agents.\n if (isUserMessageEvent(event)) {\n if (conversationId) {\n consumeMatchingPendingMessage(\n conversationId,\n extractMessageEventText(event),\n );\n // Clear draft from localStorage - message was successfully delivered\n setConversationState(conversationId, { draftMessage: null });\n }\n }\n\n // Handle cache invalidation for ActionEvent\n if (isActionEvent(event)) {\n const currentConversationId =\n conversationId || \"test-conversation-id\"; // TODO: Get from context\n handleActionEventCacheInvalidation(\n event,\n currentConversationId,\n queryClient,\n );\n }\n\n // Handle conversation state updates\n // TODO: Tests\n if (isConversationStateUpdateEvent(event)) {\n if (isFullStateConversationStateUpdateEvent(event)) {\n setExecutionStatus(event.value.execution_status);\n }\n if (isAgentStatusConversationStateUpdateEvent(event)) {\n setExecutionStatus(event.value);\n }\n if (isStatsConversationStateUpdateEvent(event)) {\n updateMetricsFromStats(event);\n }\n }\n\n // Handle ExecuteBashAction events - add command as input to terminal\n if (isExecuteBashActionEvent(event)) {\n appendInput(event.action.command);\n }\n\n // Handle ExecuteBashObservation events - add output to terminal\n if (isExecuteBashObservationEvent(event)) {\n // Extract text content from the observation content array\n const textContent = event.observation.content\n .filter((c) => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\\n\");\n appendOutput(textContent);\n }\n\n // Handle BrowserObservation events - update browser store with screenshot\n if (isBrowserObservationEvent(event)) {\n const { screenshot_data: screenshotData } = event.observation;\n if (screenshotData) {\n const screenshotSrc = screenshotData.startsWith(\"data:\")\n ? screenshotData\n : `data:image/png;base64,${screenshotData}`;\n useBrowserStore.getState().setScreenshotSrc(screenshotSrc);\n }\n }\n\n // Handle BrowserNavigateAction events - update browser store with URL\n if (isBrowserNavigateActionEvent(event)) {\n useBrowserStore.getState().setUrl(event.action.url);\n }\n\n if (\n conversationId &&\n switchLLMObservation &&\n !switchLLMObservation.observation.is_error\n ) {\n recordModelSwitchMessage(\n conversationId,\n switchLLMObservation.observation.profile_name,\n );\n\n if (switchLLMObservation.observation.active_model) {\n updateConversationLlmModelInCache(\n queryClient,\n conversationId,\n switchLLMObservation.observation.active_model,\n );\n }\n\n invalidateConversationQueries(queryClient, conversationId);\n }\n\n // Handle canvas_ui custom-tool ActionEvents - drive the frontend\n // (navigate to a file, switch tabs, show a preview). The tool\n // executes server-side as a no-op; the actual UI change happens\n // here on the client.\n if (isCanvasUIActionEvent(event)) {\n handleCanvasUIAction(event.action);\n }\n }\n } catch (error) {\n console.warn(\"Failed to parse WebSocket message as JSON:\", error);\n }\n },\n [\n addEvent,\n setErrorMessage,\n consumeMatchingPendingMessage,\n queryClient,\n conversationId,\n setExecutionStatus,\n appendInput,\n appendOutput,\n updateMetricsFromStats,\n handleNonErrorEvent,\n posthog,\n ],\n );\n\n const handlePlanningMessage = useCallback(\n (messageEvent: MessageEvent) => {\n try {\n const event = JSON.parse(messageEvent.data);\n\n // Track received events for history loading (count ALL events from WebSocket)\n // Always count when loading, even if we don't have the expected count yet\n if (isLoadingHistoryPlanning) {\n receivedEventCountRefPlanning.current += 1;\n\n if (\n expectedEventCountPlanning !== null &&\n receivedEventCountRefPlanning.current >= expectedEventCountPlanning\n ) {\n setIsLoadingHistoryPlanning(false);\n }\n }\n\n // Use type guard to validate v1 event structure\n if (isAgentServerEvent(event)) {\n // Mark this event as coming from the planning agent\n const eventWithPlanningFlag = {\n ...event,\n isFromPlanningAgent: true,\n };\n addEvent(eventWithPlanningFlag);\n\n // Handle displayable error events - show error banner\n // AgentErrorEvent errors are displayed inline in the chat, not as banners\n if (isDisplayableErrorEvent(event)) {\n const errorEvent = event as\n | ConversationErrorEvent\n | ServerErrorEvent;\n trackError({\n message: errorEvent.detail,\n source: \"planning_conversation\",\n metadata: {\n eventId: errorEvent.id,\n errorCode: errorEvent.code,\n },\n posthog,\n });\n setErrorMessage(errorEvent.detail);\n } else {\n handleNonErrorEvent();\n }\n\n // LLM errors render inline in the chat (see ErrorEventMessage); track\n // them for analytics but keep them out of the banner above the chat box.\n if (isAgentErrorEvent(event)) {\n trackError({\n message: event.error,\n source: \"planning_agent\",\n metadata: {\n eventId: event.id,\n toolName: event.tool_name,\n toolCallId: event.tool_call_id,\n },\n posthog,\n });\n }\n\n // Clear optimistic user message when a user message is confirmed.\n // Always scope to the main `conversationId` (where the user types)\n // and match on the echoed content so the planning sub-agent's own\n // events can never consume a main-conversation pending entry.\n if (isUserMessageEvent(event)) {\n if (conversationId) {\n consumeMatchingPendingMessage(\n conversationId,\n extractMessageEventText(event),\n );\n setConversationState(conversationId, { draftMessage: null });\n }\n }\n\n // Handle cache invalidation for ActionEvent\n if (isActionEvent(event)) {\n const planningAgentConversation = subConversations?.[0];\n const currentConversationId =\n planningAgentConversation?.id || \"test-conversation-id\"; // TODO: Get from context\n handleActionEventCacheInvalidation(\n event,\n currentConversationId,\n queryClient,\n );\n }\n\n // Handle conversation state updates\n // TODO: Tests\n if (isConversationStateUpdateEvent(event)) {\n if (isFullStateConversationStateUpdateEvent(event)) {\n setExecutionStatus(event.value.execution_status);\n }\n if (isAgentStatusConversationStateUpdateEvent(event)) {\n setExecutionStatus(event.value);\n }\n if (isStatsConversationStateUpdateEvent(event)) {\n updateMetricsFromStats(event);\n }\n }\n\n // Handle ExecuteBashAction events - add command as input to terminal\n if (isExecuteBashActionEvent(event)) {\n appendInput(event.action.command);\n }\n\n // Handle ExecuteBashObservation events - add output to terminal\n if (isExecuteBashObservationEvent(event)) {\n // Extract text content from the observation content array\n const textContent = event.observation.content\n .filter((c) => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\\n\");\n appendOutput(textContent);\n }\n\n // Handle PlanningFileEditorObservation - only update plan for Plan.md\n if (isPlanningFileEditorObservationEvent(event)) {\n const { path } = event.observation;\n if (isPlanFilePath(path)) {\n const planningAgentConversation = subConversations?.[0];\n const planningConversationId = planningAgentConversation?.id;\n\n if (planningConversationId && path) {\n if (isLoadingHistoryPlanning) {\n latestPlanningFileEventRef.current = {\n path,\n conversationId: planningConversationId,\n };\n } else {\n readConversationFile(\n {\n conversationId: planningConversationId,\n filePath: path,\n },\n {\n onSuccess: (fileContent) => {\n setPlanContent(fileContent);\n },\n onError: (error) => {\n console.warn(\n \"Failed to read conversation file:\",\n error,\n );\n },\n },\n );\n }\n }\n }\n }\n }\n } catch (error) {\n console.warn(\"Failed to parse WebSocket message as JSON:\", error);\n }\n },\n [\n addEvent,\n isLoadingHistoryPlanning,\n expectedEventCountPlanning,\n setErrorMessage,\n consumeMatchingPendingMessage,\n queryClient,\n subConversations,\n conversationId,\n setExecutionStatus,\n appendInput,\n appendOutput,\n readConversationFile,\n setPlanContent,\n updateMetricsFromStats,\n handleNonErrorEvent,\n posthog,\n ],\n );\n\n // Separate WebSocket options for main connection\n const mainWebsocketOptions: WebSocketHookOptions = useMemo(() => {\n // History was already loaded over REST (`useConversationHistory`).\n // Subscribe with `resend_mode='since'` so the server only resends events\n // strictly after the latest one we already have. If REST returned no\n // events at all (brand-new conversation), fall back to `'all'` so any\n // events that may have been written between the REST call and the WS\n // handshake still show up. Dedup in the event store handles overlap.\n const queryParams: Record<string, string | boolean> = initialAfterTimestamp\n ? { resend_mode: \"since\", after_timestamp: initialAfterTimestamp }\n : { resend_mode: \"all\" };\n\n // Add session_api_key if available\n if (sessionApiKey) {\n queryParams.session_api_key = sessionApiKey;\n }\n\n return {\n queryParams,\n reconnect: { enabled: true },\n onOpen: () => {\n setMainConnectionState(\"OPEN\");\n hasConnectedRefMain.current = true; // Mark that we've successfully connected\n clearConnectionError(); // Clear a previous connection error; keep sticky conversation errors\n },\n onClose: () => {\n setMainConnectionState(\"CLOSED\");\n },\n onError: () => {\n setMainConnectionState(\"CLOSED\");\n // Only show error message if we've previously connected successfully\n if (hasConnectedRefMain.current) {\n setErrorMessage(SERVER_CONNECTION_ERROR_MESSAGE, \"connection\");\n }\n },\n onMessage: handleMainMessage,\n };\n }, [\n handleMainMessage,\n setErrorMessage,\n clearConnectionError,\n sessionApiKey,\n initialAfterTimestamp,\n ]);\n\n // Separate WebSocket options for planning agent connection\n const planningWebsocketOptions: WebSocketHookOptions = useMemo(() => {\n const queryParams: Record<string, string | boolean> = {\n resend_all: true,\n };\n\n // Add session_api_key if available\n if (sessionApiKey) {\n queryParams.session_api_key = sessionApiKey;\n }\n\n const planningAgentConversation = subConversations?.[0];\n\n return {\n queryParams,\n reconnect: { enabled: true },\n onOpen: async () => {\n setPlanningConnectionState(\"OPEN\");\n hasConnectedRefPlanning.current = true; // Mark that we've successfully connected\n clearConnectionError(); // Clear a previous connection error; keep sticky conversation errors\n\n // Fetch expected event count for history loading detection\n if (\n planningAgentConversation?.id &&\n planningAgentConversation.conversation_url\n ) {\n try {\n const count = await EventService.getEventCount(\n planningAgentConversation.id,\n planningAgentConversation.conversation_url,\n planningAgentConversation.session_api_key,\n );\n setExpectedEventCountPlanning(count);\n\n // If no events expected, mark as loaded immediately\n if (count === 0) {\n setIsLoadingHistoryPlanning(false);\n }\n } catch (error) {\n // Fall back to marking as loaded to avoid infinite loading state\n setIsLoadingHistoryPlanning(false);\n }\n }\n },\n onClose: () => {\n setPlanningConnectionState(\"CLOSED\");\n },\n onError: () => {\n setPlanningConnectionState(\"CLOSED\");\n // Only show error message if we've previously connected successfully\n if (hasConnectedRefPlanning.current) {\n setErrorMessage(SERVER_CONNECTION_ERROR_MESSAGE, \"connection\");\n }\n },\n onMessage: handlePlanningMessage,\n };\n }, [\n handlePlanningMessage,\n setErrorMessage,\n clearConnectionError,\n sessionApiKey,\n subConversations,\n ]);\n\n // Only attempt WebSocket connection when we have a valid URL\n // This prevents connection attempts during task polling phase\n const websocketUrl = wsUrl;\n const { socket: mainSocket, reconnect: reconnectMain } = useWebSocket(\n websocketUrl || \"\",\n mainWebsocketOptions,\n );\n\n const { socket: planningAgentSocket, reconnect: reconnectPlanning } =\n useWebSocket(planningAgentWsUrl || \"\", planningWebsocketOptions);\n\n const reconnect = useCallback(() => {\n removeErrorMessage();\n const currentMode = useConversationStore.getState().conversationMode;\n if (currentMode === \"plan\" && planningAgentWsUrl) {\n reconnectPlanning();\n return;\n }\n reconnectMain();\n }, [\n planningAgentWsUrl,\n reconnectMain,\n reconnectPlanning,\n removeErrorMessage,\n ]);\n\n // V1 send message function via WebSocket\n // Falls back to REST API queue when WebSocket is not connected\n const sendMessage = useCallback(\n async (message: SendMessageRequest): Promise<SendMessageResult> => {\n const currentMode = useConversationStore.getState().conversationMode;\n const currentSocket =\n currentMode === \"plan\" ? planningAgentSocket : mainSocket;\n\n if (currentSocket?.readyState !== WebSocket.OPEN) {\n // WebSocket not connected - queue message via REST API\n // Message will be delivered automatically when conversation becomes ready\n if (!conversationId) {\n const error = new Error(\"No conversation ID available\");\n setErrorMessage(error.message);\n throw error;\n }\n\n try {\n await new ConversationClient(getAgentServerClientOptions()).sendEvent(\n conversationId,\n {\n role: \"user\",\n content: message.content,\n },\n { run: true },\n );\n // Message queued successfully - it will be delivered when ready\n // Return queued: true so caller knows not to show optimistic UI\n return { queued: true };\n } catch (error) {\n const errorMessage =\n error instanceof Error\n ? error.message\n : \"Failed to queue message for delivery\";\n setErrorMessage(errorMessage);\n throw error;\n }\n }\n\n try {\n // Send message through WebSocket as JSON with run: true so the\n // agent loop starts automatically in async mode.\n currentSocket.send(JSON.stringify({ ...message, run: true }));\n return { queued: false };\n } catch (error) {\n const errorMessage =\n error instanceof Error ? error.message : \"Failed to send message\";\n setErrorMessage(errorMessage);\n throw error;\n }\n },\n [mainSocket, planningAgentSocket, setErrorMessage, conversationId],\n );\n\n // Track main socket state changes\n useEffect(() => {\n // Only process socket updates if we have a valid URL and socket\n if (mainSocket && wsUrl) {\n // Update state based on socket readyState\n const updateState = () => {\n switch (mainSocket.readyState) {\n case WebSocket.CONNECTING:\n setMainConnectionState(\"CONNECTING\");\n break;\n case WebSocket.OPEN:\n setMainConnectionState(\"OPEN\");\n break;\n case WebSocket.CLOSING:\n setMainConnectionState(\"CLOSING\");\n break;\n case WebSocket.CLOSED:\n setMainConnectionState(\"CLOSED\");\n break;\n default:\n setMainConnectionState(\"CLOSED\");\n break;\n }\n };\n\n updateState();\n }\n }, [mainSocket, wsUrl]);\n\n // Track planning agent socket state changes\n useEffect(() => {\n // Only process socket updates if we have a valid URL and socket\n if (planningAgentSocket && planningAgentWsUrl) {\n // Update state based on socket readyState\n const updateState = () => {\n switch (planningAgentSocket.readyState) {\n case WebSocket.CONNECTING:\n setPlanningConnectionState(\"CONNECTING\");\n break;\n case WebSocket.OPEN:\n setPlanningConnectionState(\"OPEN\");\n break;\n case WebSocket.CLOSING:\n setPlanningConnectionState(\"CLOSING\");\n break;\n case WebSocket.CLOSED:\n setPlanningConnectionState(\"CLOSED\");\n break;\n default:\n setPlanningConnectionState(\"CLOSED\");\n break;\n }\n };\n\n updateState();\n }\n }, [planningAgentSocket, planningAgentWsUrl]);\n\n const contextValue = useMemo(\n () => ({ connectionState, sendMessage, isLoadingHistory, reconnect }),\n [connectionState, sendMessage, isLoadingHistory, reconnect],\n );\n\n return (\n <ConversationWebSocketContext.Provider value={contextValue}>\n {children}\n </ConversationWebSocketContext.Provider>\n );\n}\n\nexport const useConversationWebSocket =\n (): ConversationWebSocketContextType | null => {\n const context = useContext(ConversationWebSocketContext);\n // Return null instead of throwing when not in provider\n // This allows the hook to be called conditionally based on conversation version\n return context || null;\n };\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-model-interceptor.cjs","names":[],"sources":["../../../src/hooks/chat/use-model-interceptor.ts"],"sourcesContent":["import { useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport ProfilesService from \"#/api/profiles-service/profiles-service.api\";\nimport { useSwitchLlmProfileAndLog } from \"#/hooks/mutation/use-switch-llm-profile-and-log\";\nimport { getLastRenderableEventId } from \"#/hooks/chat/model-command-event-anchor\";\nimport { LLM_PROFILES_QUERY_KEYS } from \"#/hooks/query/query-keys\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useModelStore } from \"#/stores/model-store\";\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport { MODEL_COMMAND } from \"#/utils/constants\";\n\nconst MODEL_PREFIX = `${MODEL_COMMAND} `;\n\n/**\n * Intercepts \"/model\" submissions:\n * - \"/model\" → render an inline list of saved profiles in the chat\n * - \"/model <name>\" → switch the running conversation's LLM profile\n * Anything else (or when on a cloud backend, which doesn't support profiles)\n * falls through to `onSubmit`.\n */\nexport const useModelInterceptor = (\n conversationId: string | null | undefined,\n onSubmit: (message: string) => void,\n) => {\n const showProfiles = useModelStore((s) => s.show);\n const queryClient = useQueryClient();\n const { switchAndLog } = useSwitchLlmProfileAndLog();\n const { backend, orgId } = useActiveBackend();\n const isLocal = backend.kind === \"local\";\n const { t } = useTranslation();\n\n return useCallback(\n (message: string) => {\n const trimmed = message.trim();\n const isModel =\n trimmed === MODEL_COMMAND || trimmed.startsWith(MODEL_PREFIX);\n if (!isModel || !isLocal) {\n onSubmit(message);\n return;\n }\n\n const arg = trimmed.slice(MODEL_COMMAND.length).trim();\n\n if (arg) {\n //
|
|
1
|
+
{"version":3,"file":"use-model-interceptor.cjs","names":[],"sources":["../../../src/hooks/chat/use-model-interceptor.ts"],"sourcesContent":["import { useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport ProfilesService from \"#/api/profiles-service/profiles-service.api\";\nimport { useSwitchLlmProfileAndLog } from \"#/hooks/mutation/use-switch-llm-profile-and-log\";\nimport { getLastRenderableEventId } from \"#/hooks/chat/model-command-event-anchor\";\nimport { LLM_PROFILES_QUERY_KEYS } from \"#/hooks/query/query-keys\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useModelStore } from \"#/stores/model-store\";\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport { MODEL_COMMAND } from \"#/utils/constants\";\n\nconst MODEL_PREFIX = `${MODEL_COMMAND} `;\n\n/**\n * Intercepts \"/model\" submissions:\n * - \"/model\" → render an inline list of saved profiles in the chat\n * - \"/model <name>\" → switch the running conversation's LLM profile\n * Anything else (or when on a cloud backend, which doesn't support profiles)\n * falls through to `onSubmit`.\n */\nexport const useModelInterceptor = (\n conversationId: string | null | undefined,\n onSubmit: (message: string) => void,\n) => {\n const showProfiles = useModelStore((s) => s.show);\n const queryClient = useQueryClient();\n const { switchAndLog } = useSwitchLlmProfileAndLog();\n const { backend, orgId } = useActiveBackend();\n const isLocal = backend.kind === \"local\";\n const { t } = useTranslation();\n\n return useCallback(\n (message: string) => {\n const trimmed = message.trim();\n const isModel =\n trimmed === MODEL_COMMAND || trimmed.startsWith(MODEL_PREFIX);\n if (!isModel || !isLocal) {\n onSubmit(message);\n return;\n }\n\n const arg = trimmed.slice(MODEL_COMMAND.length).trim();\n\n if (arg) {\n // Switch the running conversation when one is open; otherwise activate\n // the profile globally so the next conversation starts with it.\n switchAndLog(conversationId ?? null, arg);\n return;\n }\n\n // Bare `/model` — list profiles inline. Needs a conversation to anchor\n // the entry to; swallow silently on the home page.\n if (!conversationId) return;\n\n const anchorEventId = getLastRenderableEventId();\n\n // Imperative fetch through the query cache so the result lands on the\n // same key `useLlmProfiles` reads. `staleTime: 0` forces a fresh fetch\n // each time the user types /model.\n // Multiple rapid /model submissions intentionally append multiple chat\n // entries, matching normal command history behavior rather than replacing\n // earlier results.\n\n queryClient\n .fetchQuery({\n queryKey: [...LLM_PROFILES_QUERY_KEYS.all, backend.id, orgId],\n queryFn: ProfilesService.listProfiles,\n staleTime: 0,\n })\n .then(({ profiles }) =>\n showProfiles(conversationId, anchorEventId, profiles),\n )\n .catch((err: unknown) => {\n const fallback = t(I18nKey.MODEL$LIST_FAILED);\n const messageText =\n err instanceof Error && err.message ? err.message : fallback;\n displayErrorToast(messageText);\n });\n },\n [\n conversationId,\n isLocal,\n onSubmit,\n showProfiles,\n queryClient,\n switchAndLog,\n backend.id,\n orgId,\n t,\n ],\n );\n};\n"],"mappings":"oqBAaA,IAAM,EAAe,GAAG,EAAA,cAAc,GASzB,GACX,EACA,IACG,CACH,IAAM,EAAe,EAAA,cAAe,GAAM,EAAE,KAAK,CAC3C,EAAc,EAAA,gBAAgB,CAC9B,CAAE,gBAAiB,EAAA,2BAA2B,CAC9C,CAAE,UAAS,SAAU,EAAA,kBAAkB,CACvC,EAAU,EAAQ,OAAS,QAC3B,CAAE,KAAM,EAAA,gBAAgB,CAE9B,OAAA,EAAA,EAAA,aACG,GAAoB,CACnB,IAAM,EAAU,EAAQ,MAAM,CAG9B,GAAI,EADF,IAAA,UAA6B,EAAQ,WAAW,EAAa,GAC/C,CAAC,EAAS,CACxB,EAAS,EAAQ,CACjB,OAGF,IAAM,EAAM,EAAQ,MAAM,EAAA,cAAc,OAAO,CAAC,MAAM,CAEtD,GAAI,EAAK,CAGP,EAAa,GAAkB,KAAM,EAAI,CACzC,OAKF,GAAI,CAAC,EAAgB,OAErB,IAAM,EAAgB,EAAA,0BAA0B,CAShD,EACG,WAAW,CACV,SAAU,CAAC,GAAG,EAAA,wBAAwB,IAAK,EAAQ,GAAI,EAAM,CAC7D,QAAS,EAAA,QAAgB,aACzB,UAAW,EACZ,CAAC,CACD,MAAM,CAAE,cACP,EAAa,EAAgB,EAAe,EAAS,CACtD,CACA,MAAO,GAAiB,CACvB,IAAM,EAAW,EAAE,EAAA,QAAQ,kBAAkB,CAG7C,EAAA,kBADE,aAAe,OAAS,EAAI,QAAU,EAAI,QAAU,EACxB,EAC9B,EAEN,CACE,EACA,EACA,EACA,EACA,EACA,EACA,EAAQ,GACR,EACA,EACD,CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-model-interceptor.js","names":[],"sources":["../../../src/hooks/chat/use-model-interceptor.ts"],"sourcesContent":["import { useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport ProfilesService from \"#/api/profiles-service/profiles-service.api\";\nimport { useSwitchLlmProfileAndLog } from \"#/hooks/mutation/use-switch-llm-profile-and-log\";\nimport { getLastRenderableEventId } from \"#/hooks/chat/model-command-event-anchor\";\nimport { LLM_PROFILES_QUERY_KEYS } from \"#/hooks/query/query-keys\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useModelStore } from \"#/stores/model-store\";\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport { MODEL_COMMAND } from \"#/utils/constants\";\n\nconst MODEL_PREFIX = `${MODEL_COMMAND} `;\n\n/**\n * Intercepts \"/model\" submissions:\n * - \"/model\" → render an inline list of saved profiles in the chat\n * - \"/model <name>\" → switch the running conversation's LLM profile\n * Anything else (or when on a cloud backend, which doesn't support profiles)\n * falls through to `onSubmit`.\n */\nexport const useModelInterceptor = (\n conversationId: string | null | undefined,\n onSubmit: (message: string) => void,\n) => {\n const showProfiles = useModelStore((s) => s.show);\n const queryClient = useQueryClient();\n const { switchAndLog } = useSwitchLlmProfileAndLog();\n const { backend, orgId } = useActiveBackend();\n const isLocal = backend.kind === \"local\";\n const { t } = useTranslation();\n\n return useCallback(\n (message: string) => {\n const trimmed = message.trim();\n const isModel =\n trimmed === MODEL_COMMAND || trimmed.startsWith(MODEL_PREFIX);\n if (!isModel || !isLocal) {\n onSubmit(message);\n return;\n }\n\n const arg = trimmed.slice(MODEL_COMMAND.length).trim();\n\n if (arg) {\n //
|
|
1
|
+
{"version":3,"file":"use-model-interceptor.js","names":[],"sources":["../../../src/hooks/chat/use-model-interceptor.ts"],"sourcesContent":["import { useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useQueryClient } from \"@tanstack/react-query\";\nimport ProfilesService from \"#/api/profiles-service/profiles-service.api\";\nimport { useSwitchLlmProfileAndLog } from \"#/hooks/mutation/use-switch-llm-profile-and-log\";\nimport { getLastRenderableEventId } from \"#/hooks/chat/model-command-event-anchor\";\nimport { LLM_PROFILES_QUERY_KEYS } from \"#/hooks/query/query-keys\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useModelStore } from \"#/stores/model-store\";\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport { MODEL_COMMAND } from \"#/utils/constants\";\n\nconst MODEL_PREFIX = `${MODEL_COMMAND} `;\n\n/**\n * Intercepts \"/model\" submissions:\n * - \"/model\" → render an inline list of saved profiles in the chat\n * - \"/model <name>\" → switch the running conversation's LLM profile\n * Anything else (or when on a cloud backend, which doesn't support profiles)\n * falls through to `onSubmit`.\n */\nexport const useModelInterceptor = (\n conversationId: string | null | undefined,\n onSubmit: (message: string) => void,\n) => {\n const showProfiles = useModelStore((s) => s.show);\n const queryClient = useQueryClient();\n const { switchAndLog } = useSwitchLlmProfileAndLog();\n const { backend, orgId } = useActiveBackend();\n const isLocal = backend.kind === \"local\";\n const { t } = useTranslation();\n\n return useCallback(\n (message: string) => {\n const trimmed = message.trim();\n const isModel =\n trimmed === MODEL_COMMAND || trimmed.startsWith(MODEL_PREFIX);\n if (!isModel || !isLocal) {\n onSubmit(message);\n return;\n }\n\n const arg = trimmed.slice(MODEL_COMMAND.length).trim();\n\n if (arg) {\n // Switch the running conversation when one is open; otherwise activate\n // the profile globally so the next conversation starts with it.\n switchAndLog(conversationId ?? null, arg);\n return;\n }\n\n // Bare `/model` — list profiles inline. Needs a conversation to anchor\n // the entry to; swallow silently on the home page.\n if (!conversationId) return;\n\n const anchorEventId = getLastRenderableEventId();\n\n // Imperative fetch through the query cache so the result lands on the\n // same key `useLlmProfiles` reads. `staleTime: 0` forces a fresh fetch\n // each time the user types /model.\n // Multiple rapid /model submissions intentionally append multiple chat\n // entries, matching normal command history behavior rather than replacing\n // earlier results.\n\n queryClient\n .fetchQuery({\n queryKey: [...LLM_PROFILES_QUERY_KEYS.all, backend.id, orgId],\n queryFn: ProfilesService.listProfiles,\n staleTime: 0,\n })\n .then(({ profiles }) =>\n showProfiles(conversationId, anchorEventId, profiles),\n )\n .catch((err: unknown) => {\n const fallback = t(I18nKey.MODEL$LIST_FAILED);\n const messageText =\n err instanceof Error && err.message ? err.message : fallback;\n displayErrorToast(messageText);\n });\n },\n [\n conversationId,\n isLocal,\n onSubmit,\n showProfiles,\n queryClient,\n switchAndLog,\n backend.id,\n orgId,\n t,\n ],\n );\n};\n"],"mappings":";;;;;;;;;;;;;AAaA,IAAM,IAAe,GAAG,EAAc,IASzB,KACX,GACA,MACG;CACH,IAAM,IAAe,GAAe,MAAM,EAAE,KAAK,EAC3C,IAAc,GAAgB,EAC9B,EAAE,oBAAiB,GAA2B,EAC9C,EAAE,YAAS,aAAU,GAAkB,EACvC,IAAU,EAAQ,SAAS,SAC3B,EAAE,SAAM,GAAgB;AAE9B,QAAO,GACJ,MAAoB;EACnB,IAAM,IAAU,EAAQ,MAAM;AAG9B,MAAI,EADF,MAAA,YAA6B,EAAQ,WAAW,EAAa,KAC/C,CAAC,GAAS;AACxB,KAAS,EAAQ;AACjB;;EAGF,IAAM,IAAM,EAAQ,MAAM,EAAc,OAAO,CAAC,MAAM;AAEtD,MAAI,GAAK;AAGP,KAAa,KAAkB,MAAM,EAAI;AACzC;;AAKF,MAAI,CAAC,EAAgB;EAErB,IAAM,IAAgB,GAA0B;AAShD,IACG,WAAW;GACV,UAAU;IAAC,GAAG,EAAwB;IAAK,EAAQ;IAAI;IAAM;GAC7D,SAAS,EAAgB;GACzB,WAAW;GACZ,CAAC,CACD,MAAM,EAAE,kBACP,EAAa,GAAgB,GAAe,EAAS,CACtD,CACA,OAAO,MAAiB;GACvB,IAAM,IAAW,EAAE,EAAQ,kBAAkB;AAG7C,KADE,aAAe,SAAS,EAAI,UAAU,EAAI,UAAU,EACxB;IAC9B;IAEN;EACE;EACA;EACA;EACA;EACA;EACA;EACA,EAAQ;EACR;EACA;EACD,CACF"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
require(`../../_virtual/_rolldown/runtime.cjs`);const e=require(`../../utils/constants.cjs`),t=require(`../../contexts/active-backend-context.cjs`),n=require(`../query/use-skills.cjs`),r=require(`../query/use-llm-profiles.cjs`);let i=require(`react`);function a(e){let t=window.getSelection();if(!t||t.rangeCount===0)return-1;let n=t.getRangeAt(0),r=n.cloneRange();return r.selectNodeContents(e),r.setEnd(n.startContainer,n.startOffset),r.toString().length}var o=o=>{let{data:s,isLoading:c}=n.
|
|
1
|
+
require(`../../_virtual/_rolldown/runtime.cjs`);const e=require(`../../utils/constants.cjs`),t=require(`../../contexts/active-backend-context.cjs`),n=require(`../query/use-conversation-skills.cjs`),r=require(`../query/use-llm-profiles.cjs`);let i=require(`react`);function a(e){let t=window.getSelection();if(!t||t.rangeCount===0)return-1;let n=t.getRangeAt(0),r=n.cloneRange();return r.selectNodeContents(e),r.setEnd(n.startContainer,n.startOffset),r.toString().length}var o=o=>{let{data:s,isLoading:c}=n.useConversationSkills(),l=t.useActiveBackend().backend.kind===`cloud`,{data:u,isLoading:d}=r.useLlmProfiles({enabled:!l}),[f,p]=(0,i.useState)(!1),[m,h]=(0,i.useState)(``),[g,_]=(0,i.useState)(`command`),[v,y]=(0,i.useState)(0),b=(0,i.useMemo)(()=>{let t=e.BUILT_IN_COMMANDS.filter(e=>e.command===`/new`?l:e.command===`/model`?!l:!0);return c||!s||s.forEach(e=>{let n=(e.triggers||[]).filter(e=>e.startsWith(`/`));n.length>0?n.forEach(n=>{t.push({skill:e,command:n})}):e.type===`agentskills`&&t.push({skill:e,command:`/${e.name}`})}),t},[s,c,l]),x=(0,i.useMemo)(()=>l?[]:(u?.profiles??[]).map(t=>{let n=`${e.MODEL_COMMAND} ${t.name}`;return{command:n,skill:{name:t.name,type:`agentskills`,content:t.model?`Switch to ${t.model}`:`Switch to this LLM profile`,triggers:[n]}}}),[u?.profiles,l]),S=(0,i.useMemo)(()=>{let e=g===`model-profile`?x:b;if(!m)return e;let t=m.toLowerCase();return e.filter(e=>e.command.toLowerCase().includes(t)||e.skill.name.toLowerCase().includes(t)||e.skill.content?.toLowerCase().includes(t))},[g,x,b,m]),C=(0,i.useRef)(f);C.current=f;let w=(0,i.useRef)(S);w.current=S;let T=(0,i.useRef)(v);T.current=v,(0,i.useEffect)(()=>{y(0)},[m]);let E=(0,i.useRef)(null),D=(0,i.useCallback)(()=>{let e=o.current;if(!e)return null;let t=(e.innerText||``).replace(/[\n\r]+$/,``),n=a(e);if(n<0)return null;let r=t.slice(0,n),i=r.match(/(^|\s)(\/model(?:\s+\S*)?)$/);if(i){let e=i[2],a=r.length-e.length,o=t.slice(n).match(/^\S*/),s=n+(o?o[0].length:0);return{kind:`model-profile`,text:e.replace(/^\/model(?:\s+)?/,``),start:a,end:s}}let s=r.match(/(^|\s)(\/\S*)$/);if(!s)return null;let c=s[2],l=r.length-c.length,u=t.slice(n).match(/^\S*/),d=n+(u?u[0].length:0);return{kind:`command`,text:c.slice(1),start:l,end:d}},[o]),O=(0,i.useCallback)(()=>{let e=D(),t=e?.kind===`model-profile`?x.length>0||d:b.length>0;e!==null&&t?(_(e.kind),h(e.text),E.current={start:e.start,end:e.end},p(!0)):(p(!1),h(``),_(`command`),E.current=null)},[D,d,x.length,b.length]),k=(0,i.useCallback)(e=>{let t=o.current;if(!t)return;let n=E.current,r=(t.innerText||``).replace(/[\n\r]+$/,``),i=`${e.command} `;if(n){t.textContent=r.slice(0,n.start)+i+r.slice(n.end);let e=n.start+i.length,a=t.firstChild;if(a){let t=document.createRange(),n=window.getSelection(),r=Math.min(e,a.textContent.length);t.setStart(a,r),t.collapse(!0),n?.removeAllRanges(),n?.addRange(t)}}else{t.textContent=i;let e=document.createRange(),n=window.getSelection();e.selectNodeContents(t),e.collapse(!1),n?.removeAllRanges(),n?.addRange(e)}p(!1),h(``),_(`command`),y(0),E.current=null,t.dispatchEvent(new InputEvent(`input`,{bubbles:!0})),t.focus()},[o]);return{isMenuOpen:f,filteredItems:S,selectedIndex:v,updateSlashMenu:O,selectItem:k,handleSlashKeyDown:(0,i.useCallback)(e=>{let t=w.current;if(!C.current||t.length===0)return!1;switch(e.key){case`ArrowDown`:return e.preventDefault(),y(e=>e<t.length-1?e+1:0),!0;case`ArrowUp`:return e.preventDefault(),y(e=>e>0?e-1:t.length-1),!0;case`Enter`:case`Tab`:{let n=t[T.current];return n?(e.preventDefault(),k(n),!0):!1}case`Escape`:return e.preventDefault(),p(!1),!0;case`ArrowLeft`:case`ArrowRight`:case`Home`:case`End`:return p(!1),!1;default:return!1}},[k]),closeMenu:(0,i.useCallback)(()=>p(!1),[])}};exports.useSlashCommand=o;
|
|
2
2
|
//# sourceMappingURL=use-slash-command.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-slash-command.cjs","names":[],"sources":["../../../src/hooks/chat/use-slash-command.ts"],"sourcesContent":["import { useState, useCallback, useEffect, useMemo, useRef } from \"react\";\nimport { useSkills } from \"#/hooks/query/use-skills\";\nimport { SkillInfo } from \"#/types/settings\";\nimport { Microagent } from \"#/api/open-hands.types\";\nimport { BUILT_IN_COMMANDS, MODEL_COMMAND } from \"#/utils/constants\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useLlmProfiles } from \"#/hooks/query/use-llm-profiles\";\n\nexport type SlashCommandSkill = SkillInfo | Microagent;\n\nexport interface SlashCommandItem {\n skill: SlashCommandSkill;\n /** The slash command string, e.g. \"/random-number\" */\n command: string;\n}\n\ntype SlashCompletionKind = \"command\" | \"model-profile\";\n\n/** Get the cursor's character offset within a contentEditable element. */\nfunction getCursorOffset(element: HTMLElement): number {\n const selection = window.getSelection();\n if (!selection || selection.rangeCount === 0) return -1;\n const range = selection.getRangeAt(0);\n const preRange = range.cloneRange();\n preRange.selectNodeContents(element);\n preRange.setEnd(range.startContainer, range.startOffset);\n return preRange.toString().length;\n}\n\n/**\n * Hook for managing slash command autocomplete in the chat input.\n * Detects when user types \"/\" and provides filtered skill suggestions.\n * Only skills with explicit \"/\" triggers (TaskTrigger) appear in the menu.\n */\nexport const useSlashCommand = (\n chatInputRef: React.RefObject<HTMLDivElement | null>,\n) => {\n const { data: skills, isLoading: isSkillsLoading } = useSkills();\n const isCloud = useActiveBackend().backend.kind === \"cloud\";\n const { data: profilesData, isLoading: isProfilesLoading } = useLlmProfiles({\n enabled: !isCloud,\n });\n const [isMenuOpen, setIsMenuOpen] = useState(false);\n const [filterText, setFilterText] = useState(\"\");\n const [completionKind, setCompletionKind] =\n useState<SlashCompletionKind>(\"command\");\n const [selectedIndex, setSelectedIndex] = useState(0);\n\n // Build slash command items from built-in commands + skills:\n // - Built-in commands (like /new) are included for V1 conversations\n // - /new is cloud-only — local backends don't surface it\n // - /model is local-only — cloud backends don't have profiles\n // - Skills with explicit \"/\" triggers use those triggers\n // - AgentSkills without \"/\" triggers get a derived \"/<name>\" command\n const slashItems = useMemo(() => {\n const items: SlashCommandItem[] = BUILT_IN_COMMANDS.filter((cmd) => {\n if (cmd.command === \"/new\") return isCloud;\n if (cmd.command === MODEL_COMMAND) return !isCloud;\n return true;\n });\n\n // Wait for skills to finish initial load so all commands appear together\n if (isSkillsLoading) return items;\n\n if (!skills) return items;\n skills.forEach((skill) => {\n const triggers = skill.triggers || [];\n const slashTriggers = triggers.filter((t) => t.startsWith(\"/\"));\n\n if (slashTriggers.length > 0) {\n // Skill has explicit slash triggers\n slashTriggers.forEach((trigger) => {\n items.push({ skill, command: trigger });\n });\n } else if (skill.type === \"agentskills\") {\n // AgentSkills without slash triggers get a derived command\n items.push({ skill, command: `/${skill.name}` });\n }\n });\n return items;\n }, [skills, isSkillsLoading, isCloud]);\n\n const modelProfileItems = useMemo<SlashCommandItem[]>(() => {\n if (isCloud) return [];\n\n return (profilesData?.profiles ?? []).map((profile) => {\n const command = `${MODEL_COMMAND} ${profile.name}`;\n return {\n command,\n skill: {\n name: profile.name,\n type: \"agentskills\",\n content: profile.model\n ? `Switch to ${profile.model}`\n : \"Switch to this LLM profile\",\n triggers: [command],\n },\n };\n });\n }, [profilesData?.profiles, isCloud]);\n\n // Filter items based on user input after \"/\"\n const filteredItems = useMemo(() => {\n const sourceItems =\n completionKind === \"model-profile\" ? modelProfileItems : slashItems;\n if (!filterText) return sourceItems;\n const lower = filterText.toLowerCase();\n return sourceItems.filter(\n (item) =>\n item.command.toLowerCase().includes(lower) ||\n item.skill.name.toLowerCase().includes(lower) ||\n item.skill.content?.toLowerCase().includes(lower),\n );\n }, [completionKind, modelProfileItems, slashItems, filterText]);\n\n // Keep refs in sync so handleSlashKeyDown always reads the latest values,\n // avoiding stale closures from React's batched state updates.\n const isMenuOpenRef = useRef(isMenuOpen);\n isMenuOpenRef.current = isMenuOpen;\n const filteredItemsRef = useRef(filteredItems);\n filteredItemsRef.current = filteredItems;\n const selectedIndexRef = useRef(selectedIndex);\n selectedIndexRef.current = selectedIndex;\n\n // Reset selected index when the filter text changes\n useEffect(() => {\n setSelectedIndex(0);\n }, [filterText]);\n\n // Track the character range of the current slash word so selectItem can\n // replace only that portion instead of wiping the entire input.\n const slashRangeRef = useRef<{ start: number; end: number } | null>(null);\n\n // Detect a slash word at the cursor position.\n // Returns the filter text (characters after \"/\") and the range of the\n // slash word within the full input text, or null if no slash word found.\n const getSlashText = useCallback((): {\n kind: SlashCompletionKind;\n text: string;\n start: number;\n end: number;\n } | null => {\n const element = chatInputRef.current;\n if (!element) return null;\n\n // Strip trailing newlines that contentEditable can produce, but preserve\n // spaces so \"/command \" (after selection) won't re-trigger the menu.\n const text = (element.innerText || \"\").replace(/[\\n\\r]+$/, \"\");\n const cursor = getCursorOffset(element);\n if (cursor < 0) return null;\n\n const textBeforeCursor = text.slice(0, cursor);\n\n const modelMatch = textBeforeCursor.match(/(^|\\s)(\\/model(?:\\s+\\S*)?)$/);\n if (modelMatch) {\n const modelCommand = modelMatch[2];\n const start = textBeforeCursor.length - modelCommand.length;\n const afterCursor = text.slice(cursor);\n const trailing = afterCursor.match(/^\\S*/);\n const end = cursor + (trailing ? trailing[0].length : 0);\n\n return {\n kind: \"model-profile\",\n text: modelCommand.replace(/^\\/model(?:\\s+)?/, \"\"),\n start,\n end,\n };\n }\n\n // Match a \"/\" preceded by whitespace or at position 0, followed by\n // non-whitespace characters, ending right at the cursor.\n const match = textBeforeCursor.match(/(^|\\s)(\\/\\S*)$/);\n if (!match) return null;\n\n const slashWord = match[2]; // e.g. \"/hel\"\n const start = textBeforeCursor.length - slashWord.length;\n // The end of the slash word extends past the cursor to include any\n // contiguous non-whitespace characters (covers the case where the\n // cursor sits in the middle of a word).\n const afterCursor = text.slice(cursor);\n const trailing = afterCursor.match(/^\\S*/);\n const end = cursor + (trailing ? trailing[0].length : 0);\n\n return { kind: \"command\", text: slashWord.slice(1), start, end }; // strip leading \"/\"\n }, [chatInputRef]);\n\n // Update the menu state based on current input\n const updateSlashMenu = useCallback(() => {\n const result = getSlashText();\n const hasItems =\n result?.kind === \"model-profile\"\n ? modelProfileItems.length > 0 || isProfilesLoading\n : slashItems.length > 0;\n\n if (result !== null && hasItems) {\n setCompletionKind(result.kind);\n setFilterText(result.text);\n slashRangeRef.current = { start: result.start, end: result.end };\n setIsMenuOpen(true);\n } else {\n setIsMenuOpen(false);\n setFilterText(\"\");\n setCompletionKind(\"command\");\n slashRangeRef.current = null;\n }\n }, [\n getSlashText,\n isProfilesLoading,\n modelProfileItems.length,\n slashItems.length,\n ]);\n\n // Select an item and replace only the slash word with the command\n const selectItem = useCallback(\n (item: SlashCommandItem) => {\n const element = chatInputRef.current;\n if (!element) return;\n\n const slashRange = slashRangeRef.current;\n const currentText = (element.innerText || \"\").replace(/[\\n\\r]+$/, \"\");\n const replacement = `${item.command} `;\n\n if (slashRange) {\n // Splice the command into the text, replacing only the slash word\n element.textContent =\n currentText.slice(0, slashRange.start) +\n replacement +\n currentText.slice(slashRange.end);\n\n // Position cursor right after the inserted command + space\n const cursorPos = slashRange.start + replacement.length;\n const textNode = element.firstChild;\n if (textNode) {\n const range = document.createRange();\n const sel = window.getSelection();\n const offset = Math.min(cursorPos, textNode.textContent!.length);\n range.setStart(textNode, offset);\n range.collapse(true);\n sel?.removeAllRanges();\n sel?.addRange(range);\n }\n } else {\n // Fallback: replace everything (e.g. if range tracking failed)\n element.textContent = replacement;\n const range = document.createRange();\n const sel = window.getSelection();\n range.selectNodeContents(element);\n range.collapse(false);\n sel?.removeAllRanges();\n sel?.addRange(range);\n }\n\n setIsMenuOpen(false);\n setFilterText(\"\");\n setCompletionKind(\"command\");\n setSelectedIndex(0);\n slashRangeRef.current = null;\n\n // Trigger a native InputEvent so React's onInput fires (for smartResize etc.)\n element.dispatchEvent(new InputEvent(\"input\", { bubbles: true }));\n\n // Restore focus so keyboard events (Enter to submit) work after selection\n element.focus();\n },\n [chatInputRef],\n );\n\n // Handle keyboard navigation in the menu.\n // Uses refs to always read the latest state, avoiding stale closures.\n const handleSlashKeyDown = useCallback(\n (e: React.KeyboardEvent): boolean => {\n const items = filteredItemsRef.current;\n if (!isMenuOpenRef.current || items.length === 0) return false;\n\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));\n return true;\n case \"ArrowUp\":\n e.preventDefault();\n setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));\n return true;\n case \"Enter\":\n case \"Tab\": {\n const item = items[selectedIndexRef.current];\n if (!item) return false;\n e.preventDefault();\n selectItem(item);\n return true;\n }\n case \"Escape\":\n e.preventDefault();\n setIsMenuOpen(false);\n return true;\n // Cursor-movement keys: close the menu to avoid acting on a stale\n // slash-word range, but don't consume the event so the cursor moves.\n case \"ArrowLeft\":\n case \"ArrowRight\":\n case \"Home\":\n case \"End\":\n setIsMenuOpen(false);\n return false;\n default:\n return false;\n }\n },\n [selectItem],\n );\n\n const closeMenu = useCallback(() => setIsMenuOpen(false), []);\n\n return {\n isMenuOpen,\n filteredItems,\n selectedIndex,\n updateSlashMenu,\n selectItem,\n handleSlashKeyDown,\n closeMenu,\n };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-slash-command.cjs","names":[],"sources":["../../../src/hooks/chat/use-slash-command.ts"],"sourcesContent":["import { useState, useCallback, useEffect, useMemo, useRef } from \"react\";\nimport { useConversationSkills } from \"#/hooks/query/use-conversation-skills\";\nimport { SkillInfo } from \"#/types/settings\";\nimport { Microagent } from \"#/api/open-hands.types\";\nimport { BUILT_IN_COMMANDS, MODEL_COMMAND } from \"#/utils/constants\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useLlmProfiles } from \"#/hooks/query/use-llm-profiles\";\n\nexport type SlashCommandSkill = SkillInfo | Microagent;\n\nexport interface SlashCommandItem {\n skill: SlashCommandSkill;\n /** The slash command string, e.g. \"/random-number\" */\n command: string;\n}\n\ntype SlashCompletionKind = \"command\" | \"model-profile\";\n\n/** Get the cursor's character offset within a contentEditable element. */\nfunction getCursorOffset(element: HTMLElement): number {\n const selection = window.getSelection();\n if (!selection || selection.rangeCount === 0) return -1;\n const range = selection.getRangeAt(0);\n const preRange = range.cloneRange();\n preRange.selectNodeContents(element);\n preRange.setEnd(range.startContainer, range.startOffset);\n return preRange.toString().length;\n}\n\n/**\n * Hook for managing slash command autocomplete in the chat input.\n * Detects when user types \"/\" and provides filtered skill suggestions.\n * Only skills with explicit \"/\" triggers (TaskTrigger) appear in the menu.\n */\nexport const useSlashCommand = (\n chatInputRef: React.RefObject<HTMLDivElement | null>,\n) => {\n // Scope the skill catalog to this conversation's attached workspace so the\n // slash menu lists the same project skills that were loaded into it.\n const { data: skills, isLoading: isSkillsLoading } = useConversationSkills();\n const isCloud = useActiveBackend().backend.kind === \"cloud\";\n const { data: profilesData, isLoading: isProfilesLoading } = useLlmProfiles({\n enabled: !isCloud,\n });\n const [isMenuOpen, setIsMenuOpen] = useState(false);\n const [filterText, setFilterText] = useState(\"\");\n const [completionKind, setCompletionKind] =\n useState<SlashCompletionKind>(\"command\");\n const [selectedIndex, setSelectedIndex] = useState(0);\n\n // Build slash command items from built-in commands + skills:\n // - Built-in commands (like /new) are included for V1 conversations\n // - /new is cloud-only — local backends don't surface it\n // - /model is local-only — cloud backends don't have profiles\n // - Skills with explicit \"/\" triggers use those triggers\n // - AgentSkills without \"/\" triggers get a derived \"/<name>\" command\n const slashItems = useMemo(() => {\n const items: SlashCommandItem[] = BUILT_IN_COMMANDS.filter((cmd) => {\n if (cmd.command === \"/new\") return isCloud;\n if (cmd.command === MODEL_COMMAND) return !isCloud;\n return true;\n });\n\n // Wait for skills to finish initial load so all commands appear together\n if (isSkillsLoading) return items;\n\n if (!skills) return items;\n skills.forEach((skill) => {\n const triggers = skill.triggers || [];\n const slashTriggers = triggers.filter((t) => t.startsWith(\"/\"));\n\n if (slashTriggers.length > 0) {\n // Skill has explicit slash triggers\n slashTriggers.forEach((trigger) => {\n items.push({ skill, command: trigger });\n });\n } else if (skill.type === \"agentskills\") {\n // AgentSkills without slash triggers get a derived command\n items.push({ skill, command: `/${skill.name}` });\n }\n });\n return items;\n }, [skills, isSkillsLoading, isCloud]);\n\n const modelProfileItems = useMemo<SlashCommandItem[]>(() => {\n if (isCloud) return [];\n\n return (profilesData?.profiles ?? []).map((profile) => {\n const command = `${MODEL_COMMAND} ${profile.name}`;\n return {\n command,\n skill: {\n name: profile.name,\n type: \"agentskills\",\n content: profile.model\n ? `Switch to ${profile.model}`\n : \"Switch to this LLM profile\",\n triggers: [command],\n },\n };\n });\n }, [profilesData?.profiles, isCloud]);\n\n // Filter items based on user input after \"/\"\n const filteredItems = useMemo(() => {\n const sourceItems =\n completionKind === \"model-profile\" ? modelProfileItems : slashItems;\n if (!filterText) return sourceItems;\n const lower = filterText.toLowerCase();\n return sourceItems.filter(\n (item) =>\n item.command.toLowerCase().includes(lower) ||\n item.skill.name.toLowerCase().includes(lower) ||\n item.skill.content?.toLowerCase().includes(lower),\n );\n }, [completionKind, modelProfileItems, slashItems, filterText]);\n\n // Keep refs in sync so handleSlashKeyDown always reads the latest values,\n // avoiding stale closures from React's batched state updates.\n const isMenuOpenRef = useRef(isMenuOpen);\n isMenuOpenRef.current = isMenuOpen;\n const filteredItemsRef = useRef(filteredItems);\n filteredItemsRef.current = filteredItems;\n const selectedIndexRef = useRef(selectedIndex);\n selectedIndexRef.current = selectedIndex;\n\n // Reset selected index when the filter text changes\n useEffect(() => {\n setSelectedIndex(0);\n }, [filterText]);\n\n // Track the character range of the current slash word so selectItem can\n // replace only that portion instead of wiping the entire input.\n const slashRangeRef = useRef<{ start: number; end: number } | null>(null);\n\n // Detect a slash word at the cursor position.\n // Returns the filter text (characters after \"/\") and the range of the\n // slash word within the full input text, or null if no slash word found.\n const getSlashText = useCallback((): {\n kind: SlashCompletionKind;\n text: string;\n start: number;\n end: number;\n } | null => {\n const element = chatInputRef.current;\n if (!element) return null;\n\n // Strip trailing newlines that contentEditable can produce, but preserve\n // spaces so \"/command \" (after selection) won't re-trigger the menu.\n const text = (element.innerText || \"\").replace(/[\\n\\r]+$/, \"\");\n const cursor = getCursorOffset(element);\n if (cursor < 0) return null;\n\n const textBeforeCursor = text.slice(0, cursor);\n\n const modelMatch = textBeforeCursor.match(/(^|\\s)(\\/model(?:\\s+\\S*)?)$/);\n if (modelMatch) {\n const modelCommand = modelMatch[2];\n const start = textBeforeCursor.length - modelCommand.length;\n const afterCursor = text.slice(cursor);\n const trailing = afterCursor.match(/^\\S*/);\n const end = cursor + (trailing ? trailing[0].length : 0);\n\n return {\n kind: \"model-profile\",\n text: modelCommand.replace(/^\\/model(?:\\s+)?/, \"\"),\n start,\n end,\n };\n }\n\n // Match a \"/\" preceded by whitespace or at position 0, followed by\n // non-whitespace characters, ending right at the cursor.\n const match = textBeforeCursor.match(/(^|\\s)(\\/\\S*)$/);\n if (!match) return null;\n\n const slashWord = match[2]; // e.g. \"/hel\"\n const start = textBeforeCursor.length - slashWord.length;\n // The end of the slash word extends past the cursor to include any\n // contiguous non-whitespace characters (covers the case where the\n // cursor sits in the middle of a word).\n const afterCursor = text.slice(cursor);\n const trailing = afterCursor.match(/^\\S*/);\n const end = cursor + (trailing ? trailing[0].length : 0);\n\n return { kind: \"command\", text: slashWord.slice(1), start, end }; // strip leading \"/\"\n }, [chatInputRef]);\n\n // Update the menu state based on current input\n const updateSlashMenu = useCallback(() => {\n const result = getSlashText();\n const hasItems =\n result?.kind === \"model-profile\"\n ? modelProfileItems.length > 0 || isProfilesLoading\n : slashItems.length > 0;\n\n if (result !== null && hasItems) {\n setCompletionKind(result.kind);\n setFilterText(result.text);\n slashRangeRef.current = { start: result.start, end: result.end };\n setIsMenuOpen(true);\n } else {\n setIsMenuOpen(false);\n setFilterText(\"\");\n setCompletionKind(\"command\");\n slashRangeRef.current = null;\n }\n }, [\n getSlashText,\n isProfilesLoading,\n modelProfileItems.length,\n slashItems.length,\n ]);\n\n // Select an item and replace only the slash word with the command\n const selectItem = useCallback(\n (item: SlashCommandItem) => {\n const element = chatInputRef.current;\n if (!element) return;\n\n const slashRange = slashRangeRef.current;\n const currentText = (element.innerText || \"\").replace(/[\\n\\r]+$/, \"\");\n const replacement = `${item.command} `;\n\n if (slashRange) {\n // Splice the command into the text, replacing only the slash word\n element.textContent =\n currentText.slice(0, slashRange.start) +\n replacement +\n currentText.slice(slashRange.end);\n\n // Position cursor right after the inserted command + space\n const cursorPos = slashRange.start + replacement.length;\n const textNode = element.firstChild;\n if (textNode) {\n const range = document.createRange();\n const sel = window.getSelection();\n const offset = Math.min(cursorPos, textNode.textContent!.length);\n range.setStart(textNode, offset);\n range.collapse(true);\n sel?.removeAllRanges();\n sel?.addRange(range);\n }\n } else {\n // Fallback: replace everything (e.g. if range tracking failed)\n element.textContent = replacement;\n const range = document.createRange();\n const sel = window.getSelection();\n range.selectNodeContents(element);\n range.collapse(false);\n sel?.removeAllRanges();\n sel?.addRange(range);\n }\n\n setIsMenuOpen(false);\n setFilterText(\"\");\n setCompletionKind(\"command\");\n setSelectedIndex(0);\n slashRangeRef.current = null;\n\n // Trigger a native InputEvent so React's onInput fires (for smartResize etc.)\n element.dispatchEvent(new InputEvent(\"input\", { bubbles: true }));\n\n // Restore focus so keyboard events (Enter to submit) work after selection\n element.focus();\n },\n [chatInputRef],\n );\n\n // Handle keyboard navigation in the menu.\n // Uses refs to always read the latest state, avoiding stale closures.\n const handleSlashKeyDown = useCallback(\n (e: React.KeyboardEvent): boolean => {\n const items = filteredItemsRef.current;\n if (!isMenuOpenRef.current || items.length === 0) return false;\n\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));\n return true;\n case \"ArrowUp\":\n e.preventDefault();\n setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));\n return true;\n case \"Enter\":\n case \"Tab\": {\n const item = items[selectedIndexRef.current];\n if (!item) return false;\n e.preventDefault();\n selectItem(item);\n return true;\n }\n case \"Escape\":\n e.preventDefault();\n setIsMenuOpen(false);\n return true;\n // Cursor-movement keys: close the menu to avoid acting on a stale\n // slash-word range, but don't consume the event so the cursor moves.\n case \"ArrowLeft\":\n case \"ArrowRight\":\n case \"Home\":\n case \"End\":\n setIsMenuOpen(false);\n return false;\n default:\n return false;\n }\n },\n [selectItem],\n );\n\n const closeMenu = useCallback(() => setIsMenuOpen(false), []);\n\n return {\n isMenuOpen,\n filteredItems,\n selectedIndex,\n updateSlashMenu,\n selectItem,\n handleSlashKeyDown,\n closeMenu,\n };\n};\n"],"mappings":"wQAmBA,SAAS,EAAgB,EAA8B,CACrD,IAAM,EAAY,OAAO,cAAc,CACvC,GAAI,CAAC,GAAa,EAAU,aAAe,EAAG,MAAO,GACrD,IAAM,EAAQ,EAAU,WAAW,EAAE,CAC/B,EAAW,EAAM,YAAY,CAGnC,OAFA,EAAS,mBAAmB,EAAQ,CACpC,EAAS,OAAO,EAAM,eAAgB,EAAM,YAAY,CACjD,EAAS,UAAU,CAAC,OAQ7B,IAAa,EACX,GACG,CAGH,GAAM,CAAE,KAAM,EAAQ,UAAW,GAAoB,EAAA,uBAAuB,CACtE,EAAU,EAAA,kBAAkB,CAAC,QAAQ,OAAS,QAC9C,CAAE,KAAM,EAAc,UAAW,GAAsB,EAAA,eAAe,CAC1E,QAAS,CAAC,EACX,CAAC,CACI,CAAC,EAAY,IAAA,EAAA,EAAA,UAA0B,GAAM,CAC7C,CAAC,EAAY,IAAA,EAAA,EAAA,UAA0B,GAAG,CAC1C,CAAC,EAAgB,IAAA,EAAA,EAAA,UACS,UAAU,CACpC,CAAC,EAAe,IAAA,EAAA,EAAA,UAA6B,EAAE,CAQ/C,GAAA,EAAA,EAAA,aAA2B,CAC/B,IAAM,EAA4B,EAAA,kBAAkB,OAAQ,GACtD,EAAI,UAAY,OAAe,EAC/B,EAAI,UAAA,SAAkC,CAAC,EACpC,GACP,CAoBF,OAjBI,GAEA,CAAC,GACL,EAAO,QAAS,GAAU,CAExB,IAAM,GADW,EAAM,UAAY,EAAE,EACN,OAAQ,GAAM,EAAE,WAAW,IAAI,CAAC,CAE3D,EAAc,OAAS,EAEzB,EAAc,QAAS,GAAY,CACjC,EAAM,KAAK,CAAE,QAAO,QAAS,EAAS,CAAC,EACvC,CACO,EAAM,OAAS,eAExB,EAAM,KAAK,CAAE,QAAO,QAAS,IAAI,EAAM,OAAQ,CAAC,EAElD,CAdkB,GAgBnB,CAAC,EAAQ,EAAiB,EAAQ,CAAC,CAEhC,GAAA,EAAA,EAAA,aACA,EAAgB,EAAE,EAEd,GAAc,UAAY,EAAE,EAAE,IAAK,GAAY,CACrD,IAAM,EAAU,GAAG,EAAA,cAAc,GAAG,EAAQ,OAC5C,MAAO,CACL,UACA,MAAO,CACL,KAAM,EAAQ,KACd,KAAM,cACN,QAAS,EAAQ,MACb,aAAa,EAAQ,QACrB,6BACJ,SAAU,CAAC,EAAQ,CACpB,CACF,EACD,CACD,CAAC,GAAc,SAAU,EAAQ,CAAC,CAG/B,GAAA,EAAA,EAAA,aAA8B,CAClC,IAAM,EACJ,IAAmB,gBAAkB,EAAoB,EAC3D,GAAI,CAAC,EAAY,OAAO,EACxB,IAAM,EAAQ,EAAW,aAAa,CACtC,OAAO,EAAY,OAChB,GACC,EAAK,QAAQ,aAAa,CAAC,SAAS,EAAM,EAC1C,EAAK,MAAM,KAAK,aAAa,CAAC,SAAS,EAAM,EAC7C,EAAK,MAAM,SAAS,aAAa,CAAC,SAAS,EAAM,CACpD,EACA,CAAC,EAAgB,EAAmB,EAAY,EAAW,CAAC,CAIzD,GAAA,EAAA,EAAA,QAAuB,EAAW,CACxC,EAAc,QAAU,EACxB,IAAM,GAAA,EAAA,EAAA,QAA0B,EAAc,CAC9C,EAAiB,QAAU,EAC3B,IAAM,GAAA,EAAA,EAAA,QAA0B,EAAc,CAC9C,EAAiB,QAAU,GAG3B,EAAA,EAAA,eAAgB,CACd,EAAiB,EAAE,EAClB,CAAC,EAAW,CAAC,CAIhB,IAAM,GAAA,EAAA,EAAA,QAA8D,KAAK,CAKnE,GAAA,EAAA,EAAA,iBAKM,CACV,IAAM,EAAU,EAAa,QAC7B,GAAI,CAAC,EAAS,OAAO,KAIrB,IAAM,GAAQ,EAAQ,WAAa,IAAI,QAAQ,WAAY,GAAG,CACxD,EAAS,EAAgB,EAAQ,CACvC,GAAI,EAAS,EAAG,OAAO,KAEvB,IAAM,EAAmB,EAAK,MAAM,EAAG,EAAO,CAExC,EAAa,EAAiB,MAAM,8BAA8B,CACxE,GAAI,EAAY,CACd,IAAM,EAAe,EAAW,GAC1B,EAAQ,EAAiB,OAAS,EAAa,OAE/C,EADc,EAAK,MAAM,EACd,CAAY,MAAM,OAAO,CACpC,EAAM,GAAU,EAAW,EAAS,GAAG,OAAS,GAEtD,MAAO,CACL,KAAM,gBACN,KAAM,EAAa,QAAQ,mBAAoB,GAAG,CAClD,QACA,MACD,CAKH,IAAM,EAAQ,EAAiB,MAAM,iBAAiB,CACtD,GAAI,CAAC,EAAO,OAAO,KAEnB,IAAM,EAAY,EAAM,GAClB,EAAQ,EAAiB,OAAS,EAAU,OAK5C,EADc,EAAK,MAAM,EACd,CAAY,MAAM,OAAO,CACpC,EAAM,GAAU,EAAW,EAAS,GAAG,OAAS,GAEtD,MAAO,CAAE,KAAM,UAAW,KAAM,EAAU,MAAM,EAAE,CAAE,QAAO,MAAK,EAC/D,CAAC,EAAa,CAAC,CAGZ,GAAA,EAAA,EAAA,iBAAoC,CACxC,IAAM,EAAS,GAAc,CACvB,EACJ,GAAQ,OAAS,gBACb,EAAkB,OAAS,GAAK,EAChC,EAAW,OAAS,EAEtB,IAAW,MAAQ,GACrB,EAAkB,EAAO,KAAK,CAC9B,EAAc,EAAO,KAAK,CAC1B,EAAc,QAAU,CAAE,MAAO,EAAO,MAAO,IAAK,EAAO,IAAK,CAChE,EAAc,GAAK,GAEnB,EAAc,GAAM,CACpB,EAAc,GAAG,CACjB,EAAkB,UAAU,CAC5B,EAAc,QAAU,OAEzB,CACD,EACA,EACA,EAAkB,OAClB,EAAW,OACZ,CAAC,CAGI,GAAA,EAAA,EAAA,aACH,GAA2B,CAC1B,IAAM,EAAU,EAAa,QAC7B,GAAI,CAAC,EAAS,OAEd,IAAM,EAAa,EAAc,QAC3B,GAAe,EAAQ,WAAa,IAAI,QAAQ,WAAY,GAAG,CAC/D,EAAc,GAAG,EAAK,QAAQ,GAEpC,GAAI,EAAY,CAEd,EAAQ,YACN,EAAY,MAAM,EAAG,EAAW,MAAM,CACtC,EACA,EAAY,MAAM,EAAW,IAAI,CAGnC,IAAM,EAAY,EAAW,MAAQ,EAAY,OAC3C,EAAW,EAAQ,WACzB,GAAI,EAAU,CACZ,IAAM,EAAQ,SAAS,aAAa,CAC9B,EAAM,OAAO,cAAc,CAC3B,EAAS,KAAK,IAAI,EAAW,EAAS,YAAa,OAAO,CAChE,EAAM,SAAS,EAAU,EAAO,CAChC,EAAM,SAAS,GAAK,CACpB,GAAK,iBAAiB,CACtB,GAAK,SAAS,EAAM,MAEjB,CAEL,EAAQ,YAAc,EACtB,IAAM,EAAQ,SAAS,aAAa,CAC9B,EAAM,OAAO,cAAc,CACjC,EAAM,mBAAmB,EAAQ,CACjC,EAAM,SAAS,GAAM,CACrB,GAAK,iBAAiB,CACtB,GAAK,SAAS,EAAM,CAGtB,EAAc,GAAM,CACpB,EAAc,GAAG,CACjB,EAAkB,UAAU,CAC5B,EAAiB,EAAE,CACnB,EAAc,QAAU,KAGxB,EAAQ,cAAc,IAAI,WAAW,QAAS,CAAE,QAAS,GAAM,CAAC,CAAC,CAGjE,EAAQ,OAAO,EAEjB,CAAC,EAAa,CACf,CA+CD,MAAO,CACL,aACA,gBACA,gBACA,kBACA,aACA,oBAAA,EAAA,EAAA,aAhDC,GAAoC,CACnC,IAAM,EAAQ,EAAiB,QAC/B,GAAI,CAAC,EAAc,SAAW,EAAM,SAAW,EAAG,MAAO,GAEzD,OAAQ,EAAE,IAAV,CACE,IAAK,YAGH,OAFA,EAAE,gBAAgB,CAClB,EAAkB,GAAU,EAAO,EAAM,OAAS,EAAI,EAAO,EAAI,EAAG,CAC7D,GACT,IAAK,UAGH,OAFA,EAAE,gBAAgB,CAClB,EAAkB,GAAU,EAAO,EAAI,EAAO,EAAI,EAAM,OAAS,EAAG,CAC7D,GACT,IAAK,QACL,IAAK,MAAO,CACV,IAAM,EAAO,EAAM,EAAiB,SAIpC,OAHK,GACL,EAAE,gBAAgB,CAClB,EAAW,EAAK,CACT,IAHW,GAKpB,IAAK,SAGH,OAFA,EAAE,gBAAgB,CAClB,EAAc,GAAM,CACb,GAGT,IAAK,YACL,IAAK,aACL,IAAK,OACL,IAAK,MAEH,OADA,EAAc,GAAM,CACb,GACT,QACE,MAAO,KAGb,CAAC,EAAW,CAWZ,CACA,WAAA,EAAA,EAAA,iBATkC,EAAc,GAAM,CAAE,EAAE,CAS1D,CACD"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BUILT_IN_COMMANDS as e, MODEL_COMMAND as t } from "../../utils/constants.js";
|
|
2
2
|
import { useActiveBackend as n } from "../../contexts/active-backend-context.js";
|
|
3
|
-
import {
|
|
3
|
+
import { useConversationSkills as r } from "../query/use-conversation-skills.js";
|
|
4
4
|
import { useLlmProfiles as i } from "../query/use-llm-profiles.js";
|
|
5
5
|
import { useCallback as a, useEffect as o, useMemo as s, useRef as c, useState as l } from "react";
|
|
6
6
|
//#region src/hooks/chat/use-slash-command.ts
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-slash-command.js","names":[],"sources":["../../../src/hooks/chat/use-slash-command.ts"],"sourcesContent":["import { useState, useCallback, useEffect, useMemo, useRef } from \"react\";\nimport { useSkills } from \"#/hooks/query/use-skills\";\nimport { SkillInfo } from \"#/types/settings\";\nimport { Microagent } from \"#/api/open-hands.types\";\nimport { BUILT_IN_COMMANDS, MODEL_COMMAND } from \"#/utils/constants\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useLlmProfiles } from \"#/hooks/query/use-llm-profiles\";\n\nexport type SlashCommandSkill = SkillInfo | Microagent;\n\nexport interface SlashCommandItem {\n skill: SlashCommandSkill;\n /** The slash command string, e.g. \"/random-number\" */\n command: string;\n}\n\ntype SlashCompletionKind = \"command\" | \"model-profile\";\n\n/** Get the cursor's character offset within a contentEditable element. */\nfunction getCursorOffset(element: HTMLElement): number {\n const selection = window.getSelection();\n if (!selection || selection.rangeCount === 0) return -1;\n const range = selection.getRangeAt(0);\n const preRange = range.cloneRange();\n preRange.selectNodeContents(element);\n preRange.setEnd(range.startContainer, range.startOffset);\n return preRange.toString().length;\n}\n\n/**\n * Hook for managing slash command autocomplete in the chat input.\n * Detects when user types \"/\" and provides filtered skill suggestions.\n * Only skills with explicit \"/\" triggers (TaskTrigger) appear in the menu.\n */\nexport const useSlashCommand = (\n chatInputRef: React.RefObject<HTMLDivElement | null>,\n) => {\n const { data: skills, isLoading: isSkillsLoading } = useSkills();\n const isCloud = useActiveBackend().backend.kind === \"cloud\";\n const { data: profilesData, isLoading: isProfilesLoading } = useLlmProfiles({\n enabled: !isCloud,\n });\n const [isMenuOpen, setIsMenuOpen] = useState(false);\n const [filterText, setFilterText] = useState(\"\");\n const [completionKind, setCompletionKind] =\n useState<SlashCompletionKind>(\"command\");\n const [selectedIndex, setSelectedIndex] = useState(0);\n\n // Build slash command items from built-in commands + skills:\n // - Built-in commands (like /new) are included for V1 conversations\n // - /new is cloud-only — local backends don't surface it\n // - /model is local-only — cloud backends don't have profiles\n // - Skills with explicit \"/\" triggers use those triggers\n // - AgentSkills without \"/\" triggers get a derived \"/<name>\" command\n const slashItems = useMemo(() => {\n const items: SlashCommandItem[] = BUILT_IN_COMMANDS.filter((cmd) => {\n if (cmd.command === \"/new\") return isCloud;\n if (cmd.command === MODEL_COMMAND) return !isCloud;\n return true;\n });\n\n // Wait for skills to finish initial load so all commands appear together\n if (isSkillsLoading) return items;\n\n if (!skills) return items;\n skills.forEach((skill) => {\n const triggers = skill.triggers || [];\n const slashTriggers = triggers.filter((t) => t.startsWith(\"/\"));\n\n if (slashTriggers.length > 0) {\n // Skill has explicit slash triggers\n slashTriggers.forEach((trigger) => {\n items.push({ skill, command: trigger });\n });\n } else if (skill.type === \"agentskills\") {\n // AgentSkills without slash triggers get a derived command\n items.push({ skill, command: `/${skill.name}` });\n }\n });\n return items;\n }, [skills, isSkillsLoading, isCloud]);\n\n const modelProfileItems = useMemo<SlashCommandItem[]>(() => {\n if (isCloud) return [];\n\n return (profilesData?.profiles ?? []).map((profile) => {\n const command = `${MODEL_COMMAND} ${profile.name}`;\n return {\n command,\n skill: {\n name: profile.name,\n type: \"agentskills\",\n content: profile.model\n ? `Switch to ${profile.model}`\n : \"Switch to this LLM profile\",\n triggers: [command],\n },\n };\n });\n }, [profilesData?.profiles, isCloud]);\n\n // Filter items based on user input after \"/\"\n const filteredItems = useMemo(() => {\n const sourceItems =\n completionKind === \"model-profile\" ? modelProfileItems : slashItems;\n if (!filterText) return sourceItems;\n const lower = filterText.toLowerCase();\n return sourceItems.filter(\n (item) =>\n item.command.toLowerCase().includes(lower) ||\n item.skill.name.toLowerCase().includes(lower) ||\n item.skill.content?.toLowerCase().includes(lower),\n );\n }, [completionKind, modelProfileItems, slashItems, filterText]);\n\n // Keep refs in sync so handleSlashKeyDown always reads the latest values,\n // avoiding stale closures from React's batched state updates.\n const isMenuOpenRef = useRef(isMenuOpen);\n isMenuOpenRef.current = isMenuOpen;\n const filteredItemsRef = useRef(filteredItems);\n filteredItemsRef.current = filteredItems;\n const selectedIndexRef = useRef(selectedIndex);\n selectedIndexRef.current = selectedIndex;\n\n // Reset selected index when the filter text changes\n useEffect(() => {\n setSelectedIndex(0);\n }, [filterText]);\n\n // Track the character range of the current slash word so selectItem can\n // replace only that portion instead of wiping the entire input.\n const slashRangeRef = useRef<{ start: number; end: number } | null>(null);\n\n // Detect a slash word at the cursor position.\n // Returns the filter text (characters after \"/\") and the range of the\n // slash word within the full input text, or null if no slash word found.\n const getSlashText = useCallback((): {\n kind: SlashCompletionKind;\n text: string;\n start: number;\n end: number;\n } | null => {\n const element = chatInputRef.current;\n if (!element) return null;\n\n // Strip trailing newlines that contentEditable can produce, but preserve\n // spaces so \"/command \" (after selection) won't re-trigger the menu.\n const text = (element.innerText || \"\").replace(/[\\n\\r]+$/, \"\");\n const cursor = getCursorOffset(element);\n if (cursor < 0) return null;\n\n const textBeforeCursor = text.slice(0, cursor);\n\n const modelMatch = textBeforeCursor.match(/(^|\\s)(\\/model(?:\\s+\\S*)?)$/);\n if (modelMatch) {\n const modelCommand = modelMatch[2];\n const start = textBeforeCursor.length - modelCommand.length;\n const afterCursor = text.slice(cursor);\n const trailing = afterCursor.match(/^\\S*/);\n const end = cursor + (trailing ? trailing[0].length : 0);\n\n return {\n kind: \"model-profile\",\n text: modelCommand.replace(/^\\/model(?:\\s+)?/, \"\"),\n start,\n end,\n };\n }\n\n // Match a \"/\" preceded by whitespace or at position 0, followed by\n // non-whitespace characters, ending right at the cursor.\n const match = textBeforeCursor.match(/(^|\\s)(\\/\\S*)$/);\n if (!match) return null;\n\n const slashWord = match[2]; // e.g. \"/hel\"\n const start = textBeforeCursor.length - slashWord.length;\n // The end of the slash word extends past the cursor to include any\n // contiguous non-whitespace characters (covers the case where the\n // cursor sits in the middle of a word).\n const afterCursor = text.slice(cursor);\n const trailing = afterCursor.match(/^\\S*/);\n const end = cursor + (trailing ? trailing[0].length : 0);\n\n return { kind: \"command\", text: slashWord.slice(1), start, end }; // strip leading \"/\"\n }, [chatInputRef]);\n\n // Update the menu state based on current input\n const updateSlashMenu = useCallback(() => {\n const result = getSlashText();\n const hasItems =\n result?.kind === \"model-profile\"\n ? modelProfileItems.length > 0 || isProfilesLoading\n : slashItems.length > 0;\n\n if (result !== null && hasItems) {\n setCompletionKind(result.kind);\n setFilterText(result.text);\n slashRangeRef.current = { start: result.start, end: result.end };\n setIsMenuOpen(true);\n } else {\n setIsMenuOpen(false);\n setFilterText(\"\");\n setCompletionKind(\"command\");\n slashRangeRef.current = null;\n }\n }, [\n getSlashText,\n isProfilesLoading,\n modelProfileItems.length,\n slashItems.length,\n ]);\n\n // Select an item and replace only the slash word with the command\n const selectItem = useCallback(\n (item: SlashCommandItem) => {\n const element = chatInputRef.current;\n if (!element) return;\n\n const slashRange = slashRangeRef.current;\n const currentText = (element.innerText || \"\").replace(/[\\n\\r]+$/, \"\");\n const replacement = `${item.command} `;\n\n if (slashRange) {\n // Splice the command into the text, replacing only the slash word\n element.textContent =\n currentText.slice(0, slashRange.start) +\n replacement +\n currentText.slice(slashRange.end);\n\n // Position cursor right after the inserted command + space\n const cursorPos = slashRange.start + replacement.length;\n const textNode = element.firstChild;\n if (textNode) {\n const range = document.createRange();\n const sel = window.getSelection();\n const offset = Math.min(cursorPos, textNode.textContent!.length);\n range.setStart(textNode, offset);\n range.collapse(true);\n sel?.removeAllRanges();\n sel?.addRange(range);\n }\n } else {\n // Fallback: replace everything (e.g. if range tracking failed)\n element.textContent = replacement;\n const range = document.createRange();\n const sel = window.getSelection();\n range.selectNodeContents(element);\n range.collapse(false);\n sel?.removeAllRanges();\n sel?.addRange(range);\n }\n\n setIsMenuOpen(false);\n setFilterText(\"\");\n setCompletionKind(\"command\");\n setSelectedIndex(0);\n slashRangeRef.current = null;\n\n // Trigger a native InputEvent so React's onInput fires (for smartResize etc.)\n element.dispatchEvent(new InputEvent(\"input\", { bubbles: true }));\n\n // Restore focus so keyboard events (Enter to submit) work after selection\n element.focus();\n },\n [chatInputRef],\n );\n\n // Handle keyboard navigation in the menu.\n // Uses refs to always read the latest state, avoiding stale closures.\n const handleSlashKeyDown = useCallback(\n (e: React.KeyboardEvent): boolean => {\n const items = filteredItemsRef.current;\n if (!isMenuOpenRef.current || items.length === 0) return false;\n\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));\n return true;\n case \"ArrowUp\":\n e.preventDefault();\n setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));\n return true;\n case \"Enter\":\n case \"Tab\": {\n const item = items[selectedIndexRef.current];\n if (!item) return false;\n e.preventDefault();\n selectItem(item);\n return true;\n }\n case \"Escape\":\n e.preventDefault();\n setIsMenuOpen(false);\n return true;\n // Cursor-movement keys: close the menu to avoid acting on a stale\n // slash-word range, but don't consume the event so the cursor moves.\n case \"ArrowLeft\":\n case \"ArrowRight\":\n case \"Home\":\n case \"End\":\n setIsMenuOpen(false);\n return false;\n default:\n return false;\n }\n },\n [selectItem],\n );\n\n const closeMenu = useCallback(() => setIsMenuOpen(false), []);\n\n return {\n isMenuOpen,\n filteredItems,\n selectedIndex,\n updateSlashMenu,\n selectItem,\n handleSlashKeyDown,\n closeMenu,\n };\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-slash-command.js","names":[],"sources":["../../../src/hooks/chat/use-slash-command.ts"],"sourcesContent":["import { useState, useCallback, useEffect, useMemo, useRef } from \"react\";\nimport { useConversationSkills } from \"#/hooks/query/use-conversation-skills\";\nimport { SkillInfo } from \"#/types/settings\";\nimport { Microagent } from \"#/api/open-hands.types\";\nimport { BUILT_IN_COMMANDS, MODEL_COMMAND } from \"#/utils/constants\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport { useLlmProfiles } from \"#/hooks/query/use-llm-profiles\";\n\nexport type SlashCommandSkill = SkillInfo | Microagent;\n\nexport interface SlashCommandItem {\n skill: SlashCommandSkill;\n /** The slash command string, e.g. \"/random-number\" */\n command: string;\n}\n\ntype SlashCompletionKind = \"command\" | \"model-profile\";\n\n/** Get the cursor's character offset within a contentEditable element. */\nfunction getCursorOffset(element: HTMLElement): number {\n const selection = window.getSelection();\n if (!selection || selection.rangeCount === 0) return -1;\n const range = selection.getRangeAt(0);\n const preRange = range.cloneRange();\n preRange.selectNodeContents(element);\n preRange.setEnd(range.startContainer, range.startOffset);\n return preRange.toString().length;\n}\n\n/**\n * Hook for managing slash command autocomplete in the chat input.\n * Detects when user types \"/\" and provides filtered skill suggestions.\n * Only skills with explicit \"/\" triggers (TaskTrigger) appear in the menu.\n */\nexport const useSlashCommand = (\n chatInputRef: React.RefObject<HTMLDivElement | null>,\n) => {\n // Scope the skill catalog to this conversation's attached workspace so the\n // slash menu lists the same project skills that were loaded into it.\n const { data: skills, isLoading: isSkillsLoading } = useConversationSkills();\n const isCloud = useActiveBackend().backend.kind === \"cloud\";\n const { data: profilesData, isLoading: isProfilesLoading } = useLlmProfiles({\n enabled: !isCloud,\n });\n const [isMenuOpen, setIsMenuOpen] = useState(false);\n const [filterText, setFilterText] = useState(\"\");\n const [completionKind, setCompletionKind] =\n useState<SlashCompletionKind>(\"command\");\n const [selectedIndex, setSelectedIndex] = useState(0);\n\n // Build slash command items from built-in commands + skills:\n // - Built-in commands (like /new) are included for V1 conversations\n // - /new is cloud-only — local backends don't surface it\n // - /model is local-only — cloud backends don't have profiles\n // - Skills with explicit \"/\" triggers use those triggers\n // - AgentSkills without \"/\" triggers get a derived \"/<name>\" command\n const slashItems = useMemo(() => {\n const items: SlashCommandItem[] = BUILT_IN_COMMANDS.filter((cmd) => {\n if (cmd.command === \"/new\") return isCloud;\n if (cmd.command === MODEL_COMMAND) return !isCloud;\n return true;\n });\n\n // Wait for skills to finish initial load so all commands appear together\n if (isSkillsLoading) return items;\n\n if (!skills) return items;\n skills.forEach((skill) => {\n const triggers = skill.triggers || [];\n const slashTriggers = triggers.filter((t) => t.startsWith(\"/\"));\n\n if (slashTriggers.length > 0) {\n // Skill has explicit slash triggers\n slashTriggers.forEach((trigger) => {\n items.push({ skill, command: trigger });\n });\n } else if (skill.type === \"agentskills\") {\n // AgentSkills without slash triggers get a derived command\n items.push({ skill, command: `/${skill.name}` });\n }\n });\n return items;\n }, [skills, isSkillsLoading, isCloud]);\n\n const modelProfileItems = useMemo<SlashCommandItem[]>(() => {\n if (isCloud) return [];\n\n return (profilesData?.profiles ?? []).map((profile) => {\n const command = `${MODEL_COMMAND} ${profile.name}`;\n return {\n command,\n skill: {\n name: profile.name,\n type: \"agentskills\",\n content: profile.model\n ? `Switch to ${profile.model}`\n : \"Switch to this LLM profile\",\n triggers: [command],\n },\n };\n });\n }, [profilesData?.profiles, isCloud]);\n\n // Filter items based on user input after \"/\"\n const filteredItems = useMemo(() => {\n const sourceItems =\n completionKind === \"model-profile\" ? modelProfileItems : slashItems;\n if (!filterText) return sourceItems;\n const lower = filterText.toLowerCase();\n return sourceItems.filter(\n (item) =>\n item.command.toLowerCase().includes(lower) ||\n item.skill.name.toLowerCase().includes(lower) ||\n item.skill.content?.toLowerCase().includes(lower),\n );\n }, [completionKind, modelProfileItems, slashItems, filterText]);\n\n // Keep refs in sync so handleSlashKeyDown always reads the latest values,\n // avoiding stale closures from React's batched state updates.\n const isMenuOpenRef = useRef(isMenuOpen);\n isMenuOpenRef.current = isMenuOpen;\n const filteredItemsRef = useRef(filteredItems);\n filteredItemsRef.current = filteredItems;\n const selectedIndexRef = useRef(selectedIndex);\n selectedIndexRef.current = selectedIndex;\n\n // Reset selected index when the filter text changes\n useEffect(() => {\n setSelectedIndex(0);\n }, [filterText]);\n\n // Track the character range of the current slash word so selectItem can\n // replace only that portion instead of wiping the entire input.\n const slashRangeRef = useRef<{ start: number; end: number } | null>(null);\n\n // Detect a slash word at the cursor position.\n // Returns the filter text (characters after \"/\") and the range of the\n // slash word within the full input text, or null if no slash word found.\n const getSlashText = useCallback((): {\n kind: SlashCompletionKind;\n text: string;\n start: number;\n end: number;\n } | null => {\n const element = chatInputRef.current;\n if (!element) return null;\n\n // Strip trailing newlines that contentEditable can produce, but preserve\n // spaces so \"/command \" (after selection) won't re-trigger the menu.\n const text = (element.innerText || \"\").replace(/[\\n\\r]+$/, \"\");\n const cursor = getCursorOffset(element);\n if (cursor < 0) return null;\n\n const textBeforeCursor = text.slice(0, cursor);\n\n const modelMatch = textBeforeCursor.match(/(^|\\s)(\\/model(?:\\s+\\S*)?)$/);\n if (modelMatch) {\n const modelCommand = modelMatch[2];\n const start = textBeforeCursor.length - modelCommand.length;\n const afterCursor = text.slice(cursor);\n const trailing = afterCursor.match(/^\\S*/);\n const end = cursor + (trailing ? trailing[0].length : 0);\n\n return {\n kind: \"model-profile\",\n text: modelCommand.replace(/^\\/model(?:\\s+)?/, \"\"),\n start,\n end,\n };\n }\n\n // Match a \"/\" preceded by whitespace or at position 0, followed by\n // non-whitespace characters, ending right at the cursor.\n const match = textBeforeCursor.match(/(^|\\s)(\\/\\S*)$/);\n if (!match) return null;\n\n const slashWord = match[2]; // e.g. \"/hel\"\n const start = textBeforeCursor.length - slashWord.length;\n // The end of the slash word extends past the cursor to include any\n // contiguous non-whitespace characters (covers the case where the\n // cursor sits in the middle of a word).\n const afterCursor = text.slice(cursor);\n const trailing = afterCursor.match(/^\\S*/);\n const end = cursor + (trailing ? trailing[0].length : 0);\n\n return { kind: \"command\", text: slashWord.slice(1), start, end }; // strip leading \"/\"\n }, [chatInputRef]);\n\n // Update the menu state based on current input\n const updateSlashMenu = useCallback(() => {\n const result = getSlashText();\n const hasItems =\n result?.kind === \"model-profile\"\n ? modelProfileItems.length > 0 || isProfilesLoading\n : slashItems.length > 0;\n\n if (result !== null && hasItems) {\n setCompletionKind(result.kind);\n setFilterText(result.text);\n slashRangeRef.current = { start: result.start, end: result.end };\n setIsMenuOpen(true);\n } else {\n setIsMenuOpen(false);\n setFilterText(\"\");\n setCompletionKind(\"command\");\n slashRangeRef.current = null;\n }\n }, [\n getSlashText,\n isProfilesLoading,\n modelProfileItems.length,\n slashItems.length,\n ]);\n\n // Select an item and replace only the slash word with the command\n const selectItem = useCallback(\n (item: SlashCommandItem) => {\n const element = chatInputRef.current;\n if (!element) return;\n\n const slashRange = slashRangeRef.current;\n const currentText = (element.innerText || \"\").replace(/[\\n\\r]+$/, \"\");\n const replacement = `${item.command} `;\n\n if (slashRange) {\n // Splice the command into the text, replacing only the slash word\n element.textContent =\n currentText.slice(0, slashRange.start) +\n replacement +\n currentText.slice(slashRange.end);\n\n // Position cursor right after the inserted command + space\n const cursorPos = slashRange.start + replacement.length;\n const textNode = element.firstChild;\n if (textNode) {\n const range = document.createRange();\n const sel = window.getSelection();\n const offset = Math.min(cursorPos, textNode.textContent!.length);\n range.setStart(textNode, offset);\n range.collapse(true);\n sel?.removeAllRanges();\n sel?.addRange(range);\n }\n } else {\n // Fallback: replace everything (e.g. if range tracking failed)\n element.textContent = replacement;\n const range = document.createRange();\n const sel = window.getSelection();\n range.selectNodeContents(element);\n range.collapse(false);\n sel?.removeAllRanges();\n sel?.addRange(range);\n }\n\n setIsMenuOpen(false);\n setFilterText(\"\");\n setCompletionKind(\"command\");\n setSelectedIndex(0);\n slashRangeRef.current = null;\n\n // Trigger a native InputEvent so React's onInput fires (for smartResize etc.)\n element.dispatchEvent(new InputEvent(\"input\", { bubbles: true }));\n\n // Restore focus so keyboard events (Enter to submit) work after selection\n element.focus();\n },\n [chatInputRef],\n );\n\n // Handle keyboard navigation in the menu.\n // Uses refs to always read the latest state, avoiding stale closures.\n const handleSlashKeyDown = useCallback(\n (e: React.KeyboardEvent): boolean => {\n const items = filteredItemsRef.current;\n if (!isMenuOpenRef.current || items.length === 0) return false;\n\n switch (e.key) {\n case \"ArrowDown\":\n e.preventDefault();\n setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));\n return true;\n case \"ArrowUp\":\n e.preventDefault();\n setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));\n return true;\n case \"Enter\":\n case \"Tab\": {\n const item = items[selectedIndexRef.current];\n if (!item) return false;\n e.preventDefault();\n selectItem(item);\n return true;\n }\n case \"Escape\":\n e.preventDefault();\n setIsMenuOpen(false);\n return true;\n // Cursor-movement keys: close the menu to avoid acting on a stale\n // slash-word range, but don't consume the event so the cursor moves.\n case \"ArrowLeft\":\n case \"ArrowRight\":\n case \"Home\":\n case \"End\":\n setIsMenuOpen(false);\n return false;\n default:\n return false;\n }\n },\n [selectItem],\n );\n\n const closeMenu = useCallback(() => setIsMenuOpen(false), []);\n\n return {\n isMenuOpen,\n filteredItems,\n selectedIndex,\n updateSlashMenu,\n selectItem,\n handleSlashKeyDown,\n closeMenu,\n };\n};\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-switch-llm-profile.cjs","names":[],"sources":["../../../src/hooks/mutation/use-switch-llm-profile.ts"],"sourcesContent":["import { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport AgentServerConversationService from \"#/api/conversation-service/agent-server-conversation-service.api\";\nimport SettingsService from \"#/api/settings-service/settings-service.api\";\nimport {\n LLM_PROFILES_QUERY_KEYS,\n SETTINGS_QUERY_KEYS,\n} from \"#/hooks/query/query-keys\";\nimport { invalidateConversationQueries } from \"./conversation-mutation-utils\";\n\ninterface SwitchLlmProfileVars {\n /**\n * When set, the conversation's running LLM is swapped via /
|
|
1
|
+
{"version":3,"file":"use-switch-llm-profile.cjs","names":[],"sources":["../../../src/hooks/mutation/use-switch-llm-profile.ts"],"sourcesContent":["import { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport AgentServerConversationService from \"#/api/conversation-service/agent-server-conversation-service.api\";\nimport SettingsService from \"#/api/settings-service/settings-service.api\";\nimport {\n LLM_PROFILES_QUERY_KEYS,\n SETTINGS_QUERY_KEYS,\n} from \"#/hooks/query/query-keys\";\nimport { invalidateConversationQueries } from \"./conversation-mutation-utils\";\n\ninterface SwitchLlmProfileVars {\n /**\n * When set, the conversation's running LLM is swapped via /switch_profile and\n * the user's global default profile is untouched. When null (home page),\n * the profile is activated globally instead.\n */\n conversationId: string | null;\n profileName: string;\n}\n\n/**\n * Switches the LLM profile. Per-conversation when called from inside a\n * conversation; globally activates the profile when called from the home\n * page. Invalidates the conversation query so consumers reading `llm_model`\n * pick up the swap, and the profile list so anything reading `active_profile`\n * stays in sync.\n */\nexport const useSwitchLlmProfile = () => {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: ({ conversationId, profileName }: SwitchLlmProfileVars) =>\n AgentServerConversationService.switchProfile(conversationId, profileName),\n onSuccess: (_data, { conversationId }) => {\n queryClient.invalidateQueries({\n queryKey: LLM_PROFILES_QUERY_KEYS.all,\n });\n if (conversationId) {\n invalidateConversationQueries(queryClient, conversationId);\n } else {\n // Home-page activate path (same server endpoint as\n // useActivateLlmProfile): clear the SettingsService cache so the next\n // conversation-start reads the newly activated profile's LLM config\n // instead of the stale encrypted settings.\n SettingsService.invalidateCache();\n queryClient.invalidateQueries({\n queryKey: SETTINGS_QUERY_KEYS.personal(),\n });\n }\n },\n // Caller renders an inline message + handles error toast manually.\n meta: { disableToast: true },\n });\n};\n"],"mappings":"+cA0BA,IAAa,MAA4B,CACvC,IAAM,EAAc,EAAA,gBAAgB,CAEpC,OAAO,EAAA,YAAY,CACjB,YAAa,CAAE,iBAAgB,iBAC7B,EAAA,QAA+B,cAAc,EAAgB,EAAY,CAC3E,WAAY,EAAO,CAAE,oBAAqB,CACxC,EAAY,kBAAkB,CAC5B,SAAU,EAAA,wBAAwB,IACnC,CAAC,CACE,EACF,EAAA,8BAA8B,EAAa,EAAe,EAM1D,EAAA,QAAgB,iBAAiB,CACjC,EAAY,kBAAkB,CAC5B,SAAU,EAAA,oBAAoB,UAAU,CACzC,CAAC,GAIN,KAAM,CAAE,aAAc,GAAM,CAC7B,CAAC"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
interface SwitchLlmProfileVars {
|
|
2
2
|
/**
|
|
3
|
-
* When set, the conversation's running LLM is swapped via /
|
|
3
|
+
* When set, the conversation's running LLM is swapped via /switch_profile and
|
|
4
4
|
* the user's global default profile is untouched. When null (home page),
|
|
5
5
|
* the profile is activated globally instead.
|
|
6
6
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-switch-llm-profile.js","names":[],"sources":["../../../src/hooks/mutation/use-switch-llm-profile.ts"],"sourcesContent":["import { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport AgentServerConversationService from \"#/api/conversation-service/agent-server-conversation-service.api\";\nimport SettingsService from \"#/api/settings-service/settings-service.api\";\nimport {\n LLM_PROFILES_QUERY_KEYS,\n SETTINGS_QUERY_KEYS,\n} from \"#/hooks/query/query-keys\";\nimport { invalidateConversationQueries } from \"./conversation-mutation-utils\";\n\ninterface SwitchLlmProfileVars {\n /**\n * When set, the conversation's running LLM is swapped via /
|
|
1
|
+
{"version":3,"file":"use-switch-llm-profile.js","names":[],"sources":["../../../src/hooks/mutation/use-switch-llm-profile.ts"],"sourcesContent":["import { useMutation, useQueryClient } from \"@tanstack/react-query\";\nimport AgentServerConversationService from \"#/api/conversation-service/agent-server-conversation-service.api\";\nimport SettingsService from \"#/api/settings-service/settings-service.api\";\nimport {\n LLM_PROFILES_QUERY_KEYS,\n SETTINGS_QUERY_KEYS,\n} from \"#/hooks/query/query-keys\";\nimport { invalidateConversationQueries } from \"./conversation-mutation-utils\";\n\ninterface SwitchLlmProfileVars {\n /**\n * When set, the conversation's running LLM is swapped via /switch_profile and\n * the user's global default profile is untouched. When null (home page),\n * the profile is activated globally instead.\n */\n conversationId: string | null;\n profileName: string;\n}\n\n/**\n * Switches the LLM profile. Per-conversation when called from inside a\n * conversation; globally activates the profile when called from the home\n * page. Invalidates the conversation query so consumers reading `llm_model`\n * pick up the swap, and the profile list so anything reading `active_profile`\n * stays in sync.\n */\nexport const useSwitchLlmProfile = () => {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: ({ conversationId, profileName }: SwitchLlmProfileVars) =>\n AgentServerConversationService.switchProfile(conversationId, profileName),\n onSuccess: (_data, { conversationId }) => {\n queryClient.invalidateQueries({\n queryKey: LLM_PROFILES_QUERY_KEYS.all,\n });\n if (conversationId) {\n invalidateConversationQueries(queryClient, conversationId);\n } else {\n // Home-page activate path (same server endpoint as\n // useActivateLlmProfile): clear the SettingsService cache so the next\n // conversation-start reads the newly activated profile's LLM config\n // instead of the stale encrypted settings.\n SettingsService.invalidateCache();\n queryClient.invalidateQueries({\n queryKey: SETTINGS_QUERY_KEYS.personal(),\n });\n }\n },\n // Caller renders an inline message + handles error toast manually.\n meta: { disableToast: true },\n });\n};\n"],"mappings":";;;;;;;AA0BA,IAAa,UAA4B;CACvC,IAAM,IAAc,GAAgB;AAEpC,QAAO,EAAY;EACjB,aAAa,EAAE,mBAAgB,qBAC7B,EAA+B,cAAc,GAAgB,EAAY;EAC3E,YAAY,GAAO,EAAE,wBAAqB;AAIxC,GAHA,EAAY,kBAAkB,EAC5B,UAAU,EAAwB,KACnC,CAAC,EACE,IACF,EAA8B,GAAa,EAAe,IAM1D,EAAgB,iBAAiB,EACjC,EAAY,kBAAkB,EAC5B,UAAU,EAAoB,UAAU,EACzC,CAAC;;EAIN,MAAM,EAAE,cAAc,IAAM;EAC7B,CAAC"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
require(`../../_virtual/_rolldown/runtime.cjs`);const e=require(`../../node_modules/@tanstack/react-query/build/modern/useQuery.cjs`),t=require(`../../api/agent-server-compatibility.cjs`),n=require(`./query-keys.cjs`),r=require(`../../api/option-service/option-service.api.cjs`);var i=i=>e.useQuery({queryKey:n.QUERY_KEYS.WEB_CLIENT_CONFIG,queryFn:r.default.getConfig,retry:(e,n)=>!t.isAgentServerUnavailableError(n)&&e<3,meta:{disableToast:!0},...n.CONFIG_CACHE_OPTIONS,enabled:i?.enabled});exports.useConfig=i;
|
|
1
|
+
require(`../../_virtual/_rolldown/runtime.cjs`);const e=require(`../../node_modules/@tanstack/react-query/build/modern/useQuery.cjs`),t=require(`../../api/agent-server-compatibility.cjs`),n=require(`./query-keys.cjs`),r=require(`../../api/option-service/option-service.api.cjs`);var i=i=>e.useQuery({queryKey:n.QUERY_KEYS.WEB_CLIENT_CONFIG,queryFn:r.default.getConfig,retry:(e,n)=>!t.isAgentServerUnavailableError(n)&&!t.isAgentServerAuthError(n)&&e<3,meta:{disableToast:!0},...n.CONFIG_CACHE_OPTIONS,enabled:i?.enabled});exports.useConfig=i;
|
|
2
2
|
//# sourceMappingURL=use-config.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-config.cjs","names":[],"sources":["../../../src/hooks/query/use-config.ts"],"sourcesContent":["import { useQuery } from \"@tanstack/react-query\";\nimport {
|
|
1
|
+
{"version":3,"file":"use-config.cjs","names":[],"sources":["../../../src/hooks/query/use-config.ts"],"sourcesContent":["import { useQuery } from \"@tanstack/react-query\";\nimport {\n isAgentServerUnavailableError,\n isAgentServerAuthError,\n} from \"#/api/agent-server-compatibility\";\nimport OptionService from \"#/api/option-service/option-service.api\";\nimport { QUERY_KEYS, CONFIG_CACHE_OPTIONS } from \"./query-keys\";\n\ninterface UseConfigOptions {\n enabled?: boolean;\n}\n\nexport const useConfig = (options?: UseConfigOptions) =>\n useQuery({\n queryKey: QUERY_KEYS.WEB_CLIENT_CONFIG,\n queryFn: OptionService.getConfig,\n retry: (failureCount, error) =>\n !isAgentServerUnavailableError(error) &&\n !isAgentServerAuthError(error) &&\n failureCount < 3,\n meta: { disableToast: true },\n ...CONFIG_CACHE_OPTIONS,\n enabled: options?.enabled,\n });\n"],"mappings":"uRAYA,IAAa,EAAa,GACxB,EAAA,SAAS,CACP,SAAU,EAAA,WAAW,kBACrB,QAAS,EAAA,QAAc,UACvB,OAAQ,EAAc,IACpB,CAAC,EAAA,8BAA8B,EAAM,EACrC,CAAC,EAAA,uBAAuB,EAAM,EAC9B,EAAe,EACjB,KAAM,CAAE,aAAc,GAAM,CAC5B,GAAG,EAAA,qBACH,QAAS,GAAS,QACnB,CAAC"}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import { useQuery as e } from "../../node_modules/@tanstack/react-query/build/modern/useQuery.js";
|
|
2
|
-
import {
|
|
3
|
-
import { CONFIG_CACHE_OPTIONS as
|
|
4
|
-
import
|
|
2
|
+
import { isAgentServerAuthError as t, isAgentServerUnavailableError as n } from "../../api/agent-server-compatibility.js";
|
|
3
|
+
import { CONFIG_CACHE_OPTIONS as r, QUERY_KEYS as i } from "./query-keys.js";
|
|
4
|
+
import a from "../../api/option-service/option-service.api.js";
|
|
5
5
|
//#region src/hooks/query/use-config.ts
|
|
6
|
-
var
|
|
7
|
-
queryKey:
|
|
8
|
-
queryFn:
|
|
9
|
-
retry: (e,
|
|
6
|
+
var o = (o) => e({
|
|
7
|
+
queryKey: i.WEB_CLIENT_CONFIG,
|
|
8
|
+
queryFn: a.getConfig,
|
|
9
|
+
retry: (e, r) => !n(r) && !t(r) && e < 3,
|
|
10
10
|
meta: { disableToast: !0 },
|
|
11
|
-
...
|
|
12
|
-
enabled:
|
|
11
|
+
...r,
|
|
12
|
+
enabled: o?.enabled
|
|
13
13
|
});
|
|
14
14
|
//#endregion
|
|
15
|
-
export {
|
|
15
|
+
export { o as useConfig };
|
|
16
16
|
|
|
17
17
|
//# sourceMappingURL=use-config.js.map
|