@openhands/agent-canvas 1.0.0-alpha.8 → 1.0.0-alpha.9

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.
Files changed (108) hide show
  1. package/README.md +17 -6
  2. package/build/assets/{add-backend-modal-KMmPQNZU.js → add-backend-modal-FsnpTTgO.js} +1 -1
  3. package/build/assets/{agent-server-conversation-service.api-DSl9G5UR.js → agent-server-conversation-service.api-BZmUqtiO.js} +1 -1
  4. package/build/assets/{automation-detail-g5-RZ0da.js → automation-detail-R-99FUce.js} +1 -1
  5. package/build/assets/{automations-list-DHoq_0MM.js → automations-list-Dfu2c-_D.js} +1 -1
  6. package/build/assets/backend-form-modal-DxYjqqAK.js +1 -0
  7. package/build/assets/browser-HrYc5Gce.js +5 -0
  8. package/build/assets/conversation--ldUK72N.js +19 -0
  9. package/build/assets/conversation-eNrhH94O.js +1 -0
  10. package/build/assets/conversation-panel-B49Jpqpb.js +1 -0
  11. package/build/assets/{conversation-service.api-C8pYCyV6.js → conversation-service.api--f8WglOC.js} +1 -1
  12. package/build/assets/conversation-websocket-context-BW68-J8o.js +3 -0
  13. package/build/assets/{entry.client-D9uR9Blz.js → entry.client-CqqXOSvd.js} +2 -2
  14. package/build/assets/{files-tab-B3A1NDlZ.js → files-tab-CQHdWpQt.js} +1 -1
  15. package/build/assets/git-control-bar-branch-button-C8u5rzjc.js +27 -0
  16. package/build/assets/{git-provider-icon-DYE9n7fs.js → git-provider-icon-D-a-rcLm.js} +1 -1
  17. package/build/assets/{home-dIzxi5Dd.js → home-DD0GroCu.js} +1 -1
  18. package/build/assets/{launch-hZ0ifhcV.js → launch-B2mbfOSm.js} +1 -1
  19. package/build/assets/llm-settings-BEyqixPI.js +1 -0
  20. package/build/assets/{llm-settings-CcHqGOYL.js → llm-settings-BdiaGFbg.js} +1 -1
  21. package/build/assets/{manage-backends-modal-rYeyGx7j.js → manage-backends-modal-s22zCdEW.js} +1 -1
  22. package/build/assets/{manifest-97e839da.js → manifest-9d1c34fb.js} +1 -1
  23. package/build/assets/{messages-T2ewVkbp.js → messages-6aOyUu3r.js} +1 -1
  24. package/build/assets/{path-utils-CqJboYxo.js → path-utils-BVbe598W.js} +1 -1
  25. package/build/assets/{planner-tab-BrntFmb1.js → planner-tab-bN6r1G-1.js} +1 -1
  26. package/build/assets/{recommended-automations-launcher-BI9NhG8Y.js → recommended-automations-launcher-mJhK6Atl.js} +1 -1
  27. package/build/assets/{root-BS1Td78t.js → root-3t9rxEpE.js} +2 -2
  28. package/build/assets/{root-layout-BLjAEgle.js → root-layout-BjVwHmta.js} +2 -2
  29. package/build/assets/{shared-conversation-a0QV8o99.js → shared-conversation-EZV0FRIf.js} +1 -1
  30. package/build/assets/{sidebar-mobile-menu-toggle-DTUNI1WQ.js → sidebar-mobile-menu-toggle-BnbzzpQl.js} +1 -1
  31. package/build/assets/{skills-settings-DOnMn9q1.js → skills-settings-CG2hu34D.js} +1 -1
  32. package/build/assets/{task-list-tab-Day9nhRT.js → task-list-tab-465DDju0.js} +1 -1
  33. package/build/assets/{terminal-ro4SNjUU.js → terminal-CcgBEVnC.js} +1 -1
  34. package/build/assets/{use-active-conversation-D15D9GgR.js → use-active-conversation-DS5j9R4q.js} +1 -1
  35. package/build/assets/{use-agent-state-DE5dlEXJ.js → use-agent-state-D2C9SeGw.js} +1 -1
  36. package/build/assets/{use-create-conversation-DW7AGgLA.js → use-create-conversation-BEZg__Vv.js} +1 -1
  37. package/build/assets/{use-event-store-CQZCcVz-.js → use-event-store-BT_gV3ut.js} +1 -1
  38. package/build/assets/{use-handle-plan-click-DpgEQDAV.js → use-handle-plan-click-uOpew2LO.js} +1 -1
  39. package/build/assets/{use-runtime-is-ready-XFbT16BD.js → use-runtime-is-ready-pGSbPddC.js} +1 -1
  40. package/build/assets/{use-skills-Xe0vjPMt.js → use-skills-BIvlWblA.js} +1 -1
  41. package/build/assets/{use-task-list-Bs90uF2N.js → use-task-list-DDeNHprj.js} +1 -1
  42. package/build/assets/{use-unified-vscode-url-BOsIOd-b.js → use-unified-vscode-url-wAMzv8Sn.js} +1 -1
  43. package/build/assets/{use-user-conversation-Mc0mQgkl.js → use-user-conversation-B_zDoSeh.js} +1 -1
  44. package/build/assets/{vscode-tab-C0ShhiSU.js → vscode-tab-B0vdh9gU.js} +1 -1
  45. package/build/index.html +4 -4
  46. package/dist/api/agent-server-adapter.cjs +1 -1
  47. package/dist/api/agent-server-adapter.cjs.map +1 -1
  48. package/dist/api/agent-server-adapter.js +1 -0
  49. package/dist/api/agent-server-adapter.js.map +1 -1
  50. package/dist/api/skills-service.cjs +1 -1
  51. package/dist/api/skills-service.cjs.map +1 -1
  52. package/dist/api/skills-service.d.ts +1 -1
  53. package/dist/api/skills-service.js +2 -2
  54. package/dist/api/skills-service.js.map +1 -1
  55. package/dist/components/features/backends/backend-form-modal.cjs +1 -1
  56. package/dist/components/features/backends/backend-form-modal.cjs.map +1 -1
  57. package/dist/components/features/backends/backend-form-modal.js +149 -142
  58. package/dist/components/features/backends/backend-form-modal.js.map +1 -1
  59. package/dist/components/features/conversation-panel/skills-modal.cjs +1 -1
  60. package/dist/components/features/conversation-panel/skills-modal.cjs.map +1 -1
  61. package/dist/components/features/conversation-panel/skills-modal.js +1 -1
  62. package/dist/components/features/conversation-panel/skills-modal.js.map +1 -1
  63. package/dist/contexts/conversation-websocket-context.cjs +3 -3
  64. package/dist/contexts/conversation-websocket-context.cjs.map +1 -1
  65. package/dist/contexts/conversation-websocket-context.js +97 -89
  66. package/dist/contexts/conversation-websocket-context.js.map +1 -1
  67. package/dist/hooks/chat/use-slash-command.cjs +1 -1
  68. package/dist/hooks/chat/use-slash-command.cjs.map +1 -1
  69. package/dist/hooks/chat/use-slash-command.js +1 -1
  70. package/dist/hooks/chat/use-slash-command.js.map +1 -1
  71. package/dist/hooks/query/use-conversation-skills.cjs +2 -0
  72. package/dist/hooks/query/use-conversation-skills.cjs.map +1 -0
  73. package/dist/hooks/query/use-conversation-skills.d.ts +7 -0
  74. package/dist/hooks/query/use-conversation-skills.js +8 -0
  75. package/dist/hooks/query/use-conversation-skills.js.map +1 -0
  76. package/dist/hooks/query/use-skills.cjs +1 -1
  77. package/dist/hooks/query/use-skills.cjs.map +1 -1
  78. package/dist/hooks/query/use-skills.d.ts +6 -1
  79. package/dist/hooks/query/use-skills.js +3 -3
  80. package/dist/hooks/query/use-skills.js.map +1 -1
  81. package/dist/package.cjs +1 -1
  82. package/dist/package.cjs.map +1 -1
  83. package/dist/package.js +3 -3
  84. package/dist/package.js.map +1 -1
  85. package/dist/routes/conversation.cjs +1 -1
  86. package/dist/routes/conversation.cjs.map +1 -1
  87. package/dist/routes/conversation.js +61 -63
  88. package/dist/routes/conversation.js.map +1 -1
  89. package/dist/stores/use-event-store.cjs +1 -1
  90. package/dist/stores/use-event-store.cjs.map +1 -1
  91. package/dist/stores/use-event-store.d.ts +22 -0
  92. package/dist/stores/use-event-store.js +9 -1
  93. package/dist/stores/use-event-store.js.map +1 -1
  94. package/dist/ui/context-menu.d.ts +1 -1
  95. package/dist/ui/help-link.d.ts +1 -1
  96. package/package.json +3 -3
  97. package/scripts/dev-safe.mjs +35 -17
  98. package/scripts/dev-with-automation.mjs +24 -49
  99. package/build/assets/backend-form-modal-K6IMCr3p.js +0 -1
  100. package/build/assets/browser-DKG63inJ.js +0 -5
  101. package/build/assets/conversation-BD5WemJI.js +0 -19
  102. package/build/assets/conversation-C47K62n8.js +0 -1
  103. package/build/assets/conversation-panel-Dn-S56Gk.js +0 -1
  104. package/build/assets/conversation-websocket-context-Ywrxd_9p.js +0 -3
  105. package/build/assets/git-control-bar-branch-button-CcIpmyfM.js +0 -27
  106. package/build/assets/llm-settings-2036m7Wt.js +0 -1
  107. /package/build/assets/{link-external-Df8J52xI.js → link-external-C9d6Fo3x.js} +0 -0
  108. /package/build/assets/{use-llm-profiles-D3-KXwQ0.js → use-llm-profiles-O4a9V6RC.js} +0 -0
@@ -9,88 +9,86 @@ import { useConversationStateStore as s } from "../stores/conversation-state-sto
9
9
  import { useActiveBackend as c } from "../contexts/active-backend-context.js";
10
10
  import { clearLastConversationId as l, setLastConversationId as u } from "../api/backend-registry/last-conversation-store.js";
11
11
  import { displayErrorToast as d } from "../utils/custom-toast-handlers.js";
12
- import { useEventStore as f } from "../stores/use-event-store.js";
13
- import { useErrorMessageStore as p } from "../stores/error-message-store.js";
14
- import { resumeCloudSandbox as m } from "../api/cloud/conversation-service.api.js";
15
- import { useActiveConversation as h } from "../hooks/query/use-active-conversation.js";
16
- import { EventHandler as g } from "../wrapper/event-handler.js";
17
- import { useTaskPolling as _ } from "../hooks/query/use-task-polling.js";
18
- import { useIsAuthed as v } from "../hooks/query/use-is-authed.js";
19
- import { ConversationMain as y, ConversationMobilePanelPage as b } from "../components/features/conversation/conversation-main/conversation-main.js";
20
- import { WebSocketProviderWrapper as x } from "../contexts/websocket-provider-wrapper.js";
21
- import S from "react";
22
- import { jsx as C } from "react/jsx-runtime";
23
- import { useLocation as w, useMatch as T, useNavigate as E } from "react-router";
12
+ import { useErrorMessageStore as f } from "../stores/error-message-store.js";
13
+ import { resumeCloudSandbox as p } from "../api/cloud/conversation-service.api.js";
14
+ import { useActiveConversation as m } from "../hooks/query/use-active-conversation.js";
15
+ import { EventHandler as h } from "../wrapper/event-handler.js";
16
+ import { useTaskPolling as g } from "../hooks/query/use-task-polling.js";
17
+ import { useIsAuthed as _ } from "../hooks/query/use-is-authed.js";
18
+ import { ConversationMain as v, ConversationMobilePanelPage as y } from "../components/features/conversation/conversation-main/conversation-main.js";
19
+ import { WebSocketProviderWrapper as b } from "../contexts/websocket-provider-wrapper.js";
20
+ import x from "react";
21
+ import { jsx as S } from "react/jsx-runtime";
22
+ import { useLocation as C, useMatch as w, useNavigate as T } from "react-router";
24
23
  //#region src/routes/conversation.tsx
25
- function D() {
26
- let { t: D } = e("openhands"), { conversationId: O } = r(), k = T("/conversations/:conversationId/panel"), A = f((e) => e.clearEvents), { isTask: j, taskStatus: M, taskDetail: N } = _(), P = c(), F = S.useRef(P.backend.id), I = S.useRef(P.orgId), L = F.current !== P.backend.id || I.current !== P.orgId, { data: R, isFetched: z } = h(), { data: B } = v(), { resetConversationState: V } = a(), H = E(), U = w(), W = i((e) => e.clearTerminal), G = s((e) => e.reset), K = o((e) => e.setCurrentAgentState), q = p((e) => e.removeErrorMessage);
27
- S.useEffect(() => {
28
- W(), V(), G(), K(n.LOADING), q(), A();
24
+ function E() {
25
+ let { t: E } = e("openhands"), { conversationId: D } = r(), O = w("/conversations/:conversationId/panel"), { isTask: k, taskStatus: A, taskDetail: j } = g(), M = c(), N = x.useRef(M.backend.id), P = x.useRef(M.orgId), F = N.current !== M.backend.id || P.current !== M.orgId, { data: I, isFetched: L } = m(), { data: R } = _(), { resetConversationState: z } = a(), B = T(), V = C(), H = i((e) => e.clearTerminal), U = s((e) => e.reset), W = o((e) => e.setCurrentAgentState), G = f((e) => e.removeErrorMessage);
26
+ x.useEffect(() => {
27
+ H(), z(), U(), W(n.LOADING), G();
29
28
  }, [
30
- O,
29
+ D,
30
+ H,
31
+ z,
32
+ U,
31
33
  W,
32
- V,
33
- G,
34
- K,
35
- q,
36
- A
37
- ]), S.useEffect(() => {
38
- if (j && M === "ERROR") {
39
- d(N || D(t.CONVERSATION$FAILED_TO_START_FROM_TASK));
40
- let e = U.state?.resumedFromConversationId;
41
- H(e ? `/conversations/${e}` : "/conversations", { replace: !0 });
34
+ G
35
+ ]), x.useEffect(() => {
36
+ if (k && A === "ERROR") {
37
+ d(j || E(t.CONVERSATION$FAILED_TO_START_FROM_TASK));
38
+ let e = V.state?.resumedFromConversationId;
39
+ B(e ? `/conversations/${e}` : "/conversations", { replace: !0 });
42
40
  }
43
41
  }, [
42
+ k,
43
+ A,
44
44
  j,
45
- M,
46
- N,
47
- D,
48
- H,
49
- U.state
50
- ]), S.useEffect(() => {
51
- !z || !B || L || R || (l(P.backend.id, P.orgId), d(D(t.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION)), H("/conversations"));
45
+ E,
46
+ B,
47
+ V.state
48
+ ]), x.useEffect(() => {
49
+ !L || !R || F || I || (l(M.backend.id, M.orgId), d(E(t.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION)), B("/conversations"));
52
50
  }, [
51
+ I,
52
+ L,
53
53
  R,
54
- z,
55
54
  B,
56
- H,
57
- D,
58
- L,
59
- P.backend.id,
60
- P.orgId
61
- ]), S.useEffect(() => {
62
- L || O && (O.startsWith("task-") || u(P.backend.id, P.orgId, O));
55
+ E,
56
+ F,
57
+ M.backend.id,
58
+ M.orgId
59
+ ]), x.useEffect(() => {
60
+ F || D && (D.startsWith("task-") || u(M.backend.id, M.orgId, D));
63
61
  }, [
64
- O,
65
- L,
66
- P.backend.id,
67
- P.orgId
62
+ D,
63
+ F,
64
+ M.backend.id,
65
+ M.orgId
68
66
  ]);
69
- let J = S.useRef(null);
70
- return S.useEffect(() => {
71
- !z || !R || P.backend.kind === "cloud" && R.sandbox_status === "PAUSED" && R.sandbox_id && J.current !== R.id && (J.current = R.id, m(R.sandbox_id).catch(() => {
72
- d(D(t.CONVERSATION$FAILED_TO_START_FROM_TASK));
67
+ let K = x.useRef(null);
68
+ return x.useEffect(() => {
69
+ !L || !I || M.backend.kind === "cloud" && I.sandbox_status === "PAUSED" && I.sandbox_id && K.current !== I.id && (K.current = I.id, p(I.sandbox_id).catch(() => {
70
+ d(E(t.CONVERSATION$FAILED_TO_START_FROM_TASK));
73
71
  }));
74
72
  }, [
75
- z,
76
- R?.id,
77
- R?.sandbox_status,
78
- R?.sandbox_id,
79
- P.backend.kind,
80
- D
81
- ]), /* @__PURE__ */ C(x, {
82
- conversationId: O,
83
- children: /* @__PURE__ */ C(g, { children: /* @__PURE__ */ C("div", {
73
+ L,
74
+ I?.id,
75
+ I?.sandbox_status,
76
+ I?.sandbox_id,
77
+ M.backend.kind,
78
+ E
79
+ ]), /* @__PURE__ */ S(b, {
80
+ conversationId: D,
81
+ children: /* @__PURE__ */ S(h, { children: /* @__PURE__ */ S("div", {
84
82
  "data-testid": "app-route",
85
83
  className: "flex h-full flex-col",
86
- children: k ? /* @__PURE__ */ C(b, { onNavigateBack: () => H(`/conversations/${O}`) }) : /* @__PURE__ */ C(y, {})
84
+ children: O ? /* @__PURE__ */ S(y, { onNavigateBack: () => B(`/conversations/${D}`) }) : /* @__PURE__ */ S(v, {})
87
85
  }) })
88
86
  });
89
87
  }
90
- function O() {
91
- return /* @__PURE__ */ C(D, {});
88
+ function D() {
89
+ return /* @__PURE__ */ S(E, {});
92
90
  }
93
91
  //#endregion
94
- export { O as default };
92
+ export { D as default };
95
93
 
96
94
  //# sourceMappingURL=conversation.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"conversation.js","names":[],"sources":["../../src/routes/conversation.tsx"],"sourcesContent":["import React from \"react\";\nimport { useNavigate, useLocation, useMatch } from \"react-router\";\nimport { useTranslation } from \"react-i18next\";\n\nimport { useConversationId } from \"#/hooks/use-conversation-id\";\nimport { useCommandStore } from \"#/stores/command-store\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { useAgentStore } from \"#/stores/agent-store\";\nimport { useConversationStateStore } from \"#/stores/conversation-state-store\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport {\n clearLastConversationId,\n setLastConversationId,\n} from \"#/api/backend-registry/last-conversation-store\";\nimport { AgentState } from \"#/types/agent-state\";\n\nimport { EventHandler } from \"../wrapper/event-handler\";\n\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { useTaskPolling } from \"#/hooks/query/use-task-polling\";\n\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport { useIsAuthed } from \"#/hooks/query/use-is-authed\";\nimport {\n ConversationMain,\n ConversationMobilePanelPage,\n} from \"#/components/features/conversation/conversation-main/conversation-main\";\n\nimport { WebSocketProviderWrapper } from \"#/contexts/websocket-provider-wrapper\";\nimport { useErrorMessageStore } from \"#/stores/error-message-store\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { useEventStore } from \"#/stores/use-event-store\";\nimport { resumeCloudSandbox } from \"#/api/cloud/conversation-service.api\";\n\nfunction AppContent() {\n const { t } = useTranslation(\"openhands\");\n const { conversationId } = useConversationId();\n const panelViewMatch = useMatch(\"/conversations/:conversationId/panel\");\n const clearEvents = useEventStore((state) => state.clearEvents);\n\n const { isTask, taskStatus, taskDetail } = useTaskPolling();\n\n // The conversationId in the URL belongs to whichever backend was\n // active when the route first mounted. If the user switches backends\n // while this route is still mounted, the id is meaningless under the\n // new backend — disable the active-conversation fetch (and its 404\n // toast) so we don't fire a request that the BackendSelector's\n // redirect will immediately navigate away from anyway. Mirrors the\n // same guard in `routes/automation-detail.tsx`.\n const active = useActiveBackend();\n const mountedBackendId = React.useRef(active.backend.id);\n const mountedOrgId = React.useRef(active.orgId);\n const backendChanged =\n mountedBackendId.current !== active.backend.id ||\n mountedOrgId.current !== active.orgId;\n\n const { data: conversation, isFetched } = useActiveConversation();\n const { data: isAuthed } = useIsAuthed();\n const { resetConversationState } = useConversationStore();\n const navigate = useNavigate();\n const location = useLocation();\n const clearTerminal = useCommandStore((state) => state.clearTerminal);\n const resetConversationRuntimeState = useConversationStateStore(\n (state) => state.reset,\n );\n const setCurrentAgentState = useAgentStore(\n (state) => state.setCurrentAgentState,\n );\n const removeErrorMessage = useErrorMessageStore(\n (state) => state.removeErrorMessage,\n );\n\n React.useEffect(() => {\n clearTerminal();\n resetConversationState();\n resetConversationRuntimeState();\n setCurrentAgentState(AgentState.LOADING);\n removeErrorMessage();\n clearEvents();\n }, [\n conversationId,\n clearTerminal,\n resetConversationState,\n resetConversationRuntimeState,\n setCurrentAgentState,\n removeErrorMessage,\n clearEvents,\n ]);\n\n React.useEffect(() => {\n if (isTask && taskStatus === \"ERROR\") {\n displayErrorToast(\n taskDetail || t(I18nKey.CONVERSATION$FAILED_TO_START_FROM_TASK),\n );\n // Navigate back to the original conversation when a resume task fails so\n // the user isn't stranded at the dead task-{id} URL. The resume effect's\n // ref prevents it from immediately retrying once we land there.\n const resumedFrom = (location.state as Record<string, unknown> | null)\n ?.resumedFromConversationId as string | undefined;\n navigate(\n resumedFrom ? `/conversations/${resumedFrom}` : \"/conversations\",\n { replace: true },\n );\n }\n }, [isTask, taskStatus, taskDetail, t, navigate, location.state]);\n\n React.useEffect(() => {\n if (!isFetched || !isAuthed) return;\n // The BackendSelector is in the middle of redirecting us away from\n // this route — don't toast/navigate based on a 404 that's just\n // \"this id doesn't exist on the new backend\".\n if (backendChanged) return;\n\n if (!conversation) {\n // Clear the per-backend \"last selected\" slot so the next switch\n // to this backend doesn't try to revisit a stale id.\n clearLastConversationId(active.backend.id, active.orgId);\n displayErrorToast(t(I18nKey.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION));\n navigate(\"/conversations\");\n }\n }, [\n conversation,\n isFetched,\n isAuthed,\n navigate,\n t,\n backendChanged,\n active.backend.id,\n active.orgId,\n ]);\n\n // Remember the most recently selected conversation for the current\n // (backend, org) so flipping back to this backend later restores the\n // user to where they left off. Skip while a backend switch is in\n // flight: the id in the URL is from the previous backend and would\n // otherwise overwrite the new backend's memory.\n React.useEffect(() => {\n if (backendChanged) return;\n if (!conversationId) return;\n if (conversationId.startsWith(\"task-\")) return;\n setLastConversationId(active.backend.id, active.orgId, conversationId);\n }, [conversationId, backendChanged, active.backend.id, active.orgId]);\n\n // Cloud conversation resume: mirrors OpenHands' useSandboxRecovery.\n //\n // When the cloud API reports sandbox_status === \"PAUSED\" the sandbox is\n // sleeping. The correct wake-up call is POST /api/v1/sandboxes/{id}/resume\n // (a lightweight unpause). The previous approach — creating a new start task\n // via POST /api/v1/app-conversations — was wrong: it tries to provision a\n // fresh conversation in the sandbox and is subject to a 120-second cold-start\n // timeout that can fail. The resume endpoint simply unpauses the existing one.\n //\n // After calling resume we stay on the current URL. The 3-second refetch\n // interval in useActiveConversation (active while conversation_url is null)\n // polls until conversation_url populates, then the WebSocket connects.\n //\n // A ref guards against duplicate triggers per unique conversation.id within\n // the same route-mount lifetime.\n const resumeTriggeredForRef = React.useRef<string | null>(null);\n React.useEffect(() => {\n if (!isFetched || !conversation) return;\n if (active.backend.kind !== \"cloud\") return;\n if (conversation.sandbox_status !== \"PAUSED\") return; // only resume PAUSED sandboxes\n if (!conversation.sandbox_id) return; // no sandbox to resume\n if (resumeTriggeredForRef.current === conversation.id) return; // already sent\n\n resumeTriggeredForRef.current = conversation.id;\n\n resumeCloudSandbox(conversation.sandbox_id).catch(() => {\n displayErrorToast(t(I18nKey.CONVERSATION$FAILED_TO_START_FROM_TASK));\n });\n }, [\n isFetched,\n conversation?.id,\n conversation?.sandbox_status,\n conversation?.sandbox_id,\n active.backend.kind,\n t,\n ]);\n\n const content = (\n <EventHandler>\n <div data-testid=\"app-route\" className=\"flex h-full flex-col\">\n {panelViewMatch ? (\n <ConversationMobilePanelPage\n onNavigateBack={() => navigate(`/conversations/${conversationId}`)}\n />\n ) : (\n <ConversationMain />\n )}\n </div>\n </EventHandler>\n );\n\n return (\n <WebSocketProviderWrapper conversationId={conversationId}>\n {content}\n </WebSocketProviderWrapper>\n );\n}\n\nexport function ConversationView() {\n return <AppContent />;\n}\n\nexport default ConversationView;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAkCA,SAAS,IAAa;CACpB,IAAM,EAAE,SAAM,EAAe,YAAY,EACnC,EAAE,sBAAmB,GAAmB,EACxC,IAAiB,EAAS,uCAAuC,EACjE,IAAc,GAAe,MAAU,EAAM,YAAY,EAEzD,EAAE,WAAQ,eAAY,kBAAe,GAAgB,EASrD,IAAS,GAAkB,EAC3B,IAAmB,EAAM,OAAO,EAAO,QAAQ,GAAG,EAClD,IAAe,EAAM,OAAO,EAAO,MAAM,EACzC,IACJ,EAAiB,YAAY,EAAO,QAAQ,MAC5C,EAAa,YAAY,EAAO,OAE5B,EAAE,MAAM,GAAc,iBAAc,GAAuB,EAC3D,EAAE,MAAM,MAAa,GAAa,EAClC,EAAE,8BAA2B,GAAsB,EACnD,IAAW,GAAa,EACxB,IAAW,GAAa,EACxB,IAAgB,GAAiB,MAAU,EAAM,cAAc,EAC/D,IAAgC,GACnC,MAAU,EAAM,MAClB,EACK,IAAuB,GAC1B,MAAU,EAAM,qBAClB,EACK,IAAqB,GACxB,MAAU,EAAM,mBAClB;AAkED,CAhEA,EAAM,gBAAgB;AAMpB,EALA,GAAe,EACf,GAAwB,EACxB,GAA+B,EAC/B,EAAqB,EAAW,QAAQ,EACxC,GAAoB,EACpB,GAAa;IACZ;EACD;EACA;EACA;EACA;EACA;EACA;EACA;EACD,CAAC,EAEF,EAAM,gBAAgB;AACpB,MAAI,KAAU,MAAe,SAAS;AACpC,KACE,KAAc,EAAE,EAAQ,uCAAuC,CAChE;GAID,IAAM,IAAe,EAAS,OAC1B;AACJ,KACE,IAAc,kBAAkB,MAAgB,kBAChD,EAAE,SAAS,IAAM,CAClB;;IAEF;EAAC;EAAQ;EAAY;EAAY;EAAG;EAAU,EAAS;EAAM,CAAC,EAEjE,EAAM,gBAAgB;AAChB,GAAC,KAAa,CAAC,KAIf,KAEC,MAGH,EAAwB,EAAO,QAAQ,IAAI,EAAO,MAAM,EACxD,EAAkB,EAAE,EAAQ,wCAAwC,CAAC,EACrE,EAAS,iBAAiB;IAE3B;EACD;EACA;EACA;EACA;EACA;EACA;EACA,EAAO,QAAQ;EACf,EAAO;EACR,CAAC,EAOF,EAAM,gBAAgB;AAChB,OACC,MACD,EAAe,WAAW,QAAQ,IACtC,EAAsB,EAAO,QAAQ,IAAI,EAAO,OAAO,EAAe;IACrE;EAAC;EAAgB;EAAgB,EAAO,QAAQ;EAAI,EAAO;EAAM,CAAC;CAiBrE,IAAM,IAAwB,EAAM,OAAsB,KAAK;AAoC/D,QAnCA,EAAM,gBAAgB;AAChB,GAAC,KAAa,CAAC,KACf,EAAO,QAAQ,SAAS,WACxB,EAAa,mBAAmB,YAC/B,EAAa,cACd,EAAsB,YAAY,EAAa,OAEnD,EAAsB,UAAU,EAAa,IAE7C,EAAmB,EAAa,WAAW,CAAC,YAAY;AACtD,KAAkB,EAAE,EAAQ,uCAAuC,CAAC;IACpE;IACD;EACD;EACA,GAAc;EACd,GAAc;EACd,GAAc;EACd,EAAO,QAAQ;EACf;EACD,CAAC,EAiBA,kBAAC,GAAD;EAA0C;YAd1C,kBAAC,GAAD,EAAA,UACE,kBAAC,OAAD;GAAK,eAAY;GAAY,WAAU;aACpC,IACC,kBAAC,GAAD,EACE,sBAAsB,EAAS,kBAAkB,IAAiB,EAClE,CAAA,GAEF,kBAAC,GAAD,EAAoB,CAAA;GAElB,CAAA,EACO,CAKZ;EACwB,CAAA;;AAI/B,SAAgB,IAAmB;AACjC,QAAO,kBAAC,GAAD,EAAc,CAAA"}
1
+ {"version":3,"file":"conversation.js","names":[],"sources":["../../src/routes/conversation.tsx"],"sourcesContent":["import React from \"react\";\nimport { useNavigate, useLocation, useMatch } from \"react-router\";\nimport { useTranslation } from \"react-i18next\";\n\nimport { useConversationId } from \"#/hooks/use-conversation-id\";\nimport { useCommandStore } from \"#/stores/command-store\";\nimport { useConversationStore } from \"#/stores/conversation-store\";\nimport { useAgentStore } from \"#/stores/agent-store\";\nimport { useConversationStateStore } from \"#/stores/conversation-state-store\";\nimport { useActiveBackend } from \"#/contexts/active-backend-context\";\nimport {\n clearLastConversationId,\n setLastConversationId,\n} from \"#/api/backend-registry/last-conversation-store\";\nimport { AgentState } from \"#/types/agent-state\";\n\nimport { EventHandler } from \"../wrapper/event-handler\";\n\nimport { useActiveConversation } from \"#/hooks/query/use-active-conversation\";\nimport { useTaskPolling } from \"#/hooks/query/use-task-polling\";\n\nimport { displayErrorToast } from \"#/utils/custom-toast-handlers\";\nimport { useIsAuthed } from \"#/hooks/query/use-is-authed\";\nimport {\n ConversationMain,\n ConversationMobilePanelPage,\n} from \"#/components/features/conversation/conversation-main/conversation-main\";\n\nimport { WebSocketProviderWrapper } from \"#/contexts/websocket-provider-wrapper\";\nimport { useErrorMessageStore } from \"#/stores/error-message-store\";\nimport { I18nKey } from \"#/i18n/declaration\";\nimport { resumeCloudSandbox } from \"#/api/cloud/conversation-service.api\";\n\nfunction AppContent() {\n const { t } = useTranslation(\"openhands\");\n const { conversationId } = useConversationId();\n const panelViewMatch = useMatch(\"/conversations/:conversationId/panel\");\n\n const { isTask, taskStatus, taskDetail } = useTaskPolling();\n\n // The conversationId in the URL belongs to whichever backend was\n // active when the route first mounted. If the user switches backends\n // while this route is still mounted, the id is meaningless under the\n // new backend — disable the active-conversation fetch (and its 404\n // toast) so we don't fire a request that the BackendSelector's\n // redirect will immediately navigate away from anyway. Mirrors the\n // same guard in `routes/automation-detail.tsx`.\n const active = useActiveBackend();\n const mountedBackendId = React.useRef(active.backend.id);\n const mountedOrgId = React.useRef(active.orgId);\n const backendChanged =\n mountedBackendId.current !== active.backend.id ||\n mountedOrgId.current !== active.orgId;\n\n const { data: conversation, isFetched } = useActiveConversation();\n const { data: isAuthed } = useIsAuthed();\n const { resetConversationState } = useConversationStore();\n const navigate = useNavigate();\n const location = useLocation();\n const clearTerminal = useCommandStore((state) => state.clearTerminal);\n const resetConversationRuntimeState = useConversationStateStore(\n (state) => state.reset,\n );\n const setCurrentAgentState = useAgentStore(\n (state) => state.setCurrentAgentState,\n );\n const removeErrorMessage = useErrorMessageStore(\n (state) => state.removeErrorMessage,\n );\n\n // Per-conversation UI/runtime resets. The event store is cleared separately,\n // inside ConversationWebSocketProvider, so the clear is ordered *before* the\n // preloaded-history re-seed (see the note there) — clearing it here would run\n // too late and wipe the freshly seeded history on a conversation switch.\n React.useEffect(() => {\n clearTerminal();\n resetConversationState();\n resetConversationRuntimeState();\n setCurrentAgentState(AgentState.LOADING);\n removeErrorMessage();\n }, [\n conversationId,\n clearTerminal,\n resetConversationState,\n resetConversationRuntimeState,\n setCurrentAgentState,\n removeErrorMessage,\n ]);\n\n React.useEffect(() => {\n if (isTask && taskStatus === \"ERROR\") {\n displayErrorToast(\n taskDetail || t(I18nKey.CONVERSATION$FAILED_TO_START_FROM_TASK),\n );\n // Navigate back to the original conversation when a resume task fails so\n // the user isn't stranded at the dead task-{id} URL. The resume effect's\n // ref prevents it from immediately retrying once we land there.\n const resumedFrom = (location.state as Record<string, unknown> | null)\n ?.resumedFromConversationId as string | undefined;\n navigate(\n resumedFrom ? `/conversations/${resumedFrom}` : \"/conversations\",\n { replace: true },\n );\n }\n }, [isTask, taskStatus, taskDetail, t, navigate, location.state]);\n\n React.useEffect(() => {\n if (!isFetched || !isAuthed) return;\n // The BackendSelector is in the middle of redirecting us away from\n // this route — don't toast/navigate based on a 404 that's just\n // \"this id doesn't exist on the new backend\".\n if (backendChanged) return;\n\n if (!conversation) {\n // Clear the per-backend \"last selected\" slot so the next switch\n // to this backend doesn't try to revisit a stale id.\n clearLastConversationId(active.backend.id, active.orgId);\n displayErrorToast(t(I18nKey.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION));\n navigate(\"/conversations\");\n }\n }, [\n conversation,\n isFetched,\n isAuthed,\n navigate,\n t,\n backendChanged,\n active.backend.id,\n active.orgId,\n ]);\n\n // Remember the most recently selected conversation for the current\n // (backend, org) so flipping back to this backend later restores the\n // user to where they left off. Skip while a backend switch is in\n // flight: the id in the URL is from the previous backend and would\n // otherwise overwrite the new backend's memory.\n React.useEffect(() => {\n if (backendChanged) return;\n if (!conversationId) return;\n if (conversationId.startsWith(\"task-\")) return;\n setLastConversationId(active.backend.id, active.orgId, conversationId);\n }, [conversationId, backendChanged, active.backend.id, active.orgId]);\n\n // Cloud conversation resume: mirrors OpenHands' useSandboxRecovery.\n //\n // When the cloud API reports sandbox_status === \"PAUSED\" the sandbox is\n // sleeping. The correct wake-up call is POST /api/v1/sandboxes/{id}/resume\n // (a lightweight unpause). The previous approach — creating a new start task\n // via POST /api/v1/app-conversations — was wrong: it tries to provision a\n // fresh conversation in the sandbox and is subject to a 120-second cold-start\n // timeout that can fail. The resume endpoint simply unpauses the existing one.\n //\n // After calling resume we stay on the current URL. The 3-second refetch\n // interval in useActiveConversation (active while conversation_url is null)\n // polls until conversation_url populates, then the WebSocket connects.\n //\n // A ref guards against duplicate triggers per unique conversation.id within\n // the same route-mount lifetime.\n const resumeTriggeredForRef = React.useRef<string | null>(null);\n React.useEffect(() => {\n if (!isFetched || !conversation) return;\n if (active.backend.kind !== \"cloud\") return;\n if (conversation.sandbox_status !== \"PAUSED\") return; // only resume PAUSED sandboxes\n if (!conversation.sandbox_id) return; // no sandbox to resume\n if (resumeTriggeredForRef.current === conversation.id) return; // already sent\n\n resumeTriggeredForRef.current = conversation.id;\n\n resumeCloudSandbox(conversation.sandbox_id).catch(() => {\n displayErrorToast(t(I18nKey.CONVERSATION$FAILED_TO_START_FROM_TASK));\n });\n }, [\n isFetched,\n conversation?.id,\n conversation?.sandbox_status,\n conversation?.sandbox_id,\n active.backend.kind,\n t,\n ]);\n\n const content = (\n <EventHandler>\n <div data-testid=\"app-route\" className=\"flex h-full flex-col\">\n {panelViewMatch ? (\n <ConversationMobilePanelPage\n onNavigateBack={() => navigate(`/conversations/${conversationId}`)}\n />\n ) : (\n <ConversationMain />\n )}\n </div>\n </EventHandler>\n );\n\n return (\n <WebSocketProviderWrapper conversationId={conversationId}>\n {content}\n </WebSocketProviderWrapper>\n );\n}\n\nexport function ConversationView() {\n return <AppContent />;\n}\n\nexport default ConversationView;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AAiCA,SAAS,IAAa;CACpB,IAAM,EAAE,SAAM,EAAe,YAAY,EACnC,EAAE,sBAAmB,GAAmB,EACxC,IAAiB,EAAS,uCAAuC,EAEjE,EAAE,WAAQ,eAAY,kBAAe,GAAgB,EASrD,IAAS,GAAkB,EAC3B,IAAmB,EAAM,OAAO,EAAO,QAAQ,GAAG,EAClD,IAAe,EAAM,OAAO,EAAO,MAAM,EACzC,IACJ,EAAiB,YAAY,EAAO,QAAQ,MAC5C,EAAa,YAAY,EAAO,OAE5B,EAAE,MAAM,GAAc,iBAAc,GAAuB,EAC3D,EAAE,MAAM,MAAa,GAAa,EAClC,EAAE,8BAA2B,GAAsB,EACnD,IAAW,GAAa,EACxB,IAAW,GAAa,EACxB,IAAgB,GAAiB,MAAU,EAAM,cAAc,EAC/D,IAAgC,GACnC,MAAU,EAAM,MAClB,EACK,IAAuB,GAC1B,MAAU,EAAM,qBAClB,EACK,IAAqB,GACxB,MAAU,EAAM,mBAClB;AAoED,CA9DA,EAAM,gBAAgB;AAKpB,EAJA,GAAe,EACf,GAAwB,EACxB,GAA+B,EAC/B,EAAqB,EAAW,QAAQ,EACxC,GAAoB;IACnB;EACD;EACA;EACA;EACA;EACA;EACA;EACD,CAAC,EAEF,EAAM,gBAAgB;AACpB,MAAI,KAAU,MAAe,SAAS;AACpC,KACE,KAAc,EAAE,EAAQ,uCAAuC,CAChE;GAID,IAAM,IAAe,EAAS,OAC1B;AACJ,KACE,IAAc,kBAAkB,MAAgB,kBAChD,EAAE,SAAS,IAAM,CAClB;;IAEF;EAAC;EAAQ;EAAY;EAAY;EAAG;EAAU,EAAS;EAAM,CAAC,EAEjE,EAAM,gBAAgB;AAChB,GAAC,KAAa,CAAC,KAIf,KAEC,MAGH,EAAwB,EAAO,QAAQ,IAAI,EAAO,MAAM,EACxD,EAAkB,EAAE,EAAQ,wCAAwC,CAAC,EACrE,EAAS,iBAAiB;IAE3B;EACD;EACA;EACA;EACA;EACA;EACA;EACA,EAAO,QAAQ;EACf,EAAO;EACR,CAAC,EAOF,EAAM,gBAAgB;AAChB,OACC,MACD,EAAe,WAAW,QAAQ,IACtC,EAAsB,EAAO,QAAQ,IAAI,EAAO,OAAO,EAAe;IACrE;EAAC;EAAgB;EAAgB,EAAO,QAAQ;EAAI,EAAO;EAAM,CAAC;CAiBrE,IAAM,IAAwB,EAAM,OAAsB,KAAK;AAoC/D,QAnCA,EAAM,gBAAgB;AAChB,GAAC,KAAa,CAAC,KACf,EAAO,QAAQ,SAAS,WACxB,EAAa,mBAAmB,YAC/B,EAAa,cACd,EAAsB,YAAY,EAAa,OAEnD,EAAsB,UAAU,EAAa,IAE7C,EAAmB,EAAa,WAAW,CAAC,YAAY;AACtD,KAAkB,EAAE,EAAQ,uCAAuC,CAAC;IACpE;IACD;EACD;EACA,GAAc;EACd,GAAc;EACd,GAAc;EACd,EAAO,QAAQ;EACf;EACD,CAAC,EAiBA,kBAAC,GAAD;EAA0C;YAd1C,kBAAC,GAAD,EAAA,UACE,kBAAC,OAAD;GAAK,eAAY;GAAY,WAAU;aACpC,IACC,kBAAC,GAAD,EACE,sBAAsB,EAAS,kBAAkB,IAAiB,EAClE,CAAA,GAEF,kBAAC,GAAD,EAAoB,CAAA;GAElB,CAAA,EACO,CAKZ;EACwB,CAAA;;AAI/B,SAAgB,IAAmB;AACjC,QAAO,kBAAC,GAAD,EAAc,CAAA"}
@@ -1,2 +1,2 @@
1
- require(`../_virtual/_rolldown/runtime.cjs`);const e=require(`../node_modules/zustand/esm/react.cjs`),t=require(`../utils/handle-event-for-ui.cjs`);var n=e=>`id`in e?e.id:void 0,r=e=>`timestamp`in e?e.timestamp:void 0,i=(e,t)=>{let n=r(e),i=r(t);return!n&&!i?0:n?i?n.localeCompare(i):-1:1},a=(e,t)=>{if(e.length===0)return!1;let n=e[e.length-1],i=r(n),a=r(t);return!i||!a?!1:a<i},o=(e,r)=>{let i=n(r);if(i!==void 0&&e.eventIds.has(i))return e;let a=i===void 0?e.eventIds:new Set(e.eventIds).add(i);return{...e,events:[...e.events,r],eventIds:a,uiEvents:t.handleEventForUI(r,e.uiEvents)}},s=e=>({...e,events:[...e.events].sort(i),uiEvents:[...e.uiEvents].sort(i)}),c=(e,t)=>{let n=o(e,t);return n===e?e:!a(e.events,t)&&!a(e.uiEvents,t)?n:s(n)},l=e.create()(e=>({events:[],eventIds:new Set,uiEvents:[],addEvent:t=>e(e=>c(e,t)),addEvents:r=>e(e=>{if(r.length===0)return e;let i=new Set(e.eventIds),a=[...e.events],o=[...e.uiEvents],c=!1;for(let e of r){let r=n(e);r!==void 0&&i.has(r)||(c=!0,r!==void 0&&i.add(r),a.push(e),o=t.handleEventForUI(e,o))}return c?s({...e,events:a,eventIds:i,uiEvents:o}):e}),clearEvents:()=>e(()=>({events:[],eventIds:new Set,uiEvents:[]}))}));exports.useEventStore=l;
1
+ require(`../_virtual/_rolldown/runtime.cjs`);const e=require(`../node_modules/zustand/esm/react.cjs`),t=require(`../utils/handle-event-for-ui.cjs`);var n=e=>`id`in e?e.id:void 0,r=e=>`timestamp`in e?e.timestamp:void 0,i=(e,t)=>{let n=r(e),i=r(t);return!n&&!i?0:n?i?n.localeCompare(i):-1:1},a=(e,t)=>{if(e.length===0)return!1;let n=e[e.length-1],i=r(n),a=r(t);return!i||!a?!1:a<i},o=(e,r)=>{let i=n(r);if(i!==void 0&&e.eventIds.has(i))return e;let a=i===void 0?e.eventIds:new Set(e.eventIds).add(i);return{...e,events:[...e.events,r],eventIds:a,uiEvents:t.handleEventForUI(r,e.uiEvents)}},s=e=>({...e,events:[...e.events].sort(i),uiEvents:[...e.uiEvents].sort(i)}),c=(e,t)=>{let n=o(e,t);return n===e?e:!a(e.events,t)&&!a(e.uiEvents,t)?n:s(n)},l=e.create()(e=>({events:[],eventIds:new Set,uiEvents:[],loadedConversationId:null,addEvent:t=>e(e=>c(e,t)),addEvents:r=>e(e=>{if(r.length===0)return e;let i=new Set(e.eventIds),a=[...e.events],o=[...e.uiEvents],c=!1;for(let e of r){let r=n(e);r!==void 0&&i.has(r)||(c=!0,r!==void 0&&i.add(r),a.push(e),o=t.handleEventForUI(e,o))}return c?s({...e,events:a,eventIds:i,uiEvents:o}):e}),clearEvents:()=>e(()=>({events:[],eventIds:new Set,uiEvents:[],loadedConversationId:null})),clearEventsForConversation:t=>e(()=>({events:[],eventIds:new Set,uiEvents:[],loadedConversationId:t}))}));exports.useEventStore=l;
2
2
  //# sourceMappingURL=use-event-store.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-event-store.cjs","names":[],"sources":["../../src/stores/use-event-store.ts"],"sourcesContent":["import { create } from \"zustand\";\nimport { OpenHandsEvent } from \"#/types/agent-server/core\";\nimport { handleEventForUI } from \"#/utils/handle-event-for-ui\";\n\nexport type OHEvent = OpenHandsEvent & {\n isFromPlanningAgent?: boolean;\n};\n\nconst getEventId = (event: OHEvent): string | number | undefined =>\n \"id\" in event ? event.id : undefined;\n\nconst getEventTimestamp = (event: OHEvent): string | undefined =>\n \"timestamp\" in event ? event.timestamp : undefined;\n\n/**\n * Compare two events by timestamp for sorting.\n * Events without timestamps are placed at the end.\n */\nconst compareEventsByTimestamp = (a: OHEvent, b: OHEvent): number => {\n const timestampA = getEventTimestamp(a);\n const timestampB = getEventTimestamp(b);\n\n // Events without timestamps go to the end\n if (!timestampA && !timestampB) return 0;\n if (!timestampA) return 1;\n if (!timestampB) return -1;\n\n // Compare ISO timestamp strings (lexicographic comparison works for ISO format)\n return timestampA.localeCompare(timestampB);\n};\n\n/**\n * Check if the new event needs sorting (i.e., it's out of order).\n * Returns true if the new event's timestamp is earlier than the last event's timestamp.\n */\nconst needsSorting = (events: OHEvent[], newEvent: OHEvent): boolean => {\n if (events.length === 0) return false;\n\n const lastEvent = events[events.length - 1];\n const lastTimestamp = getEventTimestamp(lastEvent);\n const newTimestamp = getEventTimestamp(newEvent);\n\n // If either event doesn't have a timestamp, don't sort\n if (!lastTimestamp || !newTimestamp) return false;\n\n // Sort needed if new event's timestamp is earlier than last event's timestamp\n return newTimestamp < lastTimestamp;\n};\n\nexport interface EventState {\n events: OHEvent[];\n eventIds: Set<string | number>;\n uiEvents: OHEvent[];\n addEvent: (event: OHEvent) => void;\n /**\n * Bulk-insert events. Used for the initial REST history load and for\n * \"scroll up to load older\" pagination. Newly-added events are de-duped\n * against the existing store and the combined list is re-sorted by\n * timestamp so older pages drop into the correct position.\n */\n addEvents: (events: OHEvent[]) => void;\n clearEvents: () => void;\n}\n\nconst appendEvent = (state: EventState, event: OHEvent): EventState => {\n // Deduplicate: skip if event with same id already exists (O(1) lookup)\n const eventId = getEventId(event);\n if (eventId !== undefined && state.eventIds.has(eventId)) {\n return state;\n }\n\n const newEventIds =\n eventId !== undefined\n ? new Set(state.eventIds).add(eventId)\n : state.eventIds;\n\n return {\n ...state,\n events: [...state.events, event],\n eventIds: newEventIds,\n uiEvents: handleEventForUI(event, state.uiEvents),\n };\n};\n\nconst sortEventState = (state: EventState): EventState => ({\n ...state,\n events: [...state.events].sort(compareEventsByTimestamp),\n uiEvents: [...state.uiEvents].sort(compareEventsByTimestamp),\n});\n\nconst applyAddEvent = (state: EventState, event: OHEvent): EventState => {\n const next = appendEvent(state, event);\n if (next === state) {\n return state;\n }\n\n if (\n !needsSorting(state.events, event) &&\n !needsSorting(state.uiEvents, event)\n ) {\n return next;\n }\n\n return sortEventState(next);\n};\n\nexport const useEventStore = create<EventState>()((set) => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n addEvent: (event: OHEvent) => set((state) => applyAddEvent(state, event)),\n addEvents: (incoming: OHEvent[]) =>\n set((state) => {\n if (incoming.length === 0) return state;\n\n const eventIds = new Set(state.eventIds);\n const events = [...state.events];\n let uiEvents = [...state.uiEvents];\n let added = false;\n\n for (const event of incoming) {\n const eventId = getEventId(event);\n const isDuplicate = eventId !== undefined && eventIds.has(eventId);\n\n if (!isDuplicate) {\n added = true;\n if (eventId !== undefined) {\n eventIds.add(eventId);\n }\n events.push(event);\n uiEvents = handleEventForUI(event, uiEvents);\n }\n }\n\n if (!added) {\n return state;\n }\n\n return sortEventState({\n ...state,\n events,\n eventIds,\n uiEvents,\n });\n }),\n clearEvents: () =>\n set(() => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n })),\n}));\n\n// In dev builds, expose the store on `window` so that fixture/preview\n// scripts (e.g. .pr/issue-132 demo capture) can inject synthetic events\n// without round-tripping through the agent-server. Tree-shaken in\n// production builds via `import.meta.env.DEV`.\nif (\n typeof window !== \"undefined\" &&\n typeof import.meta !== \"undefined\" &&\n (import.meta as { env?: { DEV?: boolean } }).env?.DEV\n) {\n (\n window as unknown as { __OH_EVENT_STORE__?: typeof useEventStore }\n ).__OH_EVENT_STORE__ = useEventStore;\n}\n"],"mappings":"oJAQA,IAAM,EAAc,GAClB,OAAQ,EAAQ,EAAM,GAAK,IAAA,GAEvB,EAAqB,GACzB,cAAe,EAAQ,EAAM,UAAY,IAAA,GAMrC,GAA4B,EAAY,IAAuB,CACnE,IAAM,EAAa,EAAkB,EAAE,CACjC,EAAa,EAAkB,EAAE,CAQvC,MALI,CAAC,GAAc,CAAC,EAAmB,EAClC,EACA,EAGE,EAAW,cAAc,EAAW,CAHnB,GADA,GAWpB,GAAgB,EAAmB,IAA+B,CACtE,GAAI,EAAO,SAAW,EAAG,MAAO,GAEhC,IAAM,EAAY,EAAO,EAAO,OAAS,GACnC,EAAgB,EAAkB,EAAU,CAC5C,EAAe,EAAkB,EAAS,CAMhD,MAHI,CAAC,GAAiB,CAAC,EAAqB,GAGrC,EAAe,GAkBlB,GAAe,EAAmB,IAA+B,CAErE,IAAM,EAAU,EAAW,EAAM,CACjC,GAAI,IAAY,IAAA,IAAa,EAAM,SAAS,IAAI,EAAQ,CACtD,OAAO,EAGT,IAAM,EACJ,IAAY,IAAA,GAER,EAAM,SADN,IAAI,IAAI,EAAM,SAAS,CAAC,IAAI,EAAQ,CAG1C,MAAO,CACL,GAAG,EACH,OAAQ,CAAC,GAAG,EAAM,OAAQ,EAAM,CAChC,SAAU,EACV,SAAU,EAAA,iBAAiB,EAAO,EAAM,SAAS,CAClD,EAGG,EAAkB,IAAmC,CACzD,GAAG,EACH,OAAQ,CAAC,GAAG,EAAM,OAAO,CAAC,KAAK,EAAyB,CACxD,SAAU,CAAC,GAAG,EAAM,SAAS,CAAC,KAAK,EAAyB,CAC7D,EAEK,GAAiB,EAAmB,IAA+B,CACvE,IAAM,EAAO,EAAY,EAAO,EAAM,CAYtC,OAXI,IAAS,EACJ,EAIP,CAAC,EAAa,EAAM,OAAQ,EAAM,EAClC,CAAC,EAAa,EAAM,SAAU,EAAM,CAE7B,EAGF,EAAe,EAAK,EAGhB,EAAgB,EAAA,QAAoB,CAAE,IAAS,CAC1D,OAAQ,EAAE,CACV,SAAU,IAAI,IACd,SAAU,EAAE,CACZ,SAAW,GAAmB,EAAK,GAAU,EAAc,EAAO,EAAM,CAAC,CACzE,UAAY,GACV,EAAK,GAAU,CACb,GAAI,EAAS,SAAW,EAAG,OAAO,EAElC,IAAM,EAAW,IAAI,IAAI,EAAM,SAAS,CAClC,EAAS,CAAC,GAAG,EAAM,OAAO,CAC5B,EAAW,CAAC,GAAG,EAAM,SAAS,CAC9B,EAAQ,GAEZ,IAAK,IAAM,KAAS,EAAU,CAC5B,IAAM,EAAU,EAAW,EAAM,CACb,IAAY,IAAA,IAAa,EAAS,IAAI,EAAQ,GAGhE,EAAQ,GACJ,IAAY,IAAA,IACd,EAAS,IAAI,EAAQ,CAEvB,EAAO,KAAK,EAAM,CAClB,EAAW,EAAA,iBAAiB,EAAO,EAAS,EAQhD,OAJK,EAIE,EAAe,CACpB,GAAG,EACH,SACA,WACA,WACD,CAAC,CARO,GAST,CACJ,gBACE,OAAW,CACT,OAAQ,EAAE,CACV,SAAU,IAAI,IACd,SAAU,EAAE,CACb,EAAE,CACN,EAAE"}
1
+ {"version":3,"file":"use-event-store.cjs","names":[],"sources":["../../src/stores/use-event-store.ts"],"sourcesContent":["import { create } from \"zustand\";\nimport { OpenHandsEvent } from \"#/types/agent-server/core\";\nimport { handleEventForUI } from \"#/utils/handle-event-for-ui\";\n\nexport type OHEvent = OpenHandsEvent & {\n isFromPlanningAgent?: boolean;\n};\n\nconst getEventId = (event: OHEvent): string | number | undefined =>\n \"id\" in event ? event.id : undefined;\n\nconst getEventTimestamp = (event: OHEvent): string | undefined =>\n \"timestamp\" in event ? event.timestamp : undefined;\n\n/**\n * Compare two events by timestamp for sorting.\n * Events without timestamps are placed at the end.\n */\nconst compareEventsByTimestamp = (a: OHEvent, b: OHEvent): number => {\n const timestampA = getEventTimestamp(a);\n const timestampB = getEventTimestamp(b);\n\n // Events without timestamps go to the end\n if (!timestampA && !timestampB) return 0;\n if (!timestampA) return 1;\n if (!timestampB) return -1;\n\n // Compare ISO timestamp strings (lexicographic comparison works for ISO format)\n return timestampA.localeCompare(timestampB);\n};\n\n/**\n * Check if the new event needs sorting (i.e., it's out of order).\n * Returns true if the new event's timestamp is earlier than the last event's timestamp.\n */\nconst needsSorting = (events: OHEvent[], newEvent: OHEvent): boolean => {\n if (events.length === 0) return false;\n\n const lastEvent = events[events.length - 1];\n const lastTimestamp = getEventTimestamp(lastEvent);\n const newTimestamp = getEventTimestamp(newEvent);\n\n // If either event doesn't have a timestamp, don't sort\n if (!lastTimestamp || !newTimestamp) return false;\n\n // Sort needed if new event's timestamp is earlier than last event's timestamp\n return newTimestamp < lastTimestamp;\n};\n\nexport interface EventState {\n events: OHEvent[];\n eventIds: Set<string | number>;\n uiEvents: OHEvent[];\n /**\n * The conversation whose events currently populate the store. The store is\n * global (not keyed by conversation), so the conversation route uses this to\n * tell a genuine conversation switch apart from a remount of the *same*\n * conversation (e.g. navigating to Settings and back) — only the former\n * should clear the accumulated events.\n */\n loadedConversationId: string | null;\n addEvent: (event: OHEvent) => void;\n /**\n * Bulk-insert events. Used for the initial REST history load and for\n * \"scroll up to load older\" pagination. Newly-added events are de-duped\n * against the existing store and the combined list is re-sorted by\n * timestamp so older pages drop into the correct position.\n */\n addEvents: (events: OHEvent[]) => void;\n /**\n * Clear all events. Also resets `loadedConversationId` to `null` so the\n * store never claims to hold a conversation whose events have been wiped —\n * the invariant (`loadedConversationId` reflects the conversation whose\n * events are in the arrays) holds even for a standalone clear.\n */\n clearEvents: () => void;\n /**\n * Atomically clear all events and record which conversation is now loaded.\n * Collapsing the reset and the bookkeeping into a single `set` keeps the\n * store invariant enforced at the boundary, rather than relying on every\n * call-site to invoke a clear and a `loadedConversationId` setter in the\n * right order.\n */\n clearEventsForConversation: (conversationId: string | null) => void;\n}\n\nconst appendEvent = (state: EventState, event: OHEvent): EventState => {\n // Deduplicate: skip if event with same id already exists (O(1) lookup)\n const eventId = getEventId(event);\n if (eventId !== undefined && state.eventIds.has(eventId)) {\n return state;\n }\n\n const newEventIds =\n eventId !== undefined\n ? new Set(state.eventIds).add(eventId)\n : state.eventIds;\n\n return {\n ...state,\n events: [...state.events, event],\n eventIds: newEventIds,\n uiEvents: handleEventForUI(event, state.uiEvents),\n };\n};\n\nconst sortEventState = (state: EventState): EventState => ({\n ...state,\n events: [...state.events].sort(compareEventsByTimestamp),\n uiEvents: [...state.uiEvents].sort(compareEventsByTimestamp),\n});\n\nconst applyAddEvent = (state: EventState, event: OHEvent): EventState => {\n const next = appendEvent(state, event);\n if (next === state) {\n return state;\n }\n\n if (\n !needsSorting(state.events, event) &&\n !needsSorting(state.uiEvents, event)\n ) {\n return next;\n }\n\n return sortEventState(next);\n};\n\nexport const useEventStore = create<EventState>()((set) => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n loadedConversationId: null,\n addEvent: (event: OHEvent) => set((state) => applyAddEvent(state, event)),\n addEvents: (incoming: OHEvent[]) =>\n set((state) => {\n if (incoming.length === 0) return state;\n\n const eventIds = new Set(state.eventIds);\n const events = [...state.events];\n let uiEvents = [...state.uiEvents];\n let added = false;\n\n for (const event of incoming) {\n const eventId = getEventId(event);\n const isDuplicate = eventId !== undefined && eventIds.has(eventId);\n\n if (!isDuplicate) {\n added = true;\n if (eventId !== undefined) {\n eventIds.add(eventId);\n }\n events.push(event);\n uiEvents = handleEventForUI(event, uiEvents);\n }\n }\n\n if (!added) {\n return state;\n }\n\n return sortEventState({\n ...state,\n events,\n eventIds,\n uiEvents,\n });\n }),\n clearEvents: () =>\n set(() => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n loadedConversationId: null,\n })),\n clearEventsForConversation: (conversationId: string | null) =>\n set(() => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n loadedConversationId: conversationId,\n })),\n}));\n\n// In dev builds, expose the store on `window` so that fixture/preview\n// scripts (e.g. .pr/issue-132 demo capture) can inject synthetic events\n// without round-tripping through the agent-server. Tree-shaken in\n// production builds via `import.meta.env.DEV`.\nif (\n typeof window !== \"undefined\" &&\n typeof import.meta !== \"undefined\" &&\n (import.meta as { env?: { DEV?: boolean } }).env?.DEV\n) {\n (\n window as unknown as { __OH_EVENT_STORE__?: typeof useEventStore }\n ).__OH_EVENT_STORE__ = useEventStore;\n}\n"],"mappings":"oJAQA,IAAM,EAAc,GAClB,OAAQ,EAAQ,EAAM,GAAK,IAAA,GAEvB,EAAqB,GACzB,cAAe,EAAQ,EAAM,UAAY,IAAA,GAMrC,GAA4B,EAAY,IAAuB,CACnE,IAAM,EAAa,EAAkB,EAAE,CACjC,EAAa,EAAkB,EAAE,CAQvC,MALI,CAAC,GAAc,CAAC,EAAmB,EAClC,EACA,EAGE,EAAW,cAAc,EAAW,CAHnB,GADA,GAWpB,GAAgB,EAAmB,IAA+B,CACtE,GAAI,EAAO,SAAW,EAAG,MAAO,GAEhC,IAAM,EAAY,EAAO,EAAO,OAAS,GACnC,EAAgB,EAAkB,EAAU,CAC5C,EAAe,EAAkB,EAAS,CAMhD,MAHI,CAAC,GAAiB,CAAC,EAAqB,GAGrC,EAAe,GAwClB,GAAe,EAAmB,IAA+B,CAErE,IAAM,EAAU,EAAW,EAAM,CACjC,GAAI,IAAY,IAAA,IAAa,EAAM,SAAS,IAAI,EAAQ,CACtD,OAAO,EAGT,IAAM,EACJ,IAAY,IAAA,GAER,EAAM,SADN,IAAI,IAAI,EAAM,SAAS,CAAC,IAAI,EAAQ,CAG1C,MAAO,CACL,GAAG,EACH,OAAQ,CAAC,GAAG,EAAM,OAAQ,EAAM,CAChC,SAAU,EACV,SAAU,EAAA,iBAAiB,EAAO,EAAM,SAAS,CAClD,EAGG,EAAkB,IAAmC,CACzD,GAAG,EACH,OAAQ,CAAC,GAAG,EAAM,OAAO,CAAC,KAAK,EAAyB,CACxD,SAAU,CAAC,GAAG,EAAM,SAAS,CAAC,KAAK,EAAyB,CAC7D,EAEK,GAAiB,EAAmB,IAA+B,CACvE,IAAM,EAAO,EAAY,EAAO,EAAM,CAYtC,OAXI,IAAS,EACJ,EAIP,CAAC,EAAa,EAAM,OAAQ,EAAM,EAClC,CAAC,EAAa,EAAM,SAAU,EAAM,CAE7B,EAGF,EAAe,EAAK,EAGhB,EAAgB,EAAA,QAAoB,CAAE,IAAS,CAC1D,OAAQ,EAAE,CACV,SAAU,IAAI,IACd,SAAU,EAAE,CACZ,qBAAsB,KACtB,SAAW,GAAmB,EAAK,GAAU,EAAc,EAAO,EAAM,CAAC,CACzE,UAAY,GACV,EAAK,GAAU,CACb,GAAI,EAAS,SAAW,EAAG,OAAO,EAElC,IAAM,EAAW,IAAI,IAAI,EAAM,SAAS,CAClC,EAAS,CAAC,GAAG,EAAM,OAAO,CAC5B,EAAW,CAAC,GAAG,EAAM,SAAS,CAC9B,EAAQ,GAEZ,IAAK,IAAM,KAAS,EAAU,CAC5B,IAAM,EAAU,EAAW,EAAM,CACb,IAAY,IAAA,IAAa,EAAS,IAAI,EAAQ,GAGhE,EAAQ,GACJ,IAAY,IAAA,IACd,EAAS,IAAI,EAAQ,CAEvB,EAAO,KAAK,EAAM,CAClB,EAAW,EAAA,iBAAiB,EAAO,EAAS,EAQhD,OAJK,EAIE,EAAe,CACpB,GAAG,EACH,SACA,WACA,WACD,CAAC,CARO,GAST,CACJ,gBACE,OAAW,CACT,OAAQ,EAAE,CACV,SAAU,IAAI,IACd,SAAU,EAAE,CACZ,qBAAsB,KACvB,EAAE,CACL,2BAA6B,GAC3B,OAAW,CACT,OAAQ,EAAE,CACV,SAAU,IAAI,IACd,SAAU,EAAE,CACZ,qBAAsB,EACvB,EAAE,CACN,EAAE"}
@@ -6,6 +6,14 @@ export interface EventState {
6
6
  events: OHEvent[];
7
7
  eventIds: Set<string | number>;
8
8
  uiEvents: OHEvent[];
9
+ /**
10
+ * The conversation whose events currently populate the store. The store is
11
+ * global (not keyed by conversation), so the conversation route uses this to
12
+ * tell a genuine conversation switch apart from a remount of the *same*
13
+ * conversation (e.g. navigating to Settings and back) — only the former
14
+ * should clear the accumulated events.
15
+ */
16
+ loadedConversationId: string | null;
9
17
  addEvent: (event: OHEvent) => void;
10
18
  /**
11
19
  * Bulk-insert events. Used for the initial REST history load and for
@@ -14,6 +22,20 @@ export interface EventState {
14
22
  * timestamp so older pages drop into the correct position.
15
23
  */
16
24
  addEvents: (events: OHEvent[]) => void;
25
+ /**
26
+ * Clear all events. Also resets `loadedConversationId` to `null` so the
27
+ * store never claims to hold a conversation whose events have been wiped —
28
+ * the invariant (`loadedConversationId` reflects the conversation whose
29
+ * events are in the arrays) holds even for a standalone clear.
30
+ */
17
31
  clearEvents: () => void;
32
+ /**
33
+ * Atomically clear all events and record which conversation is now loaded.
34
+ * Collapsing the reset and the bookkeeping into a single `set` keeps the
35
+ * store invariant enforced at the boundary, rather than relying on every
36
+ * call-site to invoke a clear and a `loadedConversationId` setter in the
37
+ * right order.
38
+ */
39
+ clearEventsForConversation: (conversationId: string | null) => void;
18
40
  }
19
41
  export declare const useEventStore: import("zustand").UseBoundStore<import("zustand").StoreApi<EventState>>;
@@ -29,6 +29,7 @@ var n = (e) => "id" in e ? e.id : void 0, r = (e) => "timestamp" in e ? e.timest
29
29
  events: [],
30
30
  eventIds: /* @__PURE__ */ new Set(),
31
31
  uiEvents: [],
32
+ loadedConversationId: null,
32
33
  addEvent: (t) => e((e) => c(e, t)),
33
34
  addEvents: (r) => e((e) => {
34
35
  if (r.length === 0) return e;
@@ -47,7 +48,14 @@ var n = (e) => "id" in e ? e.id : void 0, r = (e) => "timestamp" in e ? e.timest
47
48
  clearEvents: () => e(() => ({
48
49
  events: [],
49
50
  eventIds: /* @__PURE__ */ new Set(),
50
- uiEvents: []
51
+ uiEvents: [],
52
+ loadedConversationId: null
53
+ })),
54
+ clearEventsForConversation: (t) => e(() => ({
55
+ events: [],
56
+ eventIds: /* @__PURE__ */ new Set(),
57
+ uiEvents: [],
58
+ loadedConversationId: t
51
59
  }))
52
60
  }));
53
61
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"use-event-store.js","names":[],"sources":["../../src/stores/use-event-store.ts"],"sourcesContent":["import { create } from \"zustand\";\nimport { OpenHandsEvent } from \"#/types/agent-server/core\";\nimport { handleEventForUI } from \"#/utils/handle-event-for-ui\";\n\nexport type OHEvent = OpenHandsEvent & {\n isFromPlanningAgent?: boolean;\n};\n\nconst getEventId = (event: OHEvent): string | number | undefined =>\n \"id\" in event ? event.id : undefined;\n\nconst getEventTimestamp = (event: OHEvent): string | undefined =>\n \"timestamp\" in event ? event.timestamp : undefined;\n\n/**\n * Compare two events by timestamp for sorting.\n * Events without timestamps are placed at the end.\n */\nconst compareEventsByTimestamp = (a: OHEvent, b: OHEvent): number => {\n const timestampA = getEventTimestamp(a);\n const timestampB = getEventTimestamp(b);\n\n // Events without timestamps go to the end\n if (!timestampA && !timestampB) return 0;\n if (!timestampA) return 1;\n if (!timestampB) return -1;\n\n // Compare ISO timestamp strings (lexicographic comparison works for ISO format)\n return timestampA.localeCompare(timestampB);\n};\n\n/**\n * Check if the new event needs sorting (i.e., it's out of order).\n * Returns true if the new event's timestamp is earlier than the last event's timestamp.\n */\nconst needsSorting = (events: OHEvent[], newEvent: OHEvent): boolean => {\n if (events.length === 0) return false;\n\n const lastEvent = events[events.length - 1];\n const lastTimestamp = getEventTimestamp(lastEvent);\n const newTimestamp = getEventTimestamp(newEvent);\n\n // If either event doesn't have a timestamp, don't sort\n if (!lastTimestamp || !newTimestamp) return false;\n\n // Sort needed if new event's timestamp is earlier than last event's timestamp\n return newTimestamp < lastTimestamp;\n};\n\nexport interface EventState {\n events: OHEvent[];\n eventIds: Set<string | number>;\n uiEvents: OHEvent[];\n addEvent: (event: OHEvent) => void;\n /**\n * Bulk-insert events. Used for the initial REST history load and for\n * \"scroll up to load older\" pagination. Newly-added events are de-duped\n * against the existing store and the combined list is re-sorted by\n * timestamp so older pages drop into the correct position.\n */\n addEvents: (events: OHEvent[]) => void;\n clearEvents: () => void;\n}\n\nconst appendEvent = (state: EventState, event: OHEvent): EventState => {\n // Deduplicate: skip if event with same id already exists (O(1) lookup)\n const eventId = getEventId(event);\n if (eventId !== undefined && state.eventIds.has(eventId)) {\n return state;\n }\n\n const newEventIds =\n eventId !== undefined\n ? new Set(state.eventIds).add(eventId)\n : state.eventIds;\n\n return {\n ...state,\n events: [...state.events, event],\n eventIds: newEventIds,\n uiEvents: handleEventForUI(event, state.uiEvents),\n };\n};\n\nconst sortEventState = (state: EventState): EventState => ({\n ...state,\n events: [...state.events].sort(compareEventsByTimestamp),\n uiEvents: [...state.uiEvents].sort(compareEventsByTimestamp),\n});\n\nconst applyAddEvent = (state: EventState, event: OHEvent): EventState => {\n const next = appendEvent(state, event);\n if (next === state) {\n return state;\n }\n\n if (\n !needsSorting(state.events, event) &&\n !needsSorting(state.uiEvents, event)\n ) {\n return next;\n }\n\n return sortEventState(next);\n};\n\nexport const useEventStore = create<EventState>()((set) => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n addEvent: (event: OHEvent) => set((state) => applyAddEvent(state, event)),\n addEvents: (incoming: OHEvent[]) =>\n set((state) => {\n if (incoming.length === 0) return state;\n\n const eventIds = new Set(state.eventIds);\n const events = [...state.events];\n let uiEvents = [...state.uiEvents];\n let added = false;\n\n for (const event of incoming) {\n const eventId = getEventId(event);\n const isDuplicate = eventId !== undefined && eventIds.has(eventId);\n\n if (!isDuplicate) {\n added = true;\n if (eventId !== undefined) {\n eventIds.add(eventId);\n }\n events.push(event);\n uiEvents = handleEventForUI(event, uiEvents);\n }\n }\n\n if (!added) {\n return state;\n }\n\n return sortEventState({\n ...state,\n events,\n eventIds,\n uiEvents,\n });\n }),\n clearEvents: () =>\n set(() => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n })),\n}));\n\n// In dev builds, expose the store on `window` so that fixture/preview\n// scripts (e.g. .pr/issue-132 demo capture) can inject synthetic events\n// without round-tripping through the agent-server. Tree-shaken in\n// production builds via `import.meta.env.DEV`.\nif (\n typeof window !== \"undefined\" &&\n typeof import.meta !== \"undefined\" &&\n (import.meta as { env?: { DEV?: boolean } }).env?.DEV\n) {\n (\n window as unknown as { __OH_EVENT_STORE__?: typeof useEventStore }\n ).__OH_EVENT_STORE__ = useEventStore;\n}\n"],"mappings":";;;AAQA,IAAM,KAAc,MAClB,QAAQ,IAAQ,EAAM,KAAK,KAAA,GAEvB,KAAqB,MACzB,eAAe,IAAQ,EAAM,YAAY,KAAA,GAMrC,KAA4B,GAAY,MAAuB;CACnE,IAAM,IAAa,EAAkB,EAAE,EACjC,IAAa,EAAkB,EAAE;AAQvC,QALI,CAAC,KAAc,CAAC,IAAmB,IAClC,IACA,IAGE,EAAW,cAAc,EAAW,GAHnB,KADA;GAWpB,KAAgB,GAAmB,MAA+B;AACtE,KAAI,EAAO,WAAW,EAAG,QAAO;CAEhC,IAAM,IAAY,EAAO,EAAO,SAAS,IACnC,IAAgB,EAAkB,EAAU,EAC5C,IAAe,EAAkB,EAAS;AAMhD,QAHI,CAAC,KAAiB,CAAC,IAAqB,KAGrC,IAAe;GAkBlB,KAAe,GAAmB,MAA+B;CAErE,IAAM,IAAU,EAAW,EAAM;AACjC,KAAI,MAAY,KAAA,KAAa,EAAM,SAAS,IAAI,EAAQ,CACtD,QAAO;CAGT,IAAM,IACJ,MAAY,KAAA,IAER,EAAM,WADN,IAAI,IAAI,EAAM,SAAS,CAAC,IAAI,EAAQ;AAG1C,QAAO;EACL,GAAG;EACH,QAAQ,CAAC,GAAG,EAAM,QAAQ,EAAM;EAChC,UAAU;EACV,UAAU,EAAiB,GAAO,EAAM,SAAS;EAClD;GAGG,KAAkB,OAAmC;CACzD,GAAG;CACH,QAAQ,CAAC,GAAG,EAAM,OAAO,CAAC,KAAK,EAAyB;CACxD,UAAU,CAAC,GAAG,EAAM,SAAS,CAAC,KAAK,EAAyB;CAC7D,GAEK,KAAiB,GAAmB,MAA+B;CACvE,IAAM,IAAO,EAAY,GAAO,EAAM;AAYtC,QAXI,MAAS,IACJ,IAIP,CAAC,EAAa,EAAM,QAAQ,EAAM,IAClC,CAAC,EAAa,EAAM,UAAU,EAAM,GAE7B,IAGF,EAAe,EAAK;GAGhB,IAAgB,GAAoB,EAAE,OAAS;CAC1D,QAAQ,EAAE;CACV,0BAAU,IAAI,KAAK;CACnB,UAAU,EAAE;CACZ,WAAW,MAAmB,GAAK,MAAU,EAAc,GAAO,EAAM,CAAC;CACzE,YAAY,MACV,GAAK,MAAU;AACb,MAAI,EAAS,WAAW,EAAG,QAAO;EAElC,IAAM,IAAW,IAAI,IAAI,EAAM,SAAS,EAClC,IAAS,CAAC,GAAG,EAAM,OAAO,EAC5B,IAAW,CAAC,GAAG,EAAM,SAAS,EAC9B,IAAQ;AAEZ,OAAK,IAAM,KAAS,GAAU;GAC5B,IAAM,IAAU,EAAW,EAAM;AAGjC,GAFoB,MAAY,KAAA,KAAa,EAAS,IAAI,EAAQ,KAGhE,IAAQ,IACJ,MAAY,KAAA,KACd,EAAS,IAAI,EAAQ,EAEvB,EAAO,KAAK,EAAM,EAClB,IAAW,EAAiB,GAAO,EAAS;;AAQhD,SAJK,IAIE,EAAe;GACpB,GAAG;GACH;GACA;GACA;GACD,CAAC,GARO;GAST;CACJ,mBACE,SAAW;EACT,QAAQ,EAAE;EACV,0BAAU,IAAI,KAAK;EACnB,UAAU,EAAE;EACb,EAAE;CACN,EAAE"}
1
+ {"version":3,"file":"use-event-store.js","names":[],"sources":["../../src/stores/use-event-store.ts"],"sourcesContent":["import { create } from \"zustand\";\nimport { OpenHandsEvent } from \"#/types/agent-server/core\";\nimport { handleEventForUI } from \"#/utils/handle-event-for-ui\";\n\nexport type OHEvent = OpenHandsEvent & {\n isFromPlanningAgent?: boolean;\n};\n\nconst getEventId = (event: OHEvent): string | number | undefined =>\n \"id\" in event ? event.id : undefined;\n\nconst getEventTimestamp = (event: OHEvent): string | undefined =>\n \"timestamp\" in event ? event.timestamp : undefined;\n\n/**\n * Compare two events by timestamp for sorting.\n * Events without timestamps are placed at the end.\n */\nconst compareEventsByTimestamp = (a: OHEvent, b: OHEvent): number => {\n const timestampA = getEventTimestamp(a);\n const timestampB = getEventTimestamp(b);\n\n // Events without timestamps go to the end\n if (!timestampA && !timestampB) return 0;\n if (!timestampA) return 1;\n if (!timestampB) return -1;\n\n // Compare ISO timestamp strings (lexicographic comparison works for ISO format)\n return timestampA.localeCompare(timestampB);\n};\n\n/**\n * Check if the new event needs sorting (i.e., it's out of order).\n * Returns true if the new event's timestamp is earlier than the last event's timestamp.\n */\nconst needsSorting = (events: OHEvent[], newEvent: OHEvent): boolean => {\n if (events.length === 0) return false;\n\n const lastEvent = events[events.length - 1];\n const lastTimestamp = getEventTimestamp(lastEvent);\n const newTimestamp = getEventTimestamp(newEvent);\n\n // If either event doesn't have a timestamp, don't sort\n if (!lastTimestamp || !newTimestamp) return false;\n\n // Sort needed if new event's timestamp is earlier than last event's timestamp\n return newTimestamp < lastTimestamp;\n};\n\nexport interface EventState {\n events: OHEvent[];\n eventIds: Set<string | number>;\n uiEvents: OHEvent[];\n /**\n * The conversation whose events currently populate the store. The store is\n * global (not keyed by conversation), so the conversation route uses this to\n * tell a genuine conversation switch apart from a remount of the *same*\n * conversation (e.g. navigating to Settings and back) — only the former\n * should clear the accumulated events.\n */\n loadedConversationId: string | null;\n addEvent: (event: OHEvent) => void;\n /**\n * Bulk-insert events. Used for the initial REST history load and for\n * \"scroll up to load older\" pagination. Newly-added events are de-duped\n * against the existing store and the combined list is re-sorted by\n * timestamp so older pages drop into the correct position.\n */\n addEvents: (events: OHEvent[]) => void;\n /**\n * Clear all events. Also resets `loadedConversationId` to `null` so the\n * store never claims to hold a conversation whose events have been wiped —\n * the invariant (`loadedConversationId` reflects the conversation whose\n * events are in the arrays) holds even for a standalone clear.\n */\n clearEvents: () => void;\n /**\n * Atomically clear all events and record which conversation is now loaded.\n * Collapsing the reset and the bookkeeping into a single `set` keeps the\n * store invariant enforced at the boundary, rather than relying on every\n * call-site to invoke a clear and a `loadedConversationId` setter in the\n * right order.\n */\n clearEventsForConversation: (conversationId: string | null) => void;\n}\n\nconst appendEvent = (state: EventState, event: OHEvent): EventState => {\n // Deduplicate: skip if event with same id already exists (O(1) lookup)\n const eventId = getEventId(event);\n if (eventId !== undefined && state.eventIds.has(eventId)) {\n return state;\n }\n\n const newEventIds =\n eventId !== undefined\n ? new Set(state.eventIds).add(eventId)\n : state.eventIds;\n\n return {\n ...state,\n events: [...state.events, event],\n eventIds: newEventIds,\n uiEvents: handleEventForUI(event, state.uiEvents),\n };\n};\n\nconst sortEventState = (state: EventState): EventState => ({\n ...state,\n events: [...state.events].sort(compareEventsByTimestamp),\n uiEvents: [...state.uiEvents].sort(compareEventsByTimestamp),\n});\n\nconst applyAddEvent = (state: EventState, event: OHEvent): EventState => {\n const next = appendEvent(state, event);\n if (next === state) {\n return state;\n }\n\n if (\n !needsSorting(state.events, event) &&\n !needsSorting(state.uiEvents, event)\n ) {\n return next;\n }\n\n return sortEventState(next);\n};\n\nexport const useEventStore = create<EventState>()((set) => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n loadedConversationId: null,\n addEvent: (event: OHEvent) => set((state) => applyAddEvent(state, event)),\n addEvents: (incoming: OHEvent[]) =>\n set((state) => {\n if (incoming.length === 0) return state;\n\n const eventIds = new Set(state.eventIds);\n const events = [...state.events];\n let uiEvents = [...state.uiEvents];\n let added = false;\n\n for (const event of incoming) {\n const eventId = getEventId(event);\n const isDuplicate = eventId !== undefined && eventIds.has(eventId);\n\n if (!isDuplicate) {\n added = true;\n if (eventId !== undefined) {\n eventIds.add(eventId);\n }\n events.push(event);\n uiEvents = handleEventForUI(event, uiEvents);\n }\n }\n\n if (!added) {\n return state;\n }\n\n return sortEventState({\n ...state,\n events,\n eventIds,\n uiEvents,\n });\n }),\n clearEvents: () =>\n set(() => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n loadedConversationId: null,\n })),\n clearEventsForConversation: (conversationId: string | null) =>\n set(() => ({\n events: [],\n eventIds: new Set(),\n uiEvents: [],\n loadedConversationId: conversationId,\n })),\n}));\n\n// In dev builds, expose the store on `window` so that fixture/preview\n// scripts (e.g. .pr/issue-132 demo capture) can inject synthetic events\n// without round-tripping through the agent-server. Tree-shaken in\n// production builds via `import.meta.env.DEV`.\nif (\n typeof window !== \"undefined\" &&\n typeof import.meta !== \"undefined\" &&\n (import.meta as { env?: { DEV?: boolean } }).env?.DEV\n) {\n (\n window as unknown as { __OH_EVENT_STORE__?: typeof useEventStore }\n ).__OH_EVENT_STORE__ = useEventStore;\n}\n"],"mappings":";;;AAQA,IAAM,KAAc,MAClB,QAAQ,IAAQ,EAAM,KAAK,KAAA,GAEvB,KAAqB,MACzB,eAAe,IAAQ,EAAM,YAAY,KAAA,GAMrC,KAA4B,GAAY,MAAuB;CACnE,IAAM,IAAa,EAAkB,EAAE,EACjC,IAAa,EAAkB,EAAE;AAQvC,QALI,CAAC,KAAc,CAAC,IAAmB,IAClC,IACA,IAGE,EAAW,cAAc,EAAW,GAHnB,KADA;GAWpB,KAAgB,GAAmB,MAA+B;AACtE,KAAI,EAAO,WAAW,EAAG,QAAO;CAEhC,IAAM,IAAY,EAAO,EAAO,SAAS,IACnC,IAAgB,EAAkB,EAAU,EAC5C,IAAe,EAAkB,EAAS;AAMhD,QAHI,CAAC,KAAiB,CAAC,IAAqB,KAGrC,IAAe;GAwClB,KAAe,GAAmB,MAA+B;CAErE,IAAM,IAAU,EAAW,EAAM;AACjC,KAAI,MAAY,KAAA,KAAa,EAAM,SAAS,IAAI,EAAQ,CACtD,QAAO;CAGT,IAAM,IACJ,MAAY,KAAA,IAER,EAAM,WADN,IAAI,IAAI,EAAM,SAAS,CAAC,IAAI,EAAQ;AAG1C,QAAO;EACL,GAAG;EACH,QAAQ,CAAC,GAAG,EAAM,QAAQ,EAAM;EAChC,UAAU;EACV,UAAU,EAAiB,GAAO,EAAM,SAAS;EAClD;GAGG,KAAkB,OAAmC;CACzD,GAAG;CACH,QAAQ,CAAC,GAAG,EAAM,OAAO,CAAC,KAAK,EAAyB;CACxD,UAAU,CAAC,GAAG,EAAM,SAAS,CAAC,KAAK,EAAyB;CAC7D,GAEK,KAAiB,GAAmB,MAA+B;CACvE,IAAM,IAAO,EAAY,GAAO,EAAM;AAYtC,QAXI,MAAS,IACJ,IAIP,CAAC,EAAa,EAAM,QAAQ,EAAM,IAClC,CAAC,EAAa,EAAM,UAAU,EAAM,GAE7B,IAGF,EAAe,EAAK;GAGhB,IAAgB,GAAoB,EAAE,OAAS;CAC1D,QAAQ,EAAE;CACV,0BAAU,IAAI,KAAK;CACnB,UAAU,EAAE;CACZ,sBAAsB;CACtB,WAAW,MAAmB,GAAK,MAAU,EAAc,GAAO,EAAM,CAAC;CACzE,YAAY,MACV,GAAK,MAAU;AACb,MAAI,EAAS,WAAW,EAAG,QAAO;EAElC,IAAM,IAAW,IAAI,IAAI,EAAM,SAAS,EAClC,IAAS,CAAC,GAAG,EAAM,OAAO,EAC5B,IAAW,CAAC,GAAG,EAAM,SAAS,EAC9B,IAAQ;AAEZ,OAAK,IAAM,KAAS,GAAU;GAC5B,IAAM,IAAU,EAAW,EAAM;AAGjC,GAFoB,MAAY,KAAA,KAAa,EAAS,IAAI,EAAQ,KAGhE,IAAQ,IACJ,MAAY,KAAA,KACd,EAAS,IAAI,EAAQ,EAEvB,EAAO,KAAK,EAAM,EAClB,IAAW,EAAiB,GAAO,EAAS;;AAQhD,SAJK,IAIE,EAAe;GACpB,GAAG;GACH;GACA;GACA;GACD,CAAC,GARO;GAST;CACJ,mBACE,SAAW;EACT,QAAQ,EAAE;EACV,0BAAU,IAAI,KAAK;EACnB,UAAU,EAAE;EACZ,sBAAsB;EACvB,EAAE;CACL,6BAA6B,MAC3B,SAAW;EACT,QAAQ,EAAE;EACV,0BAAU,IAAI,KAAK;EACnB,UAAU,EAAE;EACZ,sBAAsB;EACvB,EAAE;CACN,EAAE"}
@@ -5,7 +5,7 @@ declare const contextMenuVariants: (props?: ({
5
5
  size?: "default" | "compact" | null | undefined;
6
6
  layout?: "vertical" | null | undefined;
7
7
  position?: "none" | "top" | "bottom" | null | undefined;
8
- spacing?: "none" | "default" | null | undefined;
8
+ spacing?: "default" | "none" | null | undefined;
9
9
  alignment?: "none" | "left" | "right" | null | undefined;
10
10
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
11
11
  interface ContextMenuProps {
@@ -1,6 +1,6 @@
1
1
  import { type VariantProps } from "class-variance-authority";
2
2
  declare const helpLinkVariants: (props?: ({
3
- size?: "settings" | "default" | null | undefined;
3
+ size?: "default" | "settings" | null | undefined;
4
4
  linkColor?: "default" | "white" | null | undefined;
5
5
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
6
  interface HelpLinkProps extends VariantProps<typeof helpLinkVariants> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openhands/agent-canvas",
3
- "version": "1.0.0-alpha.8",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Agent Canvas UI for OpenHands - run AI coding agents with a visual interface",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -23,8 +23,8 @@
23
23
  "@heroui/react": "2.8.10",
24
24
  "@microlink/react-json-view": "1.31.20",
25
25
  "@monaco-editor/react": "4.7.0",
26
- "@openhands/extensions": "git+https://github.com/OpenHands/extensions.git#39711065f53166c52608462f60a4c8507253ce56",
27
- "@openhands/typescript-client": "1.24.0",
26
+ "@openhands/extensions": "git+https://github.com/OpenHands/extensions.git#e14f740c59b4bfd7369d4bb6aea5eeb33dd05909",
27
+ "@openhands/typescript-client": "1.24.3",
28
28
  "@react-router/node": "7.14.2",
29
29
  "@react-router/serve": "7.14.2",
30
30
  "@tailwindcss/vite": "4.2.4",
@@ -210,6 +210,36 @@ function tryPort(port, host = "127.0.0.1") {
210
210
  });
211
211
  }
212
212
 
213
+ /**
214
+ * Assert that all listed ports are available, throwing a descriptive error if
215
+ * any are already in use.
216
+ *
217
+ * Intended as a pre-flight check before spawning services so that a concurrent
218
+ * agent-canvas instance is detected immediately rather than silently starting
219
+ * on a different port.
220
+ *
221
+ * @param {Array<{name: string, port: number}>} portConfigs - Named port list
222
+ * @param {string} [host]
223
+ */
224
+ export async function assertPortsFree(portConfigs, host = "127.0.0.1") {
225
+ const results = await Promise.all(
226
+ portConfigs.map(async ({ name, port }) => ({
227
+ name,
228
+ port,
229
+ free: await tryPort(port, host),
230
+ })),
231
+ );
232
+ const busy = results.filter(({ free }) => !free);
233
+ if (busy.length === 0) return;
234
+
235
+ const lines = busy.map(({ name, port }) => ` • ${name}: port ${port}`).join("\n");
236
+ throw new Error(
237
+ `Cannot start: the following ports are already in use:\n\n${lines}\n\n` +
238
+ `Another agent-canvas instance may already be running.\n` +
239
+ `Stop it first, or override the port via environment variables (e.g. PORT=<other>).`,
240
+ );
241
+ }
242
+
213
243
  /**
214
244
  * Find multiple free ports at once, each preferring its specified default.
215
245
  *
@@ -491,26 +521,14 @@ export async function buildSafeDevConfigAsync(
491
521
  preferredBackendPort + 1,
492
522
  );
493
523
 
494
- // Find available ports, preferring the defaults
495
- const ports = await findFreePorts([
496
- { name: "backend", preferred: preferredBackendPort },
497
- { name: "vscode", preferred: preferredVscodePort },
524
+ // Fail fast if any required port is already in use.
525
+ await assertPortsFree([
526
+ { name: "agent-server", port: preferredBackendPort },
527
+ { name: "vscode", port: preferredVscodePort },
498
528
  ]);
499
529
 
500
- // Log if we're using non-default ports
501
- if (ports.backend !== preferredBackendPort) {
502
- console.log(
503
- ` ℹ Port ${preferredBackendPort} busy, using ${ports.backend} for agent-server`,
504
- );
505
- }
506
- if (ports.vscode !== preferredVscodePort) {
507
- console.log(
508
- ` ℹ Port ${preferredVscodePort} busy, using ${ports.vscode} for vscode`,
509
- );
510
- }
511
-
512
530
  return buildConfigFromPorts(
513
- { backendPort: ports.backend, vscodePort: ports.vscode },
531
+ { backendPort: preferredBackendPort, vscodePort: preferredVscodePort },
514
532
  cwd,
515
533
  env,
516
534
  );
@@ -51,13 +51,13 @@ import { setTimeout as delay } from "node:timers/promises";
51
51
  import process from "node:process";
52
52
 
53
53
  import {
54
+ assertPortsFree,
54
55
  buildAgentServerCommand,
55
56
  buildSafeDevConfig,
56
57
  buildAgentServerEnv,
57
58
  buildNpmScriptCommand,
58
59
  buildRuntimeServicesInfo,
59
60
  formatMissingUvxGuidance,
60
- findFreePorts,
61
61
  getOrCreatePersistedApiKey,
62
62
  validateFrontendDependencies,
63
63
  validateLocalAgentServerPath,
@@ -281,52 +281,27 @@ async function buildConfig(args, env = process.env) {
281
281
  env.OH_AUTOMATION_REPO = args.automationRepo;
282
282
  }
283
283
 
284
- // Preferred ports (from env or defaults)
284
+ // Preferred ports (from env or defaults).
285
+ // OH_CANVAS_SAFE_BACKEND_PORT / OH_CANVAS_SAFE_AUTOMATION_PORT /
286
+ // OH_CANVAS_SAFE_VITE_PORT allow tests (and advanced users) to redirect
287
+ // internal service ports without affecting the production default.
285
288
  const preferredIngressPort = args.port || parseInt(env.PORT, 10) || 8000;
286
- const preferredBackendPort = DEFAULT_BACKEND_PORT;
287
- const preferredAutomationPort = DEFAULT_AUTOMATION_PORT;
288
- const preferredVitePort = 3001;
289
-
290
- // Find available ports, preferring the defaults
291
- logStep("ports", "Allocating ports...");
292
- const ports = await findFreePorts([
293
- { name: "ingress", preferred: preferredIngressPort },
294
- { name: "backend", preferred: preferredBackendPort },
295
- { name: "automation", preferred: preferredAutomationPort },
296
- { name: "vite", preferred: preferredVitePort },
289
+ const preferredBackendPort =
290
+ parseInt(env.OH_CANVAS_SAFE_BACKEND_PORT, 10) || DEFAULT_BACKEND_PORT;
291
+ const preferredAutomationPort =
292
+ parseInt(env.OH_CANVAS_SAFE_AUTOMATION_PORT, 10) || DEFAULT_AUTOMATION_PORT;
293
+ const preferredVitePort = parseInt(env.OH_CANVAS_SAFE_VITE_PORT, 10) || 3001;
294
+
295
+ // Fail fast if any preferred port is already in use.
296
+ logStep("ports", "Checking ports...");
297
+ await assertPortsFree([
298
+ { name: "ingress", port: preferredIngressPort },
299
+ { name: "agent-server", port: preferredBackendPort },
300
+ { name: "automation", port: preferredAutomationPort },
301
+ { name: "vite", port: preferredVitePort },
297
302
  ]);
298
303
 
299
- // Log any port changes
300
- if (ports.ingress !== preferredIngressPort) {
301
- logService(
302
- "ports",
303
- `Port ${preferredIngressPort} busy, using ${ports.ingress} for ingress`,
304
- c.yellow,
305
- );
306
- }
307
- if (ports.backend !== preferredBackendPort) {
308
- logService(
309
- "ports",
310
- `Port ${preferredBackendPort} busy, using ${ports.backend} for agent-server`,
311
- c.yellow,
312
- );
313
- }
314
- if (ports.automation !== preferredAutomationPort) {
315
- logService(
316
- "ports",
317
- `Port ${preferredAutomationPort} busy, using ${ports.automation} for automation`,
318
- c.yellow,
319
- );
320
- }
321
- if (ports.vite !== preferredVitePort) {
322
- logService(
323
- "ports",
324
- `Port ${preferredVitePort} busy, using ${ports.vite} for vite`,
325
- c.yellow,
326
- );
327
- }
328
-
329
- const vscodePort = ports.backend + 1000;
304
+ const vscodePort = preferredBackendPort + 1000;
330
305
 
331
306
  // Session API key — shared by both agent-server and automation backend.
332
307
  // Both validate it via the `X-Session-API-Key` header.
@@ -336,19 +311,19 @@ async function buildConfig(args, env = process.env) {
336
311
  const safeConfig = buildSafeDevConfig(projectRoot, {
337
312
  ...env,
338
313
  OH_CANVAS_SAFE_STATE_DIR: stateDir,
339
- OH_CANVAS_SAFE_BACKEND_PORT: ports.backend.toString(),
314
+ OH_CANVAS_SAFE_BACKEND_PORT: preferredBackendPort.toString(),
340
315
  OH_CANVAS_SAFE_VSCODE_PORT: vscodePort.toString(),
341
316
  });
342
317
  const sessionApiKey = safeConfig.sessionApiKey;
343
318
 
344
319
  return {
345
320
  // Ingress port (main entry point)
346
- ingressPort: ports.ingress,
321
+ ingressPort: preferredIngressPort,
347
322
 
348
323
  // Service ports (internal)
349
- agentServerPort: ports.backend,
350
- autoBackendPort: ports.automation,
351
- vitePort: ports.vite,
324
+ agentServerPort: preferredBackendPort,
325
+ autoBackendPort: preferredAutomationPort,
326
+ vitePort: preferredVitePort,
352
327
  vscodePort,
353
328
 
354
329
  // Paths