SaylorTwift HF Staff commited on
Commit
5f40163
·
verified ·
1 Parent(s): c0af099

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/constants/acp-brand-marks.ts +18 -0
  2. src/constants/acp-providers.test.ts +114 -0
  3. src/constants/acp-providers.ts +542 -0
  4. src/constants/canvas-ui.ts +10 -0
  5. src/constants/child-conversation.ts +37 -0
  6. src/constants/llm-balance.ts +14 -0
  7. src/constants/llm-subscription.ts +75 -0
  8. src/constants/profile-scope.ts +33 -0
  9. src/constants/server-connection-error.ts +1 -0
  10. src/constants/settings-nav.tsx +77 -0
  11. src/constants/skills-docs.ts +7 -0
  12. src/constants/status-colors.ts +5 -0
  13. src/context/navigation-context.tsx +41 -0
  14. src/context/scroll-context.tsx +50 -0
  15. src/contexts/active-backend-context.tsx +251 -0
  16. src/contexts/conversation-websocket-context.tsx +1278 -0
  17. src/contexts/settings-section-header-context.tsx +35 -0
  18. src/contexts/websocket-provider-wrapper.tsx +114 -0
  19. src/mocks/agent-profiles-handlers.ts +245 -0
  20. src/mocks/analytics-handlers.ts +13 -0
  21. src/mocks/auth-handlers.ts +33 -0
  22. src/mocks/automation-handlers.ts +364 -0
  23. src/mocks/automation-runs.mock.ts +167 -0
  24. src/mocks/automations.mock.ts +177 -0
  25. src/mocks/browser.ts +4 -0
  26. src/mocks/canvas-extensions-handlers.test.ts +83 -0
  27. src/mocks/canvas-extensions-handlers.ts +169 -0
  28. src/mocks/conversation-handlers.ts +488 -0
  29. src/mocks/feedback-handlers.ts +15 -0
  30. src/mocks/file-service-handlers.ts +104 -0
  31. src/mocks/git-repository-handlers.ts +259 -0
  32. src/mocks/handlers.ts +60 -0
  33. src/mocks/node.ts +4 -0
  34. src/mocks/secrets-handlers.ts +94 -0
  35. src/mocks/settings-handlers.ts +1256 -0
  36. src/mocks/should-start-mock-worker.ts +9 -0
  37. src/mocks/workspaces-handlers.ts +105 -0
  38. src/stores/agent-store.ts +21 -0
  39. src/stores/archived-conversations-store.ts +84 -0
  40. src/stores/browser-store.ts +26 -0
  41. src/stores/btw-store.ts +81 -0
  42. src/stores/command-menu-store.ts +15 -0
  43. src/stores/command-store.ts +26 -0
  44. src/stores/conversation-panel-preferences-store.ts +241 -0
  45. src/stores/conversation-state-store.ts +41 -0
  46. src/stores/conversation-store.ts +396 -0
  47. src/stores/error-message-store.ts +66 -0
  48. src/stores/event-message-store.ts +26 -0
  49. src/stores/files-tab-store.ts +140 -0
  50. src/stores/free-models-store.ts +53 -0
src/constants/acp-brand-marks.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Brand-mark SVG path data for the agent harnesses we recognise on a
2
+ // conversation chip. Kept as raw path strings (drawn with ``currentColor``
3
+ // so callers can size & colour them via Tailwind) rather than embedded
4
+ // ``<svg>`` files to avoid an extra asset round-trip and so the icons can
5
+ // be reused inline at multiple sizes (onboarding tile, conversation chip,
6
+ // settings preview).
7
+
8
+ export const CLAUDE_CODE_MARK_PATH =
9
+ "m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z";
10
+ export const CLAUDE_CODE_VIEWBOX = "0 0 100 100";
11
+
12
+ export const CODEX_MARK_PATH =
13
+ "M4.04286 0.228393C4.52451 0.0304495 5.0488 -0.0409817 5.56586 0.0208928C6.23236 0.0973928 6.82636 0.380893 7.34786 0.870893C7.35488 0.877545 7.36344 0.882351 7.37278 0.884881C7.38212 0.887412 7.39194 0.887588 7.40136 0.885393C8.10536 0.712393 8.78236 0.773393 9.43186 1.06839L9.46336 1.08339L9.54036 1.12139C10.2189 1.47289 10.7054 2.00639 10.9994 2.72039C11.1384 3.05989 11.2084 3.41439 11.2099 3.78339C11.2197 4.05816 11.1893 4.33288 11.1199 4.59889C11.1164 4.61245 11.1165 4.62665 11.12 4.6402C11.1235 4.65374 11.1303 4.66618 11.1399 4.67639C11.5329 5.0749 11.8063 5.57572 11.9289 6.12189C12.1214 7.07239 11.9239 7.92939 11.3374 8.69189L11.2464 8.80189C10.8579 9.24669 10.3481 9.56836 9.77936 9.72739C9.76694 9.73097 9.75556 9.73747 9.74617 9.74634C9.73677 9.75521 9.72964 9.7662 9.72536 9.77839C9.59786 10.1464 9.46986 10.4604 9.23186 10.7744C8.63236 11.5654 7.75086 12.0054 6.75786 11.9999C5.96636 11.9959 5.26486 11.7064 4.65286 11.1319C4.64358 11.1234 4.63225 11.1174 4.61998 11.1146C4.6077 11.1118 4.59491 11.1123 4.58286 11.1159C4.32386 11.1994 4.06286 11.2114 3.78086 11.2084C3.33033 11.2048 2.88658 11.0984 2.48336 10.8974C2.0613 10.688 1.6939 10.3831 1.41036 10.0069C1.30886 9.87239 1.20836 9.74589 1.13486 9.59639C1.03349 9.39033 0.95066 9.17565 0.887357 8.95489C0.754446 8.45324 0.751521 7.92599 0.878857 7.42289C0.882974 7.41102 0.884341 7.39837 0.882857 7.38589C0.88038 7.37348 0.873877 7.36223 0.864357 7.35389C0.556147 7.04213 0.320543 6.66619 0.174357 6.25289C0.0775698 5.99842 0.0213841 5.73031 0.00785682 5.45839C-0.0163229 5.10033 0.0153902 4.74069 0.101857 4.39239C0.326857 3.65039 0.756357 3.06839 1.39036 2.64589C1.53136 2.55189 1.66536 2.47889 1.79136 2.42689C1.93436 2.36689 2.07786 2.31689 2.22186 2.27489C2.23216 2.27184 2.24153 2.26626 2.24913 2.25866C2.25672 2.25107 2.2623 2.24169 2.26536 2.23139C2.37455 1.83888 2.56235 1.47264 2.81736 1.15489C3.15736 0.731893 3.56586 0.422893 4.04286 0.228393ZM3.64086 4.15339C3.58503 4.05573 3.49269 3.98424 3.38415 3.95465C3.27562 3.92507 3.15977 3.93981 3.06211 3.99564C2.96444 4.05147 2.89295 4.14381 2.86337 4.25235C2.83378 4.36088 2.84853 4.47673 2.90436 4.57439L3.75136 6.05689L2.90736 7.48089C2.85561 7.57738 2.84315 7.69014 2.87257 7.7956C2.902 7.90106 2.97104 7.99108 3.06526 8.04684C3.15949 8.1026 3.27162 8.1198 3.37823 8.09484C3.48484 8.06988 3.57768 8.00469 3.63736 7.91289L4.60736 6.27689C4.64561 6.21237 4.66609 6.13887 4.66671 6.06386C4.66732 5.98886 4.64805 5.91503 4.61086 5.84989L3.64086 4.15339ZM6.36386 7.27339C6.25583 7.27982 6.15434 7.32727 6.08012 7.40603C6.00591 7.48479 5.96458 7.58892 5.96458 7.69714C5.96458 7.80536 6.00591 7.90949 6.08012 7.98826C6.15434 8.06702 6.25583 8.11446 6.36386 8.12089H8.78786C8.89675 8.1156 8.99943 8.06862 9.07462 7.98969C9.14982 7.91075 9.19176 7.80591 9.19176 7.69689C9.19176 7.58787 9.14982 7.48303 9.07462 7.4041C8.99943 7.32516 8.89675 7.27818 8.78786 7.27289H6.36386V7.27339Z";
14
+ export const CODEX_VIEWBOX = "0 0 12 12";
15
+
16
+ export const GEMINI_MARK_PATH =
17
+ "M12 0C12.904 6.056 17.944 11.096 24 12C17.944 12.904 12.904 17.944 12 24C11.096 17.944 6.056 12.904 0 12C6.056 11.096 11.096 6.056 12 0Z";
18
+ export const GEMINI_VIEWBOX = "0 0 24 24";
src/constants/acp-providers.test.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ACP_PROVIDERS as CLIENT_ACP_PROVIDERS } from "@openhands/typescript-client";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ ACP_MANAGED_SENTINEL,
5
+ ACP_PROVIDERS,
6
+ getAcpProviderSecrets,
7
+ SURFACED_ACP_PROVIDERS,
8
+ resolveEffectiveAcpModel,
9
+ } from "./acp-providers";
10
+
11
+ describe("ACP_PROVIDERS", () => {
12
+ it("passes every codex data field through from the pinned client registry", () => {
13
+ // No local override may sit between the registry and the picker: a
14
+ // hand-maintained model entry survives an upstream *removal*, so Canvas
15
+ // would keep offering an id the live ACP server has started rejecting.
16
+ const codex = ACP_PROVIDERS.find(({ key }) => key === "codex");
17
+ const client = CLIENT_ACP_PROVIDERS.codex;
18
+ expect(codex?.default_command).toEqual([...client.default_command]);
19
+ expect(codex?.available_models).toEqual(
20
+ client.available_models.map(({ id, label }) => ({ id, label })),
21
+ );
22
+ });
23
+
24
+ it("carries GPT-6 Astra, so the pin is new enough to launch it", () => {
25
+ // Canary for the pin's freshness, not a catalog Canvas maintains: Astra
26
+ // needs codex-acp >= 1.10.0, which only client >= 1.45.0 mirrors.
27
+ const codex = ACP_PROVIDERS.find(({ key }) => key === "codex");
28
+ expect(codex?.available_models?.map(({ id }) => id)).toContain(
29
+ "gpt-6-astra",
30
+ );
31
+ });
32
+ });
33
+
34
+ describe("resolveEffectiveAcpModel", () => {
35
+ it("surfaces the real claude-agent-acp 0.44+ 'default' model", () => {
36
+ // ``default`` ("Default (recommended)") is a real, selectable Claude model
37
+ // in the configOptions select — the server reports it as the current model.
38
+ // It must NOT be suppressed as a placeholder (regression: the chip would
39
+ // otherwise show no model for a session genuinely running on 'default').
40
+ expect(resolveEffectiveAcpModel({ runtimeId: "default" })).toBe("default");
41
+ expect(
42
+ resolveEffectiveAcpModel({ runtimeName: "Default (recommended)" }),
43
+ ).toBe("Default (recommended)");
44
+ });
45
+
46
+ it("follows the runtime → configured → sdkLlm precedence", () => {
47
+ expect(
48
+ resolveEffectiveAcpModel({
49
+ runtimeName: "Sonnet",
50
+ runtimeId: "sonnet",
51
+ configured: "haiku",
52
+ }),
53
+ ).toBe("Sonnet");
54
+ expect(resolveEffectiveAcpModel({ configured: "haiku" })).toBe("haiku");
55
+ expect(resolveEffectiveAcpModel({ sdkLlm: "gpt-5.5/medium" })).toBe(
56
+ "gpt-5.5/medium",
57
+ );
58
+ });
59
+
60
+ it("still suppresses the legacy acp-managed sentinel and blanks", () => {
61
+ expect(
62
+ resolveEffectiveAcpModel({ sdkLlm: ACP_MANAGED_SENTINEL }),
63
+ ).toBeNull();
64
+ expect(resolveEffectiveAcpModel({ runtimeId: " " })).toBeNull();
65
+ expect(resolveEffectiveAcpModel({})).toBeNull();
66
+ });
67
+
68
+ it("falls back to providerDefault only when no concrete model resolves", () => {
69
+ expect(
70
+ resolveEffectiveAcpModel({
71
+ sdkLlm: ACP_MANAGED_SENTINEL,
72
+ providerDefault: "opus[1m]",
73
+ }),
74
+ ).toBe("opus[1m]");
75
+ // A real 'default' wins over providerDefault — it is a concrete model.
76
+ expect(
77
+ resolveEffectiveAcpModel({
78
+ runtimeId: "default",
79
+ providerDefault: "opus[1m]",
80
+ }),
81
+ ).toBe("default");
82
+ });
83
+ });
84
+
85
+ describe("surfaced ACP providers", () => {
86
+ // Everything the pinned client registry publishes that Canvas does not
87
+ // offer. Derived, so a harness added upstream is covered here without an
88
+ // edit — the point of declaring what we surface rather than what we hide.
89
+ const unsurfaced = Object.keys(CLIENT_ACP_PROVIDERS).filter(
90
+ (key) => !SURFACED_ACP_PROVIDERS.includes(key),
91
+ );
92
+
93
+ it("surfaces only Claude Code, Codex and Gemini CLI", () => {
94
+ expect([...SURFACED_ACP_PROVIDERS]).toEqual([
95
+ "claude-code",
96
+ "codex",
97
+ "gemini-cli",
98
+ ]);
99
+ });
100
+
101
+ it("surfaces nothing the pinned client registry has dropped", () => {
102
+ // A rename or removal upstream must break loudly rather than leave a tile
103
+ // whose command and models resolve to nothing. An addition stays a no-op.
104
+ expect(
105
+ SURFACED_ACP_PROVIDERS.filter((key) => !(key in CLIENT_ACP_PROVIDERS)),
106
+ ).toEqual([]);
107
+ });
108
+
109
+ it("offers no credential fields for a harness it does not surface", () => {
110
+ unsurfaced.forEach((key) => {
111
+ expect(getAcpProviderSecrets(key)).toEqual([]);
112
+ });
113
+ });
114
+ });
src/constants/acp-providers.ts ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getAcpProvider as getClientAcpProvider } from "@openhands/typescript-client";
2
+ import { I18nKey } from "#/i18n/declaration";
3
+
4
+ export type ACPProviderIcon =
5
+ | "claude-code"
6
+ | "codex"
7
+ | "gemini"
8
+ | "cli-generic";
9
+
10
+ export const ACP_PROVIDER_FALLBACK_ICON: ACPProviderIcon = "cli-generic";
11
+
12
+ // Sentinel ``agent.llm.model`` returned by older SDKs for ACP conversations
13
+ // in lieu of a real model. Suppressed at every consumer that resolves a
14
+ // display string.
15
+ //
16
+ // NB: ``"default"`` is NOT a placeholder. claude-agent-acp 0.44+ exposes
17
+ // ``default`` ("Default (recommended)") as a real, selectable model in its
18
+ // configOptions select — the server reports it as ``currentModelId`` and lists
19
+ // it in ``availableModels`` — so it must surface like any other model id.
20
+ export const ACP_MANAGED_SENTINEL = "acp-managed";
21
+
22
+ /**
23
+ * Filter for "real" ACP model strings — non-empty and not the legacy
24
+ * ``acp-managed`` sentinel. Returns the trimmed value on success, ``null``
25
+ * otherwise.
26
+ */
27
+ function realAcpModel(value: unknown): string | null {
28
+ if (typeof value !== "string") return null;
29
+ const trimmed = value.trim();
30
+ if (!trimmed) return null;
31
+ if (trimmed === ACP_MANAGED_SENTINEL) return null;
32
+ return trimmed;
33
+ }
34
+
35
+ /**
36
+ * Single source of truth for resolving the model string to surface for an
37
+ * ACP conversation/settings context. Consumed by the conversation adapter
38
+ * (chip text), the conversation-creation path (concrete ``acp_model``
39
+ * payload), the Settings → Agent form (initial value), and the chat-input
40
+ * model label.
41
+ *
42
+ * Precedence: SDK runtime fields → user-configured ``acp_model`` →
43
+ * legacy ``agent.llm.model`` → provider default (when ``providerDefault``
44
+ * is passed). Pass ``providerDefault`` only on surfaces that should
45
+ * silently substitute the registry default; omit it for the conversation
46
+ * chip, which must distinguish "no concrete model" from "default".
47
+ */
48
+ export function resolveEffectiveAcpModel(inputs: {
49
+ runtimeName?: string | null;
50
+ runtimeId?: string | null;
51
+ configured?: string | null;
52
+ sdkLlm?: string | null;
53
+ providerDefault?: string | null;
54
+ }): string | null {
55
+ for (const candidate of [
56
+ inputs.runtimeName,
57
+ inputs.runtimeId,
58
+ inputs.configured,
59
+ inputs.sdkLlm,
60
+ ]) {
61
+ const value = realAcpModel(candidate);
62
+ if (value) return value;
63
+ }
64
+ return inputs.providerDefault ?? null;
65
+ }
66
+
67
+ /**
68
+ * Shape of a built-in ACP (Agent Client Protocol) provider as Canvas consumes
69
+ * it. The data fields (display name, launch command, model picker + default)
70
+ * are sourced at module load from ``@openhands/typescript-client``'s ACP
71
+ * registry — the generated mirror of the Python source of truth
72
+ * ``openhands.sdk.settings.acp_providers``. This config only adds the
73
+ * Canvas-specific UI fields ({@link ACPProviderConfig.icon} +
74
+ * {@link ACPProviderConfig.description_key}); see {@link ACP_PROVIDER_UI}.
75
+ */
76
+ export interface ACPProviderConfig {
77
+ /** Stable registry key, also stored on conversations as ``tags.acpserver``. */
78
+ key: string;
79
+ /** Human-readable name shown in dropdowns and conversation chips. */
80
+ display_name: string;
81
+ /**
82
+ * Tokens passed to the agent-server as ``acp_command`` when this preset
83
+ * is picked. Each entry must be a real ACP-protocol stdio server — the
84
+ * SDK validates this against the {@link ACPProviderConfig.key}.
85
+ *
86
+ * NB: ``npx -y @openai/codex acp`` looks plausible but is **not** an
87
+ * ACP server — the codex CLI has no ``acp`` subcommand and exits with
88
+ * ``Error: stdin is not a terminal`` when spawned without a TTY, which
89
+ * silently deadlocks the agent-server's ACP handshake. Use
90
+ * ``@agentclientprotocol/codex-acp`` (the codex ACP wrapper) instead.
91
+ */
92
+ default_command: string[];
93
+ /**
94
+ * Suggested ACP model IDs for the provider's picker, sourced from the
95
+ * typescript-client registry. Not authoritative access checks; users can
96
+ * still enter a custom override in Settings -> Agent.
97
+ */
98
+ available_models?: ACPModelOption[];
99
+ /** Model ID preselected for built-in providers so Canvas never saves blank. */
100
+ default_model?: string;
101
+ /**
102
+ * i18n key for the one-line provider description rendered under the
103
+ * onboarding tile. Stored on the registry so adding a new ACP
104
+ * provider only requires editing this file (not the onboarding tile
105
+ * list separately).
106
+ */
107
+ description_key: I18nKey;
108
+ /**
109
+ * Serializable icon key used by UI surfaces that render provider
110
+ * choices. Kept as a string so the SDK mirror check can continue to
111
+ * parse this registry without importing React components.
112
+ */
113
+ icon?: ACPProviderIcon;
114
+ }
115
+
116
+ export interface ACPModelOption {
117
+ /** Exact model ID sent as ``acp_model``. */
118
+ id: string;
119
+ /** Human-readable label shown in Settings -> Agent. */
120
+ label: string;
121
+ }
122
+
123
+ // Canvas-only UI metadata per built-in provider, keyed by the ACP registry
124
+ // key. Everything else — display name, launch command, model picker list and
125
+ // default — comes from the typescript-client registry below. Adding a model
126
+ // or a provider happens upstream in the SDK; Canvas only owns the brand icon
127
+ // and the onboarding-tile description here. Its keys are the harnesses Canvas
128
+ // offers — see {@link SURFACED_ACP_PROVIDERS}.
129
+ const ACP_PROVIDER_UI: Record<
130
+ string,
131
+ { icon: ACPProviderIcon; description_key: I18nKey }
132
+ > = {
133
+ "claude-code": {
134
+ icon: "claude-code",
135
+ description_key: I18nKey.ONBOARDING$AGENT_CLAUDE_CODE_DESCRIPTION,
136
+ },
137
+ codex: {
138
+ icon: "codex",
139
+ description_key: I18nKey.ONBOARDING$AGENT_CODEX_DESCRIPTION,
140
+ },
141
+ "gemini-cli": {
142
+ icon: "gemini",
143
+ description_key: I18nKey.ONBOARDING$AGENT_GEMINI_CLI_DESCRIPTION,
144
+ },
145
+ };
146
+
147
+ function getAvailableModels(key: string): ACPModelOption[] | undefined {
148
+ return getClientAcpProvider(key)?.available_models?.map((model) => ({
149
+ id: model.id,
150
+ label: model.label,
151
+ }));
152
+ }
153
+
154
+ /**
155
+ * The ACP harnesses Canvas surfaces — its own declaration of what it offers,
156
+ * independent of what the SDK registry happens to contain. Registering a
157
+ * harness upstream is a no-op here: nothing in Canvas enumerates the registry,
158
+ * so there is no list to keep in step with it.
159
+ */
160
+ export const SURFACED_ACP_PROVIDERS: readonly string[] =
161
+ Object.keys(ACP_PROVIDER_UI);
162
+
163
+ // Built-in ACP providers Canvas surfaces, built by enriching each upstream
164
+ // registry record (``@openhands/typescript-client`` → Python SDK) with the
165
+ // Canvas UI metadata above.
166
+ export const ACP_PROVIDERS: ACPProviderConfig[] = Object.entries(
167
+ ACP_PROVIDER_UI,
168
+ ).map(([key, ui]) => {
169
+ const info = getClientAcpProvider(key);
170
+ return {
171
+ key,
172
+ display_name: info?.display_name ?? key,
173
+ default_command: info ? [...info.default_command] : [],
174
+ available_models: getAvailableModels(key),
175
+ default_model: info?.default_model ?? undefined,
176
+ description_key: ui.description_key,
177
+ icon: ui.icon,
178
+ };
179
+ });
180
+
181
+ export const ACP_CUSTOM_PRESET_KEY = "custom";
182
+
183
+ /**
184
+ * A credential an ACP provider authenticates with, surfaced during onboarding
185
+ * so the user can populate it without leaving the flow. The {@link name} is
186
+ * both the global-secret name and the environment variable the agent-server
187
+ * exports into the ACP subprocess — keeping them identical is what makes a
188
+ * saved secret actually reach the provider CLI.
189
+ */
190
+ export interface ACPProviderSecretField {
191
+ /** Secret name and env var (e.g. ``"ANTHROPIC_API_KEY"``). Must satisfy the
192
+ * secret-name pattern ``^[a-zA-Z][a-zA-Z0-9_]{0,63}$``. */
193
+ name: string;
194
+ /** Render as a masked password input (API keys, OAuth tokens, credential
195
+ * blobs) rather than a plain-text input (base URLs, project IDs). Doubles as
196
+ * "this is an actual credential": when the onboarding step is required, only
197
+ * a ``secret`` field satisfies it — a base URL or GCP scalar alone can't
198
+ * authenticate anything. */
199
+ secret?: boolean;
200
+ /** Render as a multi-line textarea rather than a single-line input. Set for
201
+ * file-content credentials the user pastes verbatim (Codex ``auth.json``,
202
+ * Gemini Vertex service-account / ADC JSON) — the ones the SDK materialises
203
+ * to disk, which a cloud backend can't consume yet (agent-canvas#1016). */
204
+ multiline?: boolean;
205
+ /** i18n key for the one-line helper text under the field. */
206
+ hint_key: I18nKey;
207
+ /**
208
+ * Interpolation values for {@link hint_key} (e.g. ``{ file: "~/.codex/auth.json" }``
209
+ * for the shared "paste the file contents" hint). Omitted for hints that
210
+ * take no parameters.
211
+ */
212
+ hint_values?: Record<string, string>;
213
+ }
214
+
215
+ /**
216
+ * Container credentials — beyond the registry's API key / base URL — that
217
+ * canvas collects per provider so a *containerized* agent-server (no host login
218
+ * state) can authenticate the ACP CLI: subscription tokens plus the file-content
219
+ * blobs and Vertex config the SDK's ``acp_file_secrets`` materialisation
220
+ * consumes (agent-canvas#1013/#1014). They have no registry env-var entry
221
+ * because they're a deployment concern, not a model registry field. The Gemini
222
+ * project/location/flag are plain config, not secrets — grouped here because
223
+ * they travel with the SA blob.
224
+ *
225
+ * The blob *names* (``CODEX_AUTH_JSON`` / ``GOOGLE_APPLICATION_CREDENTIALS_JSON``)
226
+ * duplicate the SDK registry's ``file_secrets`` specs; derive them from the
227
+ * client registry once the pinned ``@openhands/typescript-client`` mirrors that
228
+ * field (same bump that unblocks ``acp_isolate_data_dir``, see #1019).
229
+ */
230
+ const ACP_RESERVED_CREDENTIALS: Record<string, ACPProviderSecretField[]> = {
231
+ codex: [
232
+ {
233
+ name: "CODEX_AUTH_JSON",
234
+ secret: true,
235
+ multiline: true,
236
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_FILE_BLOB_HINT,
237
+ hint_values: { file: "~/.codex/auth.json" },
238
+ },
239
+ ],
240
+ "claude-code": [
241
+ {
242
+ name: "CLAUDE_CODE_OAUTH_TOKEN",
243
+ secret: true,
244
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_OAUTH_TOKEN_HINT,
245
+ },
246
+ ],
247
+ "gemini-cli": [
248
+ {
249
+ name: "GOOGLE_APPLICATION_CREDENTIALS_JSON",
250
+ secret: true,
251
+ multiline: true,
252
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_FILE_BLOB_HINT,
253
+ hint_values: {
254
+ file: "~/.config/gcloud/application_default_credentials.json",
255
+ },
256
+ },
257
+ {
258
+ name: "GOOGLE_CLOUD_PROJECT",
259
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_GCP_PROJECT_HINT,
260
+ },
261
+ {
262
+ name: "GOOGLE_CLOUD_LOCATION",
263
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_GCP_LOCATION_HINT,
264
+ },
265
+ {
266
+ name: "GOOGLE_GENAI_USE_VERTEXAI",
267
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_VERTEXAI_FLAG_HINT,
268
+ },
269
+ ],
270
+ };
271
+
272
+ /**
273
+ * Credential pairs that break each other at runtime, keyed by provider —
274
+ * mirrors the SDK's ``_ENV_CONFLICT_MAP`` (software-agent-sdk#3588). Claude's
275
+ * OAuth token (``CLAUDE_CODE_OAUTH_TOKEN``) authenticates against Anthropic
276
+ * directly, so when it is set the SDK strips:
277
+ * - ``ANTHROPIC_API_KEY`` — otherwise it takes precedence and silently
278
+ * bypasses the subscription;
279
+ * - ``ANTHROPIC_BASE_URL`` — otherwise it proxies the bearer to an endpoint
280
+ * that rejects it (see docs/ACP_AGENTS.md).
281
+ * Either one set alongside the token is silently ignored at runtime, so warn.
282
+ */
283
+ const ACP_CREDENTIAL_CONFLICTS: Record<string, Array<[string, string]>> = {
284
+ "claude-code": [
285
+ ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
286
+ ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL"],
287
+ ],
288
+ };
289
+
290
+ /**
291
+ * The ``[credential, conflicting]`` pairs the user currently has set for
292
+ * ``providerKey``. ``hasValue`` should cover both typed and already-saved
293
+ * values, since a previously saved secret conflicts just the same.
294
+ */
295
+ export function getAcpCredentialConflicts(
296
+ key: string | null | undefined,
297
+ hasValue: (name: string) => boolean,
298
+ ): Array<[string, string]> {
299
+ if (!key) return [];
300
+ return (ACP_CREDENTIAL_CONFLICTS[key] ?? []).filter(
301
+ ([credential, conflicting]) =>
302
+ hasValue(credential) && hasValue(conflicting),
303
+ );
304
+ }
305
+
306
+ /**
307
+ * Vertex AI-safe ``acp_model`` for Gemini.
308
+ *
309
+ * Left unset, gemini-cli falls back to its own internal default — a preview
310
+ * flash model that 404s on Google Cloud projects without preview access. And a
311
+ * ``*-flash`` id is no safer to pin: gemini-cli re-resolves flash ids at
312
+ * generation time to its *current default* flash (software-agent-sdk#3532), so
313
+ * a pinned ``gemini-2.5-flash`` still ran ``gemini-3-flash``. Only a non-flash
314
+ * id sticks; ``gemini-2.5-pro`` is broadly available on Vertex and the
315
+ * API-key / Google-login paths, so canvas preselects it. Kept as a named
316
+ * constant so every model-default consumer agrees.
317
+ */
318
+ export const ACP_VERTEX_SAFE_MODEL = "gemini-2.5-pro";
319
+
320
+ /**
321
+ * The default ``acp_model`` canvas substitutes for ``providerKey`` wherever no
322
+ * concrete model is configured — settings seeding (onboarding, Settings →
323
+ * Agent), the {@link buildAcpAgentSettingsDiff} fallback, and the start-request
324
+ * fallback for a saved ``null``. Overrides only Gemini (→
325
+ * {@link ACP_VERTEX_SAFE_MODEL}, see why above); every other provider keeps its
326
+ * registry {@link ACPProviderConfig.default_model}. Returns ``null`` when
327
+ * there's no override and no registry default, letting the ACP server pick its
328
+ * own.
329
+ *
330
+ * Distinct from {@link ACPProviderConfig.default_model} (which mirrors the SDK
331
+ * registry verbatim, closing agent-canvas#740): this is the *preferred* default,
332
+ * deliberately diverging for Gemini where the registry value isn't safe on
333
+ * every backend — so every default-model surface must route through this, not
334
+ * read ``default_model`` directly.
335
+ */
336
+ export function getAcpPreferredDefaultModel(
337
+ key: string | null | undefined,
338
+ ): string | null {
339
+ if (key === "gemini-cli") return ACP_VERTEX_SAFE_MODEL;
340
+ return getAcpProvider(key)?.default_model ?? null;
341
+ }
342
+
343
+ /**
344
+ * List the credentials Canvas should prompt for when onboarding the given ACP
345
+ * provider. The API-key and base-URL field *names* track the SDK registry's
346
+ * ``api_key_env_var`` / ``base_url_env_var`` (mirrored via
347
+ * ``@openhands/typescript-client``) so they can't drift as providers are added
348
+ * or renamed; the per-provider container credentials (subscription / Vertex
349
+ * blobs) come from {@link ACP_RESERVED_CREDENTIALS}, since those are a
350
+ * containerized-deployment concern with no model-registry entry. Each field
351
+ * name equals the env var the agent-server exports into the provider subprocess
352
+ * (which is what makes a saved secret reach the CLI). On the wire none of this
353
+ * is special — every saved credential travels uniformly as a ``LookupSecret``.
354
+ *
355
+ * Field order is: container subscription/Vertex credentials → API key →
356
+ * optional base URL. Every field is optional at the UI level — the step is
357
+ * skippable, and a subscription / OAuth login on the backend takes precedence
358
+ * over a key at runtime. (Whether the *step* is required is a
359
+ * backend-capability decision the onboarding modal makes; see
360
+ * ``backendRequiresAcpCredentials``.)
361
+ *
362
+ * NB: the base URL is rendered plain-text (not ``secret``), so it never counts
363
+ * toward a required credential step — setting ``ANTHROPIC_BASE_URL`` alongside
364
+ * a ``CLAUDE_CODE_OAUTH_TOKEN`` breaks the token's bearer auth, which the forms
365
+ * surface via {@link getAcpCredentialConflicts}.
366
+ *
367
+ * Returns ``[]`` for OpenHands, the ``"custom"`` preset, any unknown key, and a
368
+ * future OAuth-only provider whose registry entry has no ``api_key_env_var`` —
369
+ * callers treat an empty list as "no credentials step for this provider".
370
+ */
371
+ export function getAcpProviderSecrets(
372
+ key: string | null | undefined,
373
+ ): ACPProviderSecretField[] {
374
+ if (!key) return [];
375
+ // Surfaced providers only. The client registry grows with every harness the
376
+ // SDK adds, so reading it directly would offer credential fields for one
377
+ // Canvas never lists.
378
+ if (!getAcpProvider(key)) return [];
379
+ const info = getClientAcpProvider(key);
380
+ if (!info) return [];
381
+ // Subscription / Vertex credentials first — they're the primary auth path for
382
+ // ACP providers (Claude Pro/Max OAuth token, Codex ChatGPT auth.json), with
383
+ // the API key as the fallback below.
384
+ const fields: ACPProviderSecretField[] = [
385
+ ...(ACP_RESERVED_CREDENTIALS[key] ?? []),
386
+ ];
387
+ if (info.api_key_env_var) {
388
+ fields.push({
389
+ name: info.api_key_env_var,
390
+ secret: true,
391
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_API_KEY_HINT,
392
+ });
393
+ }
394
+ if (info.base_url_env_var) {
395
+ fields.push({
396
+ name: info.base_url_env_var,
397
+ hint_key: I18nKey.ONBOARDING$ACP_SECRET_BASE_URL_HINT,
398
+ });
399
+ }
400
+ return fields;
401
+ }
402
+
403
+ /**
404
+ * Look up a built-in ACP provider config by its registry key.
405
+ *
406
+ * Returns ``undefined`` for an empty / null key, for the ``"custom"`` preset
407
+ * (which has no registry entry), and for any forward-compatible key Canvas's
408
+ * registry doesn't know about yet. Centralizes the ``ACP_PROVIDERS.find(...)``
409
+ * lookup shared by the resolvers below and by the adapter / settings surfaces
410
+ * so the key-comparison shape lives in one place.
411
+ */
412
+ export function getAcpProvider(
413
+ key: string | null | undefined,
414
+ ): ACPProviderConfig | undefined {
415
+ if (!key) return undefined;
416
+ return ACP_PROVIDERS.find((provider) => provider.key === key);
417
+ }
418
+
419
+ /**
420
+ * Resolve an ACP provider registry key (the value stored under
421
+ * ``tags.acpserver`` on a conversation) to a human display name for the
422
+ * sidebar chip.
423
+ *
424
+ * Returns ``null`` for an empty / null key and for keys not in
425
+ * {@link ACP_PROVIDERS} — most notably ``"custom"`` (the user-supplied
426
+ * command preset has no canonical brand name) and any forward-compatible
427
+ * value Canvas's registry doesn't know about yet. Callers should fall
428
+ * back to a generic ``"ACP"`` label in that case so the chip still
429
+ * communicates "this is an ACP conversation".
430
+ *
431
+ * Kept separate from {@link buildAcpAgentSettingsDiff}'s lookup so the
432
+ * conversation-card render path can resolve display names without
433
+ * importing the settings-payload builder.
434
+ */
435
+ export function getAcpProviderDisplayName(
436
+ key: string | null | undefined,
437
+ ): string | null {
438
+ const found = getAcpProvider(key);
439
+ return found ? found.display_name : null;
440
+ }
441
+
442
+ /**
443
+ * Resolve an ACP provider registry key to the icon discriminator the
444
+ * conversation chip should render alongside the model text.
445
+ *
446
+ * Falls back to {@link ACP_PROVIDER_FALLBACK_ICON} for ``"custom"``,
447
+ * unknown keys, or a missing key — the chip then shows a neutral
448
+ * terminal glyph that still communicates "this is an ACP conversation"
449
+ * without claiming a brand identity we don't know.
450
+ */
451
+ export function resolveAcpProviderIcon(
452
+ key: string | null | undefined,
453
+ ): ACPProviderIcon {
454
+ return getAcpProvider(key)?.icon ?? ACP_PROVIDER_FALLBACK_ICON;
455
+ }
456
+
457
+ /**
458
+ * Resolve a raw ``acp_model`` ID to the human-readable label the provider's
459
+ * picker shows for it (e.g. ``"claude-opus-4-7"`` → ``"Claude Opus 4.7"``).
460
+ *
461
+ * Falls back to the raw ID when the provider is unknown or the ID isn't one
462
+ * of its registered {@link ACPModelOption}s — so a user's custom override
463
+ * still renders something meaningful rather than nothing. Returns ``null``
464
+ * only when there is no model to show, letting the conversation chip decide
465
+ * to display the provider name instead.
466
+ */
467
+ export function labelForAcpModel(
468
+ serverKey: string | null | undefined,
469
+ modelId: string | null | undefined,
470
+ ): string | null {
471
+ if (!modelId) return null;
472
+ const provider = getAcpProvider(serverKey);
473
+ const match = provider?.available_models?.find((m) => m.id === modelId);
474
+ return match?.label ?? modelId;
475
+ }
476
+
477
+ /**
478
+ * Build the ``agent_settings_diff`` payload PATCH /api/settings expects
479
+ * for the agent-kind/provider choice the user just made.
480
+ *
481
+ * Used by both the Settings → Agent page and the onboarding "choose
482
+ * agent" step — keeping the shape in one helper means a future change
483
+ * (e.g. always seeding ``acp_command`` from the registry instead of
484
+ * sending ``[]``, or adding new ``acp_*`` reset fields) lands in both
485
+ * surfaces atomically.
486
+ *
487
+ * Returns ``null`` for an unknown ACP provider key by default — the
488
+ * caller can skip the save (the UI shouldn't surface unknown options,
489
+ * but the defensive path keeps a buggy preset list from corrupting
490
+ * settings).
491
+ *
492
+ * Pass ``allowUnknownServer: true`` to opt into pass-through for keys
493
+ * that aren't in {@link ACP_PROVIDERS} or ``ACP_CUSTOM_PRESET_KEY``.
494
+ * The Settings → Agent page uses this when the user opens settings
495
+ * that already carry an ``acp_server`` value canvas's registry
496
+ * doesn't know about (e.g. set out-of-band via the API for a provider
497
+ * we haven't mirrored yet) and saves without changing the command —
498
+ * otherwise the original key would be silently demoted to ``"custom"``.
499
+ */
500
+ export function buildAcpAgentSettingsDiff(
501
+ providerKey: string,
502
+ options: {
503
+ command?: string[];
504
+ model?: string | null;
505
+ allowUnknownServer?: boolean;
506
+ } = {},
507
+ ): Record<string, unknown> | null {
508
+ if (providerKey === "openhands") {
509
+ // Switching back to OpenHands. The agent-server's ``Settings.update``
510
+ // applies a fresh ``{'agent_kind': ...}`` base whenever the kind
511
+ // flips, so any ``acp_*`` fields would be discarded before
512
+ // validation. Send the kind alone.
513
+ return { agent_kind: "openhands" };
514
+ }
515
+
516
+ const isCustom = providerKey === ACP_CUSTOM_PRESET_KEY;
517
+ const provider = isCustom ? undefined : getAcpProvider(providerKey);
518
+ if (!isCustom && !provider && !options.allowUnknownServer) {
519
+ return null;
520
+ }
521
+
522
+ // Undefined model → the *preferred* default (Vertex-safe for Gemini), not
523
+ // the raw registry default — see getAcpPreferredDefaultModel.
524
+ const model =
525
+ options.model === undefined
526
+ ? getAcpPreferredDefaultModel(providerKey)
527
+ : options.model;
528
+
529
+ // ``acp_args: []`` resets any API-set ``acp_args`` that would
530
+ // otherwise survive and concatenate to ``acp_command`` at spawn time
531
+ // (the agent-server merges the two before exec). Callers building the
532
+ // payload from a textarea that already shows the merged command
533
+ // (Settings → Agent) round-trip correctly — the merged tokens land in
534
+ // ``acp_command`` here, so no args are lost.
535
+ return {
536
+ agent_kind: "acp",
537
+ acp_server: providerKey,
538
+ acp_command: options.command ?? [],
539
+ acp_args: [],
540
+ acp_model: model ?? null,
541
+ };
542
+ }
src/constants/canvas-ui.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ export const LEGACY_CANVAS_UI_TOOL_NAME = "canvas_ui";
2
+ export const CANVAS_UI_CLIENT_TOOL_NAME = "canvas_ui_control";
3
+
4
+ /**
5
+ * Action discriminator generated by the SDK for the client-defined Canvas UI
6
+ * tool (`ClientAction_<tool-name>`). Keep the SDK naming convention contained
7
+ * here instead of repeating its generated class name throughout the frontend.
8
+ */
9
+ export const CANVAS_UI_CLIENT_ACTION_KIND =
10
+ `ClientAction_${CANVAS_UI_CLIENT_TOOL_NAME}` as const;
src/constants/child-conversation.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const LAUNCH_CHILD_CONVERSATION_TOOL_NAME = "launch_child_conversation";
2
+
3
+ /**
4
+ * Action discriminator generated by the SDK for the client-defined launch tool
5
+ * (`ClientAction_<tool-name>`). Keep the SDK naming convention contained here
6
+ * instead of repeating its generated class name throughout the frontend.
7
+ */
8
+ export const LAUNCH_CHILD_CONVERSATION_ACTION_KIND =
9
+ `ClientAction_${LAUNCH_CHILD_CONVERSATION_TOOL_NAME}` as const;
10
+
11
+ /**
12
+ * Prefix of the machine-generated message the frontend posts back into the
13
+ * parent conversation after a launch. Client tools are acknowledged by the
14
+ * agent-server before the browser has done any work, so this message is the
15
+ * only way to hand the agent the child's id, URL and status — or corrective
16
+ * guidance when the call was malformed. The chat hides it (see
17
+ * `should-render-event.ts`) because the agent relays the same information in
18
+ * its own reply.
19
+ */
20
+ export const CHILD_CONVERSATION_RESULT_PREFIX = "[child-conversation] ";
21
+
22
+ export const CHILD_CONVERSATION_TARGETS = ["local", "cloud"] as const;
23
+ export type ChildConversationTarget =
24
+ (typeof CHILD_CONVERSATION_TARGETS)[number];
25
+
26
+ export const CHILD_CONVERSATION_ISOLATIONS = ["worktree", "shared"] as const;
27
+ export type ChildConversationIsolation =
28
+ (typeof CHILD_CONVERSATION_ISOLATIONS)[number];
29
+
30
+ /**
31
+ * `parent_conversation_id` on the local start-conversation request landed in
32
+ * software-agent-sdk#4188. Older agent-servers accept the request and drop the
33
+ * field (the pydantic model ignores extras), so the launch still succeeds — we
34
+ * just tell the agent the relationship was not persisted instead of letting it
35
+ * believe in a link that does not exist.
36
+ */
37
+ export const MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK = "1.37.1";
src/constants/llm-balance.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Agent-server endpoint reporting the LLM provider's credit balance.
3
+ * Servers without balance support (or providers that can't report one)
4
+ * answer 404 — callers must treat that as "hide the balance UI".
5
+ */
6
+ export const LLM_BALANCE_PATH = "/api/llm/balance";
7
+
8
+ /**
9
+ * Ceiling on a single balance request. A server that accepts the connection
10
+ * and then never answers would otherwise leave the request in flight forever:
11
+ * the query sets `retry: false`, which suppresses retries but does not bound a
12
+ * stuck request, so the balance card would stay hidden with nothing surfaced.
13
+ */
14
+ export const LLM_BALANCE_TIMEOUT_MS = 10_000;
src/constants/llm-subscription.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { SettingsChoice, SettingsFieldSchema } from "#/types/settings";
2
+
3
+ export const LLM_AUTH_TYPE_KEY = "llm.auth_type";
4
+ export const LLM_SUBSCRIPTION_VENDOR_KEY = "llm.subscription_vendor";
5
+ export const LLM_AUTH_TYPE_API_KEY = "api_key";
6
+ export const LLM_AUTH_TYPE_SUBSCRIPTION = "subscription";
7
+ export const OPENAI_SUBSCRIPTION_VENDOR = "openai";
8
+
9
+ export const OPENAI_SUBSCRIPTION_MODELS_PATH =
10
+ "/api/llm/subscription/openai/models";
11
+ export const OPENAI_SUBSCRIPTION_STATUS_PATH =
12
+ "/api/llm/subscription/openai/status";
13
+ export const OPENAI_SUBSCRIPTION_DEVICE_START_PATH =
14
+ "/api/llm/subscription/openai/device/start";
15
+ export const OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH =
16
+ "/api/llm/subscription/openai/device/poll";
17
+ export const OPENAI_SUBSCRIPTION_LOGOUT_PATH =
18
+ "/api/llm/subscription/openai/logout";
19
+
20
+ export type LlmAuthType =
21
+ | typeof LLM_AUTH_TYPE_API_KEY
22
+ | typeof LLM_AUTH_TYPE_SUBSCRIPTION;
23
+
24
+ export const LLM_AUTH_TYPE_CHOICES: SettingsChoice[] = [
25
+ { label: "API key", value: LLM_AUTH_TYPE_API_KEY },
26
+ { label: "ChatGPT subscription", value: LLM_AUTH_TYPE_SUBSCRIPTION },
27
+ ];
28
+
29
+ const LLM_AUTH_TYPE_FIELD: SettingsFieldSchema = {
30
+ key: LLM_AUTH_TYPE_KEY,
31
+ label: "Authentication",
32
+ description:
33
+ "Choose whether this profile uses API credentials or a ChatGPT subscription.",
34
+ section: "llm",
35
+ section_label: "LLM",
36
+ value_type: "string",
37
+ default: LLM_AUTH_TYPE_API_KEY,
38
+ choices: LLM_AUTH_TYPE_CHOICES,
39
+ depends_on: [],
40
+ prominence: "critical",
41
+ secret: false,
42
+ required: true,
43
+ };
44
+
45
+ const LLM_SUBSCRIPTION_VENDOR_FIELD: SettingsFieldSchema = {
46
+ key: LLM_SUBSCRIPTION_VENDOR_KEY,
47
+ label: "Subscription provider",
48
+ description: "Provider used for subscription-backed LLM access.",
49
+ section: "llm",
50
+ section_label: "LLM",
51
+ value_type: "string",
52
+ default: OPENAI_SUBSCRIPTION_VENDOR,
53
+ choices: [{ label: "OpenAI", value: OPENAI_SUBSCRIPTION_VENDOR }],
54
+ depends_on: [],
55
+ prominence: "critical",
56
+ secret: false,
57
+ required: true,
58
+ };
59
+
60
+ export const LLM_SUBSCRIPTION_SCHEMA_FIELDS = [
61
+ LLM_AUTH_TYPE_FIELD,
62
+ LLM_SUBSCRIPTION_VENDOR_FIELD,
63
+ ];
64
+
65
+ export function resolveLlmAuthType(value: unknown): LlmAuthType {
66
+ return value === LLM_AUTH_TYPE_SUBSCRIPTION
67
+ ? LLM_AUTH_TYPE_SUBSCRIPTION
68
+ : LLM_AUTH_TYPE_API_KEY;
69
+ }
70
+
71
+ export function isSubscriptionLlmConfig(
72
+ llm: Record<string, unknown> | null | undefined,
73
+ ): boolean {
74
+ return resolveLlmAuthType(llm?.auth_type) === LLM_AUTH_TYPE_SUBSCRIPTION;
75
+ }
src/constants/profile-scope.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Picker state for a profile's scope fields: server default, or an explicit list. */
2
+ export type ProfileScopeMode = "standard" | "custom";
3
+
4
+ /**
5
+ * Read a stored profile's `mcp_server_refs` into picker state.
6
+ *
7
+ * Tri-state on the wire: `null`/absent = every configured server, an array =
8
+ * only those keys (`[]` = none).
9
+ */
10
+ export function readProfileMcpRefs(value: unknown): {
11
+ mode: ProfileScopeMode;
12
+ selected: string[];
13
+ } {
14
+ if (!Array.isArray(value)) return { mode: "standard", selected: [] };
15
+ return {
16
+ mode: "custom",
17
+ selected: value.filter((name): name is string => typeof name === "string"),
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Whether two scope selections name the same servers.
23
+ *
24
+ * Compared as a set: the resolver de-duplicates refs and uses them as an
25
+ * allow-list, so neither order nor a repeat changes what the agent gets. The
26
+ * editor emits catalog order while a stored profile keeps whatever order it was
27
+ * written in, so an order-sensitive check reports a profile dirty on open.
28
+ */
29
+ export function sameScopeSelection(a: string[], b: string[]): boolean {
30
+ const normalize = (names: string[]) =>
31
+ [...new Set(names)].sort().join("\u0000");
32
+ return normalize(a) === normalize(b);
33
+ }
src/constants/server-connection-error.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export const SERVER_CONNECTION_ERROR_MESSAGE = "Unable to connect to server";
src/constants/settings-nav.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AppWindow, Brain, Shield } from "lucide-react";
2
+ import KeyIcon from "#/icons/key.svg?react";
3
+ import MemoryIcon from "#/icons/memory_icon.svg?react";
4
+ import CircuitIcon from "#/icons/u-circuit.svg?react";
5
+ import RobotIcon from "#/icons/u-robot.svg?react";
6
+
7
+ export interface SettingsNavItem {
8
+ icon: React.ReactElement;
9
+ to: string;
10
+ text: string;
11
+ /** Short grey subline under the page title (`settings.tsx`). */
12
+ subtitle: string;
13
+ }
14
+
15
+ export const OSS_NAV_ITEMS: SettingsNavItem[] = [
16
+ {
17
+ // "Agent" is the Agent Profile library: it lists the user's agent profiles
18
+ // and its create/edit view is the reused Agent settings form plus a name.
19
+ // The active profile is the current agent (#1571). Replaces the old split
20
+ // of a global "Agent" form + a separate "Agent profiles" library.
21
+ icon: <RobotIcon width={16} height={16} />,
22
+ to: "/settings/agents",
23
+ text: "SETTINGS$NAV_AGENT",
24
+ subtitle: "SETTINGS$PAGE_AGENT_PROFILES_SUBLINE",
25
+ },
26
+ {
27
+ icon: <CircuitIcon width={16} height={16} />,
28
+ to: "/settings/llm",
29
+ text: "SETTINGS$NAV_LLM",
30
+ subtitle: "SETTINGS$PAGE_LLM_SUBLINE",
31
+ },
32
+ {
33
+ icon: <MemoryIcon width={16} height={16} />,
34
+ to: "/settings/condenser",
35
+ text: "SETTINGS$NAV_CONDENSER",
36
+ subtitle: "SETTINGS$PAGE_CONDENSER_SUBLINE",
37
+ },
38
+ {
39
+ // The agent's ``agent_context`` section, whatever the schema exposes in it
40
+ // — today only persistent memory (``agent_context.load_memory``). Not
41
+ // ``disabledByAcp``: the stored flag rides the shared agent_settings
42
+ // record into ACP conversations too — inline launches spread it into
43
+ // ``agent_context``, and profile launches (the normal ACP path) have the
44
+ // agent-server stamp it onto the profile-resolved agent.
45
+ icon: <Brain className="size-4" strokeWidth={2} aria-hidden />,
46
+ to: "/settings/agent-context",
47
+ text: "SETTINGS$NAV_AGENT_CONTEXT",
48
+ subtitle: "SETTINGS$PAGE_AGENT_CONTEXT_SUBLINE",
49
+ },
50
+ {
51
+ icon: <Shield className="size-4" strokeWidth={2} aria-hidden />,
52
+ to: "/settings/verification",
53
+ text: "SETTINGS$NAV_VERIFICATION",
54
+ subtitle: "SETTINGS$PAGE_VERIFICATION_SUBLINE",
55
+ },
56
+ {
57
+ icon: <AppWindow className="size-4" strokeWidth={2} aria-hidden />,
58
+ to: "/settings/app",
59
+ text: "SETTINGS$NAV_APPLICATION",
60
+ subtitle: "SETTINGS$PAGE_APPLICATION_SUBLINE",
61
+ },
62
+ {
63
+ icon: <KeyIcon width={16} height={16} />,
64
+ to: "/settings/secrets",
65
+ text: "SETTINGS$NAV_SECRETS",
66
+ subtitle: "SETTINGS$PAGE_SECRETS_SUBLINE",
67
+ },
68
+ ];
69
+
70
+ /**
71
+ * The only OSS nav entry listed when the canvas is locked to a Cloud host —
72
+ * i.e. deployed into SaaS / self-hosted OHE next to the OHE web app, whose own
73
+ * settings shell (reached via "All Cloud Settings") owns everything else. The
74
+ * other pages stay routable for in-app deep links; they are just unlisted
75
+ * (OHE-3168).
76
+ */
77
+ export const LOCKED_CLOUD_SETTINGS_NAV_PATH = "/settings/app";
src/constants/skills-docs.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export const ADD_SKILL_DOCS_URL =
2
+ "https://docs.openhands.dev/overview/skills/adding#adding-new-skills";
3
+
4
+ export const ADD_SKILL_SKILL_NAME = "add-skill";
5
+
6
+ export const ADD_SKILL_EXAMPLE_COMMAND =
7
+ "/add-skill https://github.com/OpenHands/extensions/tree/main/skills/codereview";
src/constants/status-colors.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /** Connected / running indicators (sidebar conversation dots, server status). */
2
+ export const OH_STATUS_SUCCESS_COLOR = "var(--oh-status-success)";
3
+
4
+ /** Disconnected / error indicators aligned with sidebar status dots. */
5
+ export const OH_STATUS_ERROR_COLOR = "var(--oh-status-error)";
src/context/navigation-context.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ export interface NavigationOptions {
4
+ replace?: boolean;
5
+ }
6
+
7
+ export interface NavigationContextValue {
8
+ currentPath: string;
9
+ conversationId: string | null;
10
+ isNavigating: boolean;
11
+ navigate: (to: string, options?: NavigationOptions) => void;
12
+ }
13
+
14
+ const noop = () => {};
15
+
16
+ const NavigationContext = React.createContext<NavigationContextValue>({
17
+ currentPath: "/",
18
+ conversationId: null,
19
+ isNavigating: false,
20
+ navigate: noop,
21
+ });
22
+
23
+ interface NavigationProviderProps {
24
+ value: NavigationContextValue;
25
+ children: React.ReactNode;
26
+ }
27
+
28
+ export function NavigationProvider({
29
+ value,
30
+ children,
31
+ }: NavigationProviderProps) {
32
+ return (
33
+ <NavigationContext.Provider value={value}>
34
+ {children}
35
+ </NavigationContext.Provider>
36
+ );
37
+ }
38
+
39
+ export function useNavigation() {
40
+ return React.useContext(NavigationContext);
41
+ }
src/context/scroll-context.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, ReactNode, RefObject } from "react";
2
+ import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
3
+
4
+ interface ScrollContextType {
5
+ scrollRef: RefObject<HTMLDivElement | null>;
6
+ autoScroll: boolean;
7
+ setAutoScroll: (value: boolean) => void;
8
+ scrollDomToBottom: () => void;
9
+ hitBottom: boolean;
10
+ setHitBottom: (value: boolean) => void;
11
+ onChatBodyScroll: (e: HTMLElement) => void;
12
+ }
13
+
14
+ export const ScrollContext = createContext<ScrollContextType | undefined>(
15
+ undefined,
16
+ );
17
+
18
+ interface ScrollProviderProps {
19
+ children: ReactNode;
20
+ value?: ScrollContextType;
21
+ }
22
+
23
+ export function ScrollProvider({ children, value }: ScrollProviderProps) {
24
+ const scrollHook = useScrollToBottom(React.useRef<HTMLDivElement>(null));
25
+
26
+ // Use provided value or default to the hook
27
+ const contextValue = value || scrollHook;
28
+
29
+ return (
30
+ <ScrollContext.Provider value={contextValue}>
31
+ {children}
32
+ </ScrollContext.Provider>
33
+ );
34
+ }
35
+
36
+ export function useScrollContext() {
37
+ const context = useContext(ScrollContext);
38
+ if (context === undefined) {
39
+ throw new Error("useScrollContext must be used within a ScrollProvider");
40
+ }
41
+ return context;
42
+ }
43
+
44
+ // Same as useScrollContext but returns undefined when no provider is mounted.
45
+ // Use this in components that have valid render paths both inside and outside
46
+ // the chat's ScrollProvider (e.g. GitControlBar in production vs. isolated
47
+ // test mounts).
48
+ export function useOptionalScrollContext() {
49
+ return useContext(ScrollContext);
50
+ }
src/contexts/active-backend-context.tsx ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { clearCachedAgentServerInfo } from "#/api/agent-server-compatibility";
3
+ import {
4
+ getActiveSelection,
5
+ getRegisteredBackends,
6
+ getSnapshot,
7
+ NO_BACKEND,
8
+ setActiveSelection,
9
+ setRegisteredBackends,
10
+ subscribeActiveBackend,
11
+ } from "#/api/backend-registry/active-store";
12
+ import { makeDefaultLocalBackend } from "#/api/backend-registry/default-backend";
13
+ import {
14
+ dropBackendHealth,
15
+ resetBackendHealth,
16
+ } from "#/api/backend-registry/health-store";
17
+ import {
18
+ type Backend,
19
+ type BackendSelection,
20
+ type ResolvedActiveBackend,
21
+ } from "#/api/backend-registry/types";
22
+ import { QUERY_KEYS } from "#/hooks/query/query-keys";
23
+ import { queryClient } from "#/query-client-config";
24
+ import {
25
+ setTelemetryCloudContext,
26
+ setTelemetryIdentity,
27
+ } from "#/services/telemetry";
28
+
29
+ type BackendInput = Omit<Backend, "id" | "connectionRevision">;
30
+
31
+ interface ActiveBackendContextValue {
32
+ backends: Backend[];
33
+ active: ResolvedActiveBackend;
34
+ setActive: (backendId: string, orgId?: string | null) => void;
35
+ addBackend: (backend: BackendInput) => Backend;
36
+ updateBackend: (id: string, patch: Partial<BackendInput>) => void;
37
+ removeBackend: (id: string) => void;
38
+ }
39
+
40
+ const ActiveBackendContext =
41
+ React.createContext<ActiveBackendContextValue | null>(null);
42
+
43
+ function generateId(): string {
44
+ if (
45
+ typeof crypto !== "undefined" &&
46
+ typeof crypto.randomUUID === "function"
47
+ ) {
48
+ return crypto.randomUUID();
49
+ }
50
+ return `backend-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
51
+ }
52
+
53
+ function applyTelemetrySelectionBoundary(
54
+ previous: ResolvedActiveBackend,
55
+ next: ResolvedActiveBackend,
56
+ ): void {
57
+ const previousWasCloud = previous.backend.kind === "cloud";
58
+ const nextIsCloud = next.backend.kind === "cloud";
59
+ if (!previousWasCloud && !nextIsCloud) return;
60
+
61
+ setTelemetryCloudContext(null);
62
+
63
+ if (
64
+ nextIsCloud &&
65
+ (!previousWasCloud || previous.backend.id !== next.backend.id)
66
+ ) {
67
+ void setTelemetryIdentity(null);
68
+ }
69
+ }
70
+
71
+ export function ActiveBackendProvider({
72
+ children,
73
+ }: {
74
+ children: React.ReactNode;
75
+ }) {
76
+ const snapshot = React.useSyncExternalStore(
77
+ subscribeActiveBackend,
78
+ getSnapshot,
79
+ getSnapshot,
80
+ );
81
+
82
+ const retryBootstrapProbe = React.useCallback(() => {
83
+ clearCachedAgentServerInfo();
84
+ void queryClient.invalidateQueries({
85
+ queryKey: QUERY_KEYS.WEB_CLIENT_CONFIG,
86
+ });
87
+ }, []);
88
+
89
+ const setActive = React.useCallback(
90
+ (backendId: string, orgId?: string | null) => {
91
+ const currentSnapshot = getSnapshot();
92
+ const prevBackendId = getActiveSelection()?.backendId ?? null;
93
+ const prevOrgId = getActiveSelection()?.orgId ?? null;
94
+ const nextOrgId = orgId ?? null;
95
+
96
+ if (backendId === prevBackendId && nextOrgId === prevOrgId) return;
97
+
98
+ const registeredBackends = getRegisteredBackends();
99
+ const selectedBackend = registeredBackends.find(
100
+ (b) => b.id === backendId,
101
+ );
102
+ const nextActive: ResolvedActiveBackend = {
103
+ backend: selectedBackend ?? registeredBackends[0] ?? NO_BACKEND,
104
+ orgId: selectedBackend ? nextOrgId : null,
105
+ };
106
+ applyTelemetrySelectionBoundary(currentSnapshot.active, nextActive);
107
+
108
+ const next: BackendSelection = { backendId, orgId: nextOrgId };
109
+ setActiveSelection(next);
110
+ retryBootstrapProbe();
111
+
112
+ // No blanket `invalidateQueries()` here. Long-lived queries
113
+ // (`useSettings`, `usePaginatedConversations`,
114
+ // `useGitRepositories`, `useAppInstallations`,
115
+ // `useCloudCurrentUserId`, `useGitUser`, …) include the active
116
+ // backend's `id` and `orgId` in their query keys, so React Query
117
+ // treats a backend/org switch as a brand-new query and fetches
118
+ // automatically — once, with no duplicate waves.
119
+ },
120
+ [retryBootstrapProbe],
121
+ );
122
+
123
+ // @spec BM-001 — Auto-switch to newly connected backend
124
+ const addBackend = React.useCallback(
125
+ (backend: BackendInput): Backend => {
126
+ const previousActive = getSnapshot().active;
127
+ const next: Backend = { ...backend, id: generateId() };
128
+ const list = [...getRegisteredBackends(), next];
129
+ applyTelemetrySelectionBoundary(previousActive, {
130
+ backend: next,
131
+ orgId: null,
132
+ });
133
+ setRegisteredBackends(list);
134
+ setActiveSelection({ backendId: next.id });
135
+ retryBootstrapProbe();
136
+ return next;
137
+ },
138
+ [retryBootstrapProbe],
139
+ );
140
+
141
+ const updateBackend = React.useCallback(
142
+ (id: string, patch: Partial<BackendInput>) => {
143
+ const prev = getRegisteredBackends().find((b) => b.id === id);
144
+ const activeBeforeUpdate = getSnapshot().active.backend.id;
145
+ // Re-arm health polling when the user edits the fields that
146
+ // actually drive the probe. Cosmetic edits (name) shouldn't
147
+ // re-enable a backend that was disabled for being unreachable.
148
+ const hostChanged =
149
+ patch.host !== undefined &&
150
+ prev !== undefined &&
151
+ patch.host !== prev.host;
152
+ const apiKeyChanged =
153
+ patch.apiKey !== undefined &&
154
+ prev !== undefined &&
155
+ patch.apiKey !== prev.apiKey;
156
+ const list = getRegisteredBackends().map((b) =>
157
+ b.id === id
158
+ ? {
159
+ ...b,
160
+ ...patch,
161
+ connectionRevision:
162
+ hostChanged || apiKeyChanged
163
+ ? (b.connectionRevision ?? 0) + 1
164
+ : b.connectionRevision,
165
+ }
166
+ : b,
167
+ );
168
+ setRegisteredBackends(list);
169
+
170
+ if (hostChanged || apiKeyChanged) {
171
+ resetBackendHealth(id);
172
+ if (activeBeforeUpdate === id) {
173
+ if (prev?.kind === "cloud") {
174
+ setTelemetryCloudContext(null);
175
+ void setTelemetryIdentity(null);
176
+ }
177
+ retryBootstrapProbe();
178
+ }
179
+ }
180
+ },
181
+ [retryBootstrapProbe],
182
+ );
183
+
184
+ const removeBackend = React.useCallback(
185
+ (id: string) => {
186
+ const removed = getRegisteredBackends().find((b) => b.id === id);
187
+ const wasActiveCloud =
188
+ removed?.kind === "cloud" &&
189
+ getSnapshot().active.backend.id === removed.id;
190
+ const list = getRegisteredBackends().filter((b) => b.id !== id);
191
+ setRegisteredBackends(list);
192
+ if (wasActiveCloud) {
193
+ setTelemetryCloudContext(null);
194
+ void setTelemetryIdentity(null);
195
+ }
196
+ dropBackendHealth(id);
197
+ retryBootstrapProbe();
198
+ // If the active selection pointed at this backend, the active
199
+ // store falls back to the first remaining local backend (or the
200
+ // env-derived default if no locals exist); consumer hooks re-key
201
+ // by the new active backend identity and refetch automatically.
202
+ },
203
+ [retryBootstrapProbe],
204
+ );
205
+
206
+ const value = React.useMemo<ActiveBackendContextValue>(
207
+ () => ({
208
+ backends: snapshot.backends,
209
+ active: snapshot.active,
210
+ setActive,
211
+ addBackend,
212
+ updateBackend,
213
+ removeBackend,
214
+ }),
215
+ [snapshot, setActive, addBackend, updateBackend, removeBackend],
216
+ );
217
+
218
+ return (
219
+ <ActiveBackendContext.Provider value={value}>
220
+ {children}
221
+ </ActiveBackendContext.Provider>
222
+ );
223
+ }
224
+
225
+ export function useActiveBackendContext(): ActiveBackendContextValue {
226
+ const ctx = React.useContext(ActiveBackendContext);
227
+ if (!ctx) {
228
+ throw new Error(
229
+ "useActiveBackendContext must be used inside <ActiveBackendProvider>",
230
+ );
231
+ }
232
+ return ctx;
233
+ }
234
+
235
+ /**
236
+ * Read the resolved active backend.
237
+ *
238
+ * Falls back to a synthesized env-derived local backend when called
239
+ * outside an `<ActiveBackendProvider>` (e.g. from a unit test that
240
+ * mounts a narrow component without the full provider stack). That
241
+ * synthesized backend is identical to the seed used on first install.
242
+ *
243
+ * Components that need to mutate state (`setActive`, `addBackend`,
244
+ * etc.) must use `useActiveBackendContext()` directly — that throws if
245
+ * the provider is missing, since mutation requires the live store.
246
+ */
247
+ export function useActiveBackend(): ResolvedActiveBackend {
248
+ const ctx = React.useContext(ActiveBackendContext);
249
+ if (ctx) return ctx.active;
250
+ return { backend: makeDefaultLocalBackend() ?? NO_BACKEND, orgId: null };
251
+ }
src/contexts/conversation-websocket-context.tsx ADDED
@@ -0,0 +1,1278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useState,
7
+ useCallback,
8
+ useMemo,
9
+ useRef,
10
+ } from "react";
11
+ import { ConversationClient } from "@openhands/typescript-client/clients";
12
+
13
+ import { useQueryClient } from "@tanstack/react-query";
14
+ import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
15
+ import { SERVER_CONNECTION_ERROR_MESSAGE } from "#/constants/server-connection-error";
16
+ import { useEventStore } from "#/stores/use-event-store";
17
+ import { useErrorMessageStore } from "#/stores/error-message-store";
18
+ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
19
+ import { useConversationStateStore } from "#/stores/conversation-state-store";
20
+ import { useCommandStore } from "#/stores/command-store";
21
+ import { useBrowserStore } from "#/stores/browser-store";
22
+ import { useGoalStore } from "#/stores/goal-store";
23
+ import {
24
+ isAgentServerEvent,
25
+ isAgentErrorEvent,
26
+ isUserMessageEvent,
27
+ isActionEvent,
28
+ isConversationStateUpdateEvent,
29
+ isFullStateConversationStateUpdateEvent,
30
+ isAgentStatusConversationStateUpdateEvent,
31
+ isStatsConversationStateUpdateEvent,
32
+ isGoalConversationStateUpdateEvent,
33
+ isExecuteBashActionEvent,
34
+ isExecuteBashObservationEvent,
35
+ isDisplayableErrorEvent,
36
+ isPlanningFileEditorObservationEvent,
37
+ isBrowserObservationEvent,
38
+ isBrowserNavigateActionEvent,
39
+ isSwitchLLMObservationEvent,
40
+ isCanvasUIActionEvent,
41
+ isStreamingDeltaEvent,
42
+ isLaunchChildConversationActionEvent,
43
+ } from "#/types/agent-server/type-guards";
44
+ import {
45
+ createStreamingDeltaBatcher,
46
+ StreamingDeltaBatcher,
47
+ } from "#/utils/streaming-delta-batcher";
48
+ import { handleCanvasUIAction } from "#/services/canvas-ui";
49
+ import { handleLaunchChildConversationAction } from "#/services/child-conversation-launch";
50
+ import { ConversationStateUpdateEventStats } from "#/types/agent-server/core/events/conversation-state-event";
51
+ import type {
52
+ ConversationErrorEvent,
53
+ ServerErrorEvent,
54
+ } from "#/types/agent-server/core/events/conversation-state-event";
55
+ import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
56
+ import { buildWebSocketUrl } from "#/utils/websocket-url";
57
+ import type {
58
+ AppConversation,
59
+ SendMessageRequest,
60
+ } from "#/api/conversation-service/agent-server-conversation-service.types";
61
+ import EventService from "#/api/event-service/event-service.api";
62
+ import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
63
+ import { useConversationStore } from "#/stores/conversation-store";
64
+ import { trackError } from "#/utils/error-handler";
65
+ import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation-file";
66
+ import useMetricsStore, { type MetricsState } from "#/stores/metrics-store";
67
+ import { useConversationHistory } from "#/hooks/query/use-conversation-history";
68
+ import { setConversationState } from "#/utils/conversation-local-storage";
69
+ import {
70
+ recordModelSwitchMessage,
71
+ seedModelSwitchesFromHistory,
72
+ stampActiveLlmProfile,
73
+ } from "#/hooks/chat/record-model-switch-message";
74
+ import {
75
+ invalidateConversationQueries,
76
+ updateConversationLlmModelInCache,
77
+ } from "#/hooks/mutation/conversation-mutation-utils";
78
+ import { isPlanFilePath } from "#/utils/plan-file";
79
+
80
+ export type WebSocketConnectionState =
81
+ | "CONNECTING"
82
+ | "OPEN"
83
+ | "CLOSED"
84
+ | "CLOSING";
85
+
86
+ interface SendMessageResult {
87
+ queued: boolean; // true if message was queued for later delivery, false if sent immediately
88
+ }
89
+
90
+ interface ConversationWebSocketContextType {
91
+ connectionState: WebSocketConnectionState;
92
+ /**
93
+ * The main connection's own state, unmerged with the planning connection —
94
+ * see `useMainWebSocketStatus`.
95
+ */
96
+ mainConnectionState: WebSocketConnectionState;
97
+ sendMessage: (message: SendMessageRequest) => Promise<SendMessageResult>;
98
+ isLoadingHistory: boolean;
99
+ reconnect: () => void;
100
+ }
101
+
102
+ const ConversationWebSocketContext = createContext<
103
+ ConversationWebSocketContextType | undefined
104
+ >(undefined);
105
+
106
+ /**
107
+ * Extract the text body of an echoed user `MessageEvent` for matching against
108
+ * the optimistic pending-message queue. The server wraps the original
109
+ * `args.content` string in one or more `TextContent` entries (alongside any
110
+ * `ImageContent` entries for inline images), so concatenating the `text`
111
+ * fields gives us back the exact prompt we sent.
112
+ */
113
+ function extractMessageEventText(
114
+ event: import("#/types/agent-server/core/events/message-event").MessageEvent,
115
+ ): string {
116
+ return event.llm_message.content
117
+ .filter(
118
+ (part): part is { type: "text"; text: string } => part.type === "text",
119
+ )
120
+ .map((part) => part.text)
121
+ .join("");
122
+ }
123
+
124
+ export function ConversationWebSocketProvider({
125
+ children,
126
+ conversationId,
127
+ conversationUrl,
128
+ sessionApiKey,
129
+ subConversations,
130
+ subConversationIds,
131
+ }: {
132
+ children: React.ReactNode;
133
+ conversationId?: string;
134
+ conversationUrl?: string | null;
135
+ sessionApiKey?: string | null;
136
+ subConversations?: AppConversation[];
137
+ subConversationIds?: string[];
138
+ }) {
139
+ // Separate connection state tracking for each WebSocket
140
+ const [mainConnectionState, setMainConnectionState] =
141
+ useState<WebSocketConnectionState>("CONNECTING");
142
+ const [planningConnectionState, setPlanningConnectionState] =
143
+ useState<WebSocketConnectionState>("CONNECTING");
144
+
145
+ // Track if we've ever successfully connected for each connection
146
+ // Don't show errors until after first successful connection
147
+ const hasConnectedRefMain = React.useRef(false);
148
+ const hasConnectedRefPlanning = React.useRef(false);
149
+
150
+ const queryClient = useQueryClient();
151
+ const addEvent = useEventStore((state) => state.addEvent);
152
+ const addEvents = useEventStore((state) => state.addEvents);
153
+ const clearEventsForConversation = useEventStore(
154
+ (state) => state.clearEventsForConversation,
155
+ );
156
+ const { setErrorMessage, removeErrorMessage, clearConnectionError } =
157
+ useErrorMessageStore();
158
+ const consumeMatchingPendingMessage = useOptimisticUserMessageStore(
159
+ (state) => state.consumeMatchingPendingMessage,
160
+ );
161
+ const { setExecutionStatus } = useConversationStateStore();
162
+ const { appendInput, appendOutput } = useCommandStore();
163
+ const resetBrowserStore = useBrowserStore((state) => state.reset);
164
+
165
+ // Coalesce streaming deltas to ≤1 store commit/render per frame.
166
+ // Separate batchers keep the main and planning streams from ever merging.
167
+ const mainDeltaBatcherRef = useRef<StreamingDeltaBatcher | null>(null);
168
+ if (mainDeltaBatcherRef.current === null) {
169
+ mainDeltaBatcherRef.current = createStreamingDeltaBatcher((delta) => {
170
+ useEventStore.getState().addEvent(delta);
171
+ // A delta means connectivity recovered — mirror handleNonErrorEvent.
172
+ useErrorMessageStore.getState().clearConnectionError();
173
+ });
174
+ }
175
+ const planningDeltaBatcherRef = useRef<StreamingDeltaBatcher | null>(null);
176
+ if (planningDeltaBatcherRef.current === null) {
177
+ planningDeltaBatcherRef.current = createStreamingDeltaBatcher((delta) => {
178
+ useEventStore
179
+ .getState()
180
+ .addEvent({ ...delta, isFromPlanningAgent: true });
181
+ useErrorMessageStore.getState().clearConnectionError();
182
+ });
183
+ }
184
+
185
+ // History loading state.
186
+ // - Main conversation history is now loaded via REST (`useConversationHistory`),
187
+ // so its loading state mirrors the REST query state (see below).
188
+ // - Planning sub-conversation history still streams over the WebSocket using
189
+ // `resend_mode='all'`, so we keep the count-based detection for it.
190
+ const [isLoadingHistoryPlanning, setIsLoadingHistoryPlanning] =
191
+ useState(true);
192
+ const [expectedEventCountPlanning, setExpectedEventCountPlanning] = useState<
193
+ number | null
194
+ >(null);
195
+
196
+ const { setPlanContent } = useConversationStore();
197
+
198
+ useEffect(() => {
199
+ setPlanContent(null);
200
+ }, [conversationId, setPlanContent]);
201
+
202
+ const { mutate: readConversationFile } = useReadConversationFile();
203
+
204
+ // Track planning-agent received events (still WS-driven).
205
+ const receivedEventCountRefPlanning = useRef(0);
206
+
207
+ // Track the latest PlanningFileEditorObservation for Plan.md during history replay
208
+ const latestPlanningFileEventRef = useRef<{
209
+ path: string;
210
+ conversationId: string;
211
+ } | null>(null);
212
+
213
+ const handleNonErrorEvent = useCallback(() => {
214
+ // A normal event means connectivity recovered: clear a transient connection
215
+ // error, but keep sticky conversation errors (e.g. a wrong API key).
216
+ clearConnectionError();
217
+ }, [clearConnectionError]);
218
+
219
+ // Helper function to update metrics from stats event
220
+ const updateMetricsFromStats = useCallback(
221
+ (event: ConversationStateUpdateEventStats) => {
222
+ const usageToMetrics = event.value.usage_to_metrics;
223
+ if (!usageToMetrics) {
224
+ return;
225
+ }
226
+
227
+ // usage_to_metrics is keyed by arbitrary LLM usage ids ("default",
228
+ // "condenser", "profile:<name>:<uuid>", …) — combine across all of
229
+ // them, mirroring getCombinedMetrics on the REST path.
230
+ const combined = Object.values(usageToMetrics).reduce<{
231
+ cost: number;
232
+ maxBudgetPerTask: number | null;
233
+ usage: MetricsState["usage"];
234
+ }>(
235
+ (acc, metrics) => {
236
+ acc.cost += metrics.accumulated_cost;
237
+ if (
238
+ acc.maxBudgetPerTask === null &&
239
+ metrics.max_budget_per_task !== null
240
+ ) {
241
+ acc.maxBudgetPerTask = metrics.max_budget_per_task;
242
+ }
243
+ const tokenUsage = metrics.accumulated_token_usage;
244
+ if (tokenUsage) {
245
+ acc.usage = {
246
+ prompt_tokens:
247
+ (acc.usage?.prompt_tokens ?? 0) + tokenUsage.prompt_tokens,
248
+ completion_tokens:
249
+ (acc.usage?.completion_tokens ?? 0) +
250
+ tokenUsage.completion_tokens,
251
+ cache_read_tokens:
252
+ (acc.usage?.cache_read_tokens ?? 0) +
253
+ tokenUsage.cache_read_tokens,
254
+ cache_write_tokens:
255
+ (acc.usage?.cache_write_tokens ?? 0) +
256
+ tokenUsage.cache_write_tokens,
257
+ context_window: Math.max(
258
+ acc.usage?.context_window ?? 0,
259
+ tokenUsage.context_window,
260
+ ),
261
+ per_turn_token: Math.max(
262
+ acc.usage?.per_turn_token ?? 0,
263
+ tokenUsage.per_turn_token,
264
+ ),
265
+ };
266
+ }
267
+ return acc;
268
+ },
269
+ { cost: 0, maxBudgetPerTask: null, usage: null },
270
+ );
271
+
272
+ useMetricsStore.getState().setMetrics({
273
+ cost: combined.cost,
274
+ max_budget_per_task: combined.maxBudgetPerTask,
275
+ usage: combined.usage,
276
+ });
277
+ },
278
+ [],
279
+ );
280
+
281
+ // Initial REST history load: fetch the most recent events and seed the
282
+ // store. Older events are paginated in via `useLoadOlderEvents` when the
283
+ // user scrolls to the top of the chat. The WebSocket connection waits for
284
+ // this query so it can subscribe with `resend_mode='since'` and avoid
285
+ // re-streaming everything REST already returned.
286
+ const { data: preloadedHistory, isPending: isPreloadingHistory } =
287
+ useConversationHistory(conversationId);
288
+
289
+ // Skeleton only on the genuine first load (no cached data yet). On return the
290
+ // cached page is present, so `isPending` is false and we render the
291
+ // last-known discussion immediately while the tail refetch runs in the
292
+ // background — the socket gate below also keys on `isPending`, so that
293
+ // refetch never drops a live socket.
294
+ const isLoadingHistoryMain = !!conversationId && isPreloadingHistory;
295
+
296
+ // Clear the (global, not conversation-scoped) event store when the active
297
+ // conversation changes, BEFORE the preloaded-history effect below re-seeds
298
+ // it. This MUST live here rather than in the route component: a parent's
299
+ // passive effect runs *after* this child's layout effects, so clearing from
300
+ // the route would wipe the freshly seeded history. On a conversation switch
301
+ // the history page is already cached, so `preloadedHistory` is available
302
+ // synchronously — without ordering the clear first, the user's already-echoed
303
+ // message gets seeded then immediately wiped, leaving only the `since`
304
+ // WebSocket resend (the agent's reply). Re-entering the same conversation is
305
+ // a no-op, so the store survives navigating away to Settings and back.
306
+ useLayoutEffect(() => {
307
+ const nextId = conversationId ?? null;
308
+ if (useEventStore.getState().loadedConversationId === nextId) {
309
+ return;
310
+ }
311
+ // Single atomic action: clears the previous conversation's events and
312
+ // records the new loaded id in one `set`, so no subscriber can observe a
313
+ // half-applied state (events gone but the old id still reported).
314
+ clearEventsForConversation(nextId);
315
+ resetBrowserStore();
316
+ // The metrics store is conversation-scoped state too: without a reset the
317
+ // previous conversation's usage/cost keeps rendering in the new
318
+ // conversation's meter until fresh WS stats arrive — and a brand-new
319
+ // conversation sends none, so the stale figure stuck indefinitely.
320
+ useMetricsStore.getState().resetMetrics();
321
+ }, [conversationId, clearEventsForConversation, resetBrowserStore]);
322
+
323
+ useLayoutEffect(() => {
324
+ if (!preloadedHistory || preloadedHistory.events.length === 0) {
325
+ return;
326
+ }
327
+ addEvents(preloadedHistory.events);
328
+
329
+ // The first user message of a cloud start-task conversation is persisted
330
+ // server-side and reaches us via this REST preload, not over the WebSocket
331
+ // (which subscribes with resend_mode='since' after the latest preloaded
332
+ // timestamp). Consume any matching optimistic "Sending…" bubble here too —
333
+ // mirroring the WS handler — so it doesn't linger as a duplicate of the echo.
334
+ if (conversationId) {
335
+ // Rebuild inline "Switched to" messages from the REST-preloaded history.
336
+ // The live store writers (WS handler / user action) never see preloaded
337
+ // events, so without this past model switches wouldn't render on reload.
338
+ // Read the post-`addEvents` `uiEvents` (actions replaced by observations,
339
+ // Think/Finish observations dropped) — not the raw history — so anchors
340
+ // match the ids the renderer actually mounts.
341
+ seedModelSwitchesFromHistory(
342
+ conversationId,
343
+ useEventStore.getState().uiEvents,
344
+ );
345
+
346
+ for (const event of preloadedHistory.events) {
347
+ if (isUserMessageEvent(event)) {
348
+ consumeMatchingPendingMessage(
349
+ conversationId,
350
+ extractMessageEventText(event),
351
+ );
352
+ }
353
+ }
354
+ }
355
+ }, [
356
+ preloadedHistory,
357
+ addEvents,
358
+ conversationId,
359
+ consumeMatchingPendingMessage,
360
+ ]);
361
+
362
+ /**
363
+ * Timestamp of the latest event we already have from REST. Used as
364
+ * `after_timestamp` when opening the WebSocket so the server only resends
365
+ * events strictly after this point. `null` until the first REST page lands
366
+ * (the WS connection is gated on that — see `wsUrl` below). During
367
+ * background refetches `preloadedHistory` keeps the last-known page, so the
368
+ * anchor holds steady instead of flipping to null; reconnects read the
369
+ * freshest value from the options ref at connect time.
370
+ */
371
+ const initialAfterTimestamp = useMemo<string | null>(() => {
372
+ const events = preloadedHistory?.events ?? [];
373
+ const latest = events[events.length - 1];
374
+ if (!latest || !("timestamp" in latest) || !latest.timestamp) return null;
375
+ return latest.timestamp;
376
+ }, [preloadedHistory]);
377
+
378
+ // Build WebSocket URL from props.
379
+ //
380
+ // We deliberately wait for the FIRST history load (`isPending`: no data for
381
+ // this query key yet) before opening the socket, so the WS subscription can
382
+ // use `resend_mode='since'` with a meaningful `after_timestamp` instead of
383
+ // falling back to `resend_mode='all'`. The gate is intentionally NOT on
384
+ // `isFetching`: background refetches (e.g. the `refetchOnMount` fired when
385
+ // returning to a conversation) must never tear a live socket down — on a
386
+ // flaky link that caused a refetch → teardown → reconnect → refetch loop
387
+ // that kept the conversation stuck at "Connecting" for minutes. Connecting
388
+ // during a background refetch anchors `since` to the cached tail; the
389
+ // overlap with the refetched page is deduped by the event store and the
390
+ // `isDuplicateEvent` guards in the message handlers. A query-key reset
391
+ // (backend swap / new session key) makes `isPending` true again, so a
392
+ // genuine reset still re-gates. If the initial load errors, `isPending`
393
+ // flips false and we fall through to connect with `resend_mode='all'` so
394
+ // the user still sees live events.
395
+ const wsUrl = useMemo(() => {
396
+ if (!conversationId || !conversationUrl) {
397
+ return null;
398
+ }
399
+ if (isPreloadingHistory) {
400
+ return null;
401
+ }
402
+ return buildWebSocketUrl(conversationId, conversationUrl);
403
+ }, [conversationId, conversationUrl, isPreloadingHistory]);
404
+
405
+ // Derived from `subConversationIds` (the pre-filtered, tag-verified id
406
+ // list) rather than the resolved `subConversations` entry, which lands a
407
+ // tick later — routing on it would leave a window where the first prompt
408
+ // sent in plan mode fell through to the parent (the code agent) instead of
409
+ // the planner.
410
+ const planningConversationId = useMemo(
411
+ () => subConversationIds?.[0] ?? null,
412
+ [subConversationIds],
413
+ );
414
+
415
+ const planningAgentWsUrl = useMemo(() => {
416
+ if (!subConversations?.length) {
417
+ return null;
418
+ }
419
+
420
+ // Currently, there is only one sub-conversation and it uses the planning agent.
421
+ const planningAgentConversation = subConversations[0];
422
+
423
+ if (
424
+ !planningAgentConversation?.id ||
425
+ !planningAgentConversation.conversation_url
426
+ ) {
427
+ return null;
428
+ }
429
+
430
+ return buildWebSocketUrl(
431
+ planningAgentConversation.id,
432
+ planningAgentConversation.conversation_url,
433
+ );
434
+ }, [subConversations]);
435
+
436
+ // Merged connection state - reflects combined status of both connections
437
+ const connectionState = useMemo<WebSocketConnectionState>(() => {
438
+ // If planning agent connection doesn't exist, use main connection state
439
+ if (!planningAgentWsUrl) {
440
+ return mainConnectionState;
441
+ }
442
+
443
+ // If either is connecting, merged state is connecting
444
+ if (
445
+ mainConnectionState === "CONNECTING" ||
446
+ planningConnectionState === "CONNECTING"
447
+ ) {
448
+ return "CONNECTING";
449
+ }
450
+
451
+ // If both are open, merged state is open
452
+ if (mainConnectionState === "OPEN" && planningConnectionState === "OPEN") {
453
+ return "OPEN";
454
+ }
455
+
456
+ // If both are closed, merged state is closed
457
+ if (
458
+ mainConnectionState === "CLOSED" &&
459
+ planningConnectionState === "CLOSED"
460
+ ) {
461
+ return "CLOSED";
462
+ }
463
+
464
+ // If either is closing, merged state is closing
465
+ if (
466
+ mainConnectionState === "CLOSING" ||
467
+ planningConnectionState === "CLOSING"
468
+ ) {
469
+ return "CLOSING";
470
+ }
471
+
472
+ // Default to closed if states don't match expected patterns
473
+ return "CLOSED";
474
+ }, [mainConnectionState, planningConnectionState, planningAgentWsUrl]);
475
+
476
+ useEffect(() => {
477
+ if (
478
+ expectedEventCountPlanning !== null &&
479
+ receivedEventCountRefPlanning.current >= expectedEventCountPlanning &&
480
+ isLoadingHistoryPlanning
481
+ ) {
482
+ setIsLoadingHistoryPlanning(false);
483
+ }
484
+ }, [
485
+ expectedEventCountPlanning,
486
+ isLoadingHistoryPlanning,
487
+ receivedEventCountRefPlanning,
488
+ ]);
489
+
490
+ // Call API once after history loading completes if we tracked any PlanningFileEditorObservation events
491
+ useEffect(() => {
492
+ if (!isLoadingHistoryPlanning && latestPlanningFileEventRef.current) {
493
+ const { path, conversationId: currentPlanningConversationId } =
494
+ latestPlanningFileEventRef.current;
495
+
496
+ readConversationFile(
497
+ {
498
+ conversationId: currentPlanningConversationId,
499
+ filePath: path,
500
+ },
501
+ {
502
+ onSuccess: (fileContent) => {
503
+ setPlanContent(fileContent);
504
+ },
505
+ onError: (error) => {
506
+ console.warn("Failed to read conversation file:", error);
507
+ },
508
+ },
509
+ );
510
+
511
+ // Clear the ref after calling the API
512
+ latestPlanningFileEventRef.current = null;
513
+ }
514
+ }, [isLoadingHistoryPlanning, readConversationFile, setPlanContent]);
515
+
516
+ useEffect(() => {
517
+ hasConnectedRefMain.current = false;
518
+ setIsLoadingHistoryPlanning(!!subConversationIds?.length);
519
+ setExpectedEventCountPlanning(null);
520
+ receivedEventCountRefPlanning.current = 0;
521
+ // Reset the tracked event ref when sub-conversations change
522
+ latestPlanningFileEventRef.current = null;
523
+ }, [subConversationIds]);
524
+
525
+ // Reset hasConnected flags when the conversation changes.
526
+ useEffect(() => {
527
+ hasConnectedRefMain.current = false;
528
+ hasConnectedRefPlanning.current = false;
529
+ // Reset the tracked event ref when conversation changes
530
+ latestPlanningFileEventRef.current = null;
531
+ }, [conversationId]);
532
+
533
+ // Drop buffered deltas on conversation switch/unmount: the store is cleared on
534
+ // switch, so flushing them would leak into the next conversation.
535
+ useEffect(() => {
536
+ const mainBatcher = mainDeltaBatcherRef.current;
537
+ const planningBatcher = planningDeltaBatcherRef.current;
538
+ return () => {
539
+ mainBatcher?.reset();
540
+ planningBatcher?.reset();
541
+ };
542
+ }, [conversationId]);
543
+
544
+ // Merged loading history state - true if either connection is still loading
545
+ const isLoadingHistory = useMemo(
546
+ () => isLoadingHistoryMain || isLoadingHistoryPlanning,
547
+ [isLoadingHistoryMain, isLoadingHistoryPlanning],
548
+ );
549
+
550
+ // Separate message handlers for each connection
551
+ const handleMainMessage = useCallback(
552
+ (messageEvent: MessageEvent) => {
553
+ try {
554
+ const event = JSON.parse(messageEvent.data);
555
+
556
+ // History loading for the main conversation is REST-driven now;
557
+ // every WS message is a new event we add to the store.
558
+
559
+ // Use type guard to validate v1 event structure
560
+ if (isAgentServerEvent(event)) {
561
+ // Buffer deltas; nothing else in this handler applies to them.
562
+ if (isStreamingDeltaEvent(event)) {
563
+ mainDeltaBatcherRef.current?.enqueue(event);
564
+ return;
565
+ }
566
+ // Flush buffered deltas before this event so it can't overtake them.
567
+ mainDeltaBatcherRef.current?.flush();
568
+
569
+ // A reconnect replays the backlog from a stale anchor. The store
570
+ // dedups by id, but the side-effects below aren't idempotent, so skip
571
+ // them for replayed events (#1656).
572
+ const isDuplicateEvent = useEventStore
573
+ .getState()
574
+ .eventIds.has(event.id ?? "");
575
+ const switchLLMObservation = isSwitchLLMObservationEvent(event)
576
+ ? event
577
+ : null;
578
+ addEvent(event);
579
+ if (isDuplicateEvent) {
580
+ return;
581
+ }
582
+
583
+ // Handle displayable error events - show error banner
584
+ // AgentErrorEvent errors are displayed inline in the chat, not as banners
585
+ if (isDisplayableErrorEvent(event)) {
586
+ const errorEvent = event as
587
+ | ConversationErrorEvent
588
+ | ServerErrorEvent;
589
+ const classification =
590
+ "classification" in errorEvent ? errorEvent.classification : null;
591
+ trackError({
592
+ source: "conversation",
593
+ metadata: {
594
+ eventId: errorEvent.id,
595
+ errorCode: errorEvent.code,
596
+ },
597
+ classification,
598
+ });
599
+ setErrorMessage(
600
+ errorEvent.detail,
601
+ "conversation",
602
+ errorEvent.code,
603
+ classification,
604
+ );
605
+ } else {
606
+ handleNonErrorEvent();
607
+ }
608
+
609
+ // LLM errors render inline in the chat (see ErrorEventMessage); track
610
+ // them for analytics but keep them out of the banner above the chat box.
611
+ if (isAgentErrorEvent(event)) {
612
+ trackError({
613
+ source: "agent",
614
+ metadata: {
615
+ eventId: event.id,
616
+ toolName: event.tool_name,
617
+ toolCallId: event.tool_call_id,
618
+ },
619
+ classification: event.classification,
620
+ });
621
+ }
622
+
623
+ // Clear optimistic user message when a user message is confirmed.
624
+ // We match by the echoed text content (with FIFO fallback inside the
625
+ // store), so an echo for "second" pops "second" — not whichever
626
+ // pending entry happens to be oldest — protecting against any
627
+ // out-of-order delivery between conversations or sub-agents.
628
+ if (isUserMessageEvent(event)) {
629
+ if (conversationId) {
630
+ consumeMatchingPendingMessage(
631
+ conversationId,
632
+ extractMessageEventText(event),
633
+ );
634
+ // Clear draft from localStorage - message was successfully delivered
635
+ setConversationState(conversationId, { draftMessage: null });
636
+ }
637
+ }
638
+
639
+ // Handle cache invalidation for ActionEvent
640
+ if (isActionEvent(event)) {
641
+ const currentConversationId =
642
+ conversationId || "test-conversation-id"; // TODO: Get from context
643
+ handleActionEventCacheInvalidation(
644
+ event,
645
+ currentConversationId,
646
+ queryClient,
647
+ );
648
+ }
649
+
650
+ // Handle conversation state updates
651
+ // TODO: Tests
652
+ if (isConversationStateUpdateEvent(event)) {
653
+ if (
654
+ isFullStateConversationStateUpdateEvent(event) &&
655
+ conversationId
656
+ ) {
657
+ setExecutionStatus(conversationId, event.value.execution_status);
658
+ }
659
+ if (
660
+ isAgentStatusConversationStateUpdateEvent(event) &&
661
+ conversationId
662
+ ) {
663
+ setExecutionStatus(conversationId, event.value);
664
+ }
665
+ if (isStatsConversationStateUpdateEvent(event)) {
666
+ updateMetricsFromStats(event);
667
+ }
668
+ // Mirror goal status into the store. Intentionally duplicated across
669
+ // the main and planning WebSocket handlers (like the execution_status
670
+ // and stats branches above), not a merge artifact.
671
+ if (isGoalConversationStateUpdateEvent(event) && conversationId) {
672
+ useGoalStore.getState().setStatus(conversationId, event.value);
673
+ }
674
+ }
675
+
676
+ // Handle ExecuteBashAction events - add command as input to terminal
677
+ if (isExecuteBashActionEvent(event)) {
678
+ appendInput(event.action.command);
679
+ }
680
+
681
+ // Handle ExecuteBashObservation events - add output to terminal
682
+ if (isExecuteBashObservationEvent(event)) {
683
+ // Extract text content from the observation content array
684
+ const textContent = event.observation.content
685
+ .filter((c) => c.type === "text")
686
+ .map((c) => c.text)
687
+ .join("\n");
688
+ appendOutput(textContent);
689
+ }
690
+
691
+ // Handle BrowserObservation events - update browser store with screenshot
692
+ if (isBrowserObservationEvent(event)) {
693
+ const { screenshot_data: screenshotData } = event.observation;
694
+ if (screenshotData) {
695
+ const screenshotSrc = screenshotData.startsWith("data:")
696
+ ? screenshotData
697
+ : `data:image/png;base64,${screenshotData}`;
698
+ useBrowserStore.getState().setScreenshotSrc(screenshotSrc);
699
+ }
700
+ }
701
+
702
+ // Handle BrowserNavigateAction events - update browser store with URL
703
+ if (isBrowserNavigateActionEvent(event)) {
704
+ useBrowserStore.getState().setUrl(event.action.url);
705
+ }
706
+
707
+ if (
708
+ conversationId &&
709
+ switchLLMObservation &&
710
+ !switchLLMObservation.observation.is_error
711
+ ) {
712
+ recordModelSwitchMessage(
713
+ conversationId,
714
+ switchLLMObservation.observation.profile_name,
715
+ );
716
+
717
+ // Mirror the user-driven `/model` path: persist the profile so the
718
+ // chat-header switcher shows the right name after a reload, even
719
+ // when several profiles share a model (#1082). Stamp with the
720
+ // observation's own timestamp so a later history seed of this same
721
+ // event can't roll it back (or needlessly rewrite it).
722
+ stampActiveLlmProfile(
723
+ conversationId,
724
+ switchLLMObservation.observation.profile_name,
725
+ switchLLMObservation.timestamp,
726
+ );
727
+
728
+ if (switchLLMObservation.observation.active_model) {
729
+ updateConversationLlmModelInCache(
730
+ queryClient,
731
+ conversationId,
732
+ switchLLMObservation.observation.active_model,
733
+ );
734
+ }
735
+
736
+ invalidateConversationQueries(queryClient, conversationId);
737
+ }
738
+
739
+ // Handle canvas_ui ActionEvents from both the legacy Python tool and
740
+ // the client-defined JSON tool. The server acknowledges immediately;
741
+ // the actual UI change happens here on the client.
742
+ if (isCanvasUIActionEvent(event)) {
743
+ handleCanvasUIAction(event.action, conversationId ?? null);
744
+ }
745
+
746
+ // Same client-tool pattern, but the work is a network call: launch
747
+ // the requested child conversation and post the outcome back so the
748
+ // agent learns the id the server-side acknowledgement can't carry.
749
+ if (conversationId && isLaunchChildConversationActionEvent(event)) {
750
+ void handleLaunchChildConversationAction(
751
+ event.action,
752
+ conversationId,
753
+ event.tool_call_id,
754
+ );
755
+ }
756
+ }
757
+ } catch (error) {
758
+ console.warn("Failed to parse WebSocket message as JSON:", error);
759
+ }
760
+ },
761
+ [
762
+ addEvent,
763
+ setErrorMessage,
764
+ consumeMatchingPendingMessage,
765
+ queryClient,
766
+ conversationId,
767
+ setExecutionStatus,
768
+ appendInput,
769
+ appendOutput,
770
+ updateMetricsFromStats,
771
+ handleNonErrorEvent,
772
+ ],
773
+ );
774
+
775
+ const handlePlanningMessage = useCallback(
776
+ (messageEvent: MessageEvent) => {
777
+ try {
778
+ const event = JSON.parse(messageEvent.data);
779
+
780
+ // Track received events for history loading (count ALL events from WebSocket)
781
+ // Always count when loading, even if we don't have the expected count yet
782
+ if (isLoadingHistoryPlanning) {
783
+ receivedEventCountRefPlanning.current += 1;
784
+
785
+ if (
786
+ expectedEventCountPlanning !== null &&
787
+ receivedEventCountRefPlanning.current >= expectedEventCountPlanning
788
+ ) {
789
+ setIsLoadingHistoryPlanning(false);
790
+ }
791
+ }
792
+
793
+ // Use type guard to validate v1 event structure
794
+ if (isAgentServerEvent(event)) {
795
+ // Buffer deltas (the commit re-applies the planning flag).
796
+ if (isStreamingDeltaEvent(event)) {
797
+ planningDeltaBatcherRef.current?.enqueue(event);
798
+ return;
799
+ }
800
+ // Flush buffered deltas before this event so it can't overtake them.
801
+ planningDeltaBatcherRef.current?.flush();
802
+
803
+ // Skip non-idempotent side-effects for replayed events, as in the
804
+ // main handler (#1656).
805
+ const isDuplicateEvent = useEventStore
806
+ .getState()
807
+ .eventIds.has(event.id ?? "");
808
+ // Mark this event as coming from the planning agent
809
+ const eventWithPlanningFlag = {
810
+ ...event,
811
+ isFromPlanningAgent: true,
812
+ };
813
+ addEvent(eventWithPlanningFlag);
814
+ if (isDuplicateEvent) {
815
+ return;
816
+ }
817
+
818
+ // Handle displayable error events - show error banner
819
+ // AgentErrorEvent errors are displayed inline in the chat, not as banners
820
+ if (isDisplayableErrorEvent(event)) {
821
+ const errorEvent = event as
822
+ | ConversationErrorEvent
823
+ | ServerErrorEvent;
824
+ const classification =
825
+ "classification" in errorEvent ? errorEvent.classification : null;
826
+ trackError({
827
+ source: "planning_conversation",
828
+ metadata: {
829
+ eventId: errorEvent.id,
830
+ errorCode: errorEvent.code,
831
+ },
832
+ classification,
833
+ });
834
+ setErrorMessage(
835
+ errorEvent.detail,
836
+ "conversation",
837
+ errorEvent.code,
838
+ classification,
839
+ );
840
+ } else {
841
+ handleNonErrorEvent();
842
+ }
843
+
844
+ // LLM errors render inline in the chat (see ErrorEventMessage); track
845
+ // them for analytics but keep them out of the banner above the chat box.
846
+ if (isAgentErrorEvent(event)) {
847
+ trackError({
848
+ source: "planning_agent",
849
+ metadata: {
850
+ eventId: event.id,
851
+ toolName: event.tool_name,
852
+ toolCallId: event.tool_call_id,
853
+ },
854
+ classification: event.classification,
855
+ });
856
+ }
857
+
858
+ // Clear optimistic user message when a user message is confirmed.
859
+ // Always scope to the main `conversationId` (where the user types)
860
+ // and match on the echoed content so the planning sub-agent's own
861
+ // events can never consume a main-conversation pending entry.
862
+ if (isUserMessageEvent(event)) {
863
+ if (conversationId) {
864
+ consumeMatchingPendingMessage(
865
+ conversationId,
866
+ extractMessageEventText(event),
867
+ );
868
+ setConversationState(conversationId, { draftMessage: null });
869
+ }
870
+ }
871
+
872
+ // Handle cache invalidation for ActionEvent
873
+ if (isActionEvent(event)) {
874
+ const planningAgentConversation = subConversations?.[0];
875
+ const currentConversationId =
876
+ planningAgentConversation?.id || "test-conversation-id"; // TODO: Get from context
877
+ handleActionEventCacheInvalidation(
878
+ event,
879
+ currentConversationId,
880
+ queryClient,
881
+ );
882
+ }
883
+
884
+ // Handle conversation state updates
885
+ // TODO: Tests
886
+ if (isConversationStateUpdateEvent(event)) {
887
+ // Scope to the planning agent's own conversation id, not the main
888
+ // `conversationId` — this socket reports the planning helper
889
+ // conversation's run/idle transitions, which must never overwrite
890
+ // the main conversation's status in the shared store.
891
+ if (
892
+ isFullStateConversationStateUpdateEvent(event) &&
893
+ planningConversationId
894
+ ) {
895
+ setExecutionStatus(
896
+ planningConversationId,
897
+ event.value.execution_status,
898
+ );
899
+ }
900
+ if (
901
+ isAgentStatusConversationStateUpdateEvent(event) &&
902
+ planningConversationId
903
+ ) {
904
+ setExecutionStatus(planningConversationId, event.value);
905
+ }
906
+ if (isStatsConversationStateUpdateEvent(event)) {
907
+ updateMetricsFromStats(event);
908
+ }
909
+ // Mirror goal status into the store. Intentionally duplicated across
910
+ // the main and planning WebSocket handlers (like the execution_status
911
+ // and stats branches above), not a merge artifact.
912
+ if (isGoalConversationStateUpdateEvent(event) && conversationId) {
913
+ useGoalStore.getState().setStatus(conversationId, event.value);
914
+ }
915
+ }
916
+
917
+ // Handle ExecuteBashAction events - add command as input to terminal
918
+ if (isExecuteBashActionEvent(event)) {
919
+ appendInput(event.action.command);
920
+ }
921
+
922
+ // Handle ExecuteBashObservation events - add output to terminal
923
+ if (isExecuteBashObservationEvent(event)) {
924
+ // Extract text content from the observation content array
925
+ const textContent = event.observation.content
926
+ .filter((c) => c.type === "text")
927
+ .map((c) => c.text)
928
+ .join("\n");
929
+ appendOutput(textContent);
930
+ }
931
+
932
+ // Handle PlanningFileEditorObservation - only update plan for Plan.md
933
+ if (isPlanningFileEditorObservationEvent(event)) {
934
+ const { path } = event.observation;
935
+ if (isPlanFilePath(path)) {
936
+ const planningAgentConversation = subConversations?.[0];
937
+ const planningConversationId = planningAgentConversation?.id;
938
+
939
+ if (planningConversationId && path) {
940
+ if (isLoadingHistoryPlanning) {
941
+ latestPlanningFileEventRef.current = {
942
+ path,
943
+ conversationId: planningConversationId,
944
+ };
945
+ } else {
946
+ readConversationFile(
947
+ {
948
+ conversationId: planningConversationId,
949
+ filePath: path,
950
+ },
951
+ {
952
+ onSuccess: (fileContent) => {
953
+ setPlanContent(fileContent);
954
+ },
955
+ onError: (error) => {
956
+ console.warn(
957
+ "Failed to read conversation file:",
958
+ error,
959
+ );
960
+ },
961
+ },
962
+ );
963
+ }
964
+ }
965
+ }
966
+ }
967
+ }
968
+ } catch (error) {
969
+ console.warn("Failed to parse WebSocket message as JSON:", error);
970
+ }
971
+ },
972
+ [
973
+ addEvent,
974
+ isLoadingHistoryPlanning,
975
+ expectedEventCountPlanning,
976
+ setErrorMessage,
977
+ consumeMatchingPendingMessage,
978
+ queryClient,
979
+ subConversations,
980
+ planningConversationId,
981
+ conversationId,
982
+ setExecutionStatus,
983
+ appendInput,
984
+ appendOutput,
985
+ readConversationFile,
986
+ setPlanContent,
987
+ updateMetricsFromStats,
988
+ handleNonErrorEvent,
989
+ ],
990
+ );
991
+
992
+ // Separate WebSocket options for main connection
993
+ const mainWebsocketOptions: WebSocketHookOptions = useMemo(() => {
994
+ // History was already loaded over REST (`useConversationHistory`).
995
+ // Subscribe with `resend_mode='since'` so the server only resends events
996
+ // strictly after the latest one we already have. If REST returned no
997
+ // events at all (brand-new conversation), fall back to `'all'` so any
998
+ // events that may have been written between the REST call and the WS
999
+ // handshake still show up. Dedup in the event store handles overlap.
1000
+ const queryParams: Record<string, string | boolean> = initialAfterTimestamp
1001
+ ? { resend_mode: "since", after_timestamp: initialAfterTimestamp }
1002
+ : { resend_mode: "all" };
1003
+
1004
+ return {
1005
+ queryParams,
1006
+ sessionApiKey,
1007
+ reconnect: { enabled: true },
1008
+ onOpen: () => {
1009
+ setMainConnectionState("OPEN");
1010
+ hasConnectedRefMain.current = true; // Mark that we've successfully connected
1011
+ clearConnectionError(); // Clear a previous connection error; keep sticky conversation errors
1012
+ },
1013
+ onClose: () => {
1014
+ setMainConnectionState("CLOSED");
1015
+ },
1016
+ onError: () => {
1017
+ setMainConnectionState("CLOSED");
1018
+ // Only show error message if we've previously connected successfully
1019
+ if (hasConnectedRefMain.current) {
1020
+ setErrorMessage(SERVER_CONNECTION_ERROR_MESSAGE, "connection");
1021
+ }
1022
+ },
1023
+ onMessage: handleMainMessage,
1024
+ };
1025
+ }, [
1026
+ handleMainMessage,
1027
+ setErrorMessage,
1028
+ clearConnectionError,
1029
+ sessionApiKey,
1030
+ initialAfterTimestamp,
1031
+ ]);
1032
+
1033
+ // Separate WebSocket options for planning agent connection
1034
+ const planningWebsocketOptions: WebSocketHookOptions = useMemo(() => {
1035
+ const queryParams: Record<string, string | boolean> = {
1036
+ resend_all: true,
1037
+ };
1038
+
1039
+ const planningAgentConversation = subConversations?.[0];
1040
+ const planningApiKey =
1041
+ planningAgentConversation?.session_api_key ?? sessionApiKey;
1042
+
1043
+ return {
1044
+ queryParams,
1045
+ sessionApiKey: planningApiKey,
1046
+ reconnect: { enabled: true },
1047
+ onOpen: async () => {
1048
+ setPlanningConnectionState("OPEN");
1049
+ hasConnectedRefPlanning.current = true; // Mark that we've successfully connected
1050
+ clearConnectionError(); // Clear a previous connection error; keep sticky conversation errors
1051
+
1052
+ // Fetch expected event count for history loading detection
1053
+ if (
1054
+ planningAgentConversation?.id &&
1055
+ planningAgentConversation.conversation_url
1056
+ ) {
1057
+ try {
1058
+ const count = await EventService.getEventCount(
1059
+ planningAgentConversation.id,
1060
+ planningAgentConversation.conversation_url,
1061
+ planningAgentConversation.session_api_key,
1062
+ );
1063
+ setExpectedEventCountPlanning(count);
1064
+
1065
+ // If no events expected, mark as loaded immediately
1066
+ if (count === 0) {
1067
+ setIsLoadingHistoryPlanning(false);
1068
+ }
1069
+ } catch (error) {
1070
+ // Fall back to marking as loaded to avoid infinite loading state
1071
+ setIsLoadingHistoryPlanning(false);
1072
+ }
1073
+ }
1074
+ },
1075
+ onClose: () => {
1076
+ setPlanningConnectionState("CLOSED");
1077
+ },
1078
+ onError: () => {
1079
+ setPlanningConnectionState("CLOSED");
1080
+ // Only show error message if we've previously connected successfully
1081
+ if (hasConnectedRefPlanning.current) {
1082
+ setErrorMessage(SERVER_CONNECTION_ERROR_MESSAGE, "connection");
1083
+ }
1084
+ },
1085
+ onMessage: handlePlanningMessage,
1086
+ };
1087
+ }, [
1088
+ handlePlanningMessage,
1089
+ setErrorMessage,
1090
+ clearConnectionError,
1091
+ sessionApiKey,
1092
+ subConversations,
1093
+ ]);
1094
+
1095
+ // Only attempt WebSocket connection when we have a valid URL
1096
+ // This prevents connection attempts during task polling phase
1097
+ const websocketUrl = wsUrl;
1098
+ const { socket: mainSocket, reconnect: reconnectMain } = useWebSocket(
1099
+ websocketUrl || "",
1100
+ mainWebsocketOptions,
1101
+ );
1102
+
1103
+ const { socket: planningAgentSocket, reconnect: reconnectPlanning } =
1104
+ useWebSocket(planningAgentWsUrl || "", planningWebsocketOptions);
1105
+
1106
+ const reconnect = useCallback(() => {
1107
+ removeErrorMessage();
1108
+ const currentMode = useConversationStore.getState().conversationMode;
1109
+ if (currentMode === "plan" && planningAgentWsUrl) {
1110
+ reconnectPlanning();
1111
+ return;
1112
+ }
1113
+ reconnectMain();
1114
+ }, [
1115
+ planningAgentWsUrl,
1116
+ reconnectMain,
1117
+ reconnectPlanning,
1118
+ removeErrorMessage,
1119
+ ]);
1120
+
1121
+ // V1 send message function via WebSocket
1122
+ // Falls back to REST API queue when WebSocket is not connected
1123
+ const sendMessage = useCallback(
1124
+ async (message: SendMessageRequest): Promise<SendMessageResult> => {
1125
+ const currentMode = useConversationStore.getState().conversationMode;
1126
+ const currentSocket =
1127
+ currentMode === "plan" ? planningAgentSocket : mainSocket;
1128
+ const targetConversationId =
1129
+ currentMode === "plan" ? planningConversationId : conversationId;
1130
+
1131
+ if (currentSocket?.readyState !== WebSocket.OPEN) {
1132
+ // WebSocket not connected - queue message via REST API
1133
+ // Message will be delivered automatically when conversation becomes ready
1134
+ if (!targetConversationId) {
1135
+ // Never fall back to the parent in plan mode: without a planner
1136
+ // target the message would run in the code agent, which is exactly
1137
+ // the boundary plan mode exists to enforce.
1138
+ const error = new Error(
1139
+ currentMode === "plan"
1140
+ ? "Planning conversation is not ready yet"
1141
+ : "No conversation ID available",
1142
+ );
1143
+ setErrorMessage(error.message);
1144
+ throw error;
1145
+ }
1146
+
1147
+ try {
1148
+ await new ConversationClient(getAgentServerClientOptions()).sendEvent(
1149
+ targetConversationId,
1150
+ {
1151
+ role: "user",
1152
+ content: message.content,
1153
+ },
1154
+ { run: true },
1155
+ );
1156
+ // Message queued successfully - it will be delivered when ready
1157
+ // Return queued: true so caller knows not to show optimistic UI
1158
+ return { queued: true };
1159
+ } catch (error) {
1160
+ const errorMessage =
1161
+ error instanceof Error
1162
+ ? error.message
1163
+ : "Failed to queue message for delivery";
1164
+ setErrorMessage(errorMessage);
1165
+ throw error;
1166
+ }
1167
+ }
1168
+
1169
+ try {
1170
+ // Send message through WebSocket as JSON with run: true so the
1171
+ // agent loop starts automatically in async mode.
1172
+ currentSocket.send(JSON.stringify({ ...message, run: true }));
1173
+ return { queued: false };
1174
+ } catch (error) {
1175
+ const errorMessage =
1176
+ error instanceof Error ? error.message : "Failed to send message";
1177
+ setErrorMessage(errorMessage);
1178
+ throw error;
1179
+ }
1180
+ },
1181
+ [
1182
+ mainSocket,
1183
+ planningAgentSocket,
1184
+ setErrorMessage,
1185
+ conversationId,
1186
+ planningConversationId,
1187
+ ],
1188
+ );
1189
+
1190
+ // Track main socket state changes
1191
+ useEffect(() => {
1192
+ // Only process socket updates if we have a valid URL and socket
1193
+ if (mainSocket && wsUrl) {
1194
+ // Update state based on socket readyState
1195
+ const updateState = () => {
1196
+ switch (mainSocket.readyState) {
1197
+ case WebSocket.CONNECTING:
1198
+ setMainConnectionState("CONNECTING");
1199
+ break;
1200
+ case WebSocket.OPEN:
1201
+ setMainConnectionState("OPEN");
1202
+ break;
1203
+ case WebSocket.CLOSING:
1204
+ setMainConnectionState("CLOSING");
1205
+ break;
1206
+ case WebSocket.CLOSED:
1207
+ setMainConnectionState("CLOSED");
1208
+ break;
1209
+ default:
1210
+ setMainConnectionState("CLOSED");
1211
+ break;
1212
+ }
1213
+ };
1214
+
1215
+ updateState();
1216
+ }
1217
+ }, [mainSocket, wsUrl]);
1218
+
1219
+ // Track planning agent socket state changes
1220
+ useEffect(() => {
1221
+ // Only process socket updates if we have a valid URL and socket
1222
+ if (planningAgentSocket && planningAgentWsUrl) {
1223
+ // Update state based on socket readyState
1224
+ const updateState = () => {
1225
+ switch (planningAgentSocket.readyState) {
1226
+ case WebSocket.CONNECTING:
1227
+ setPlanningConnectionState("CONNECTING");
1228
+ break;
1229
+ case WebSocket.OPEN:
1230
+ setPlanningConnectionState("OPEN");
1231
+ break;
1232
+ case WebSocket.CLOSING:
1233
+ setPlanningConnectionState("CLOSING");
1234
+ break;
1235
+ case WebSocket.CLOSED:
1236
+ setPlanningConnectionState("CLOSED");
1237
+ break;
1238
+ default:
1239
+ setPlanningConnectionState("CLOSED");
1240
+ break;
1241
+ }
1242
+ };
1243
+
1244
+ updateState();
1245
+ }
1246
+ }, [planningAgentSocket, planningAgentWsUrl]);
1247
+
1248
+ const contextValue = useMemo(
1249
+ () => ({
1250
+ connectionState,
1251
+ mainConnectionState,
1252
+ sendMessage,
1253
+ isLoadingHistory,
1254
+ reconnect,
1255
+ }),
1256
+ [
1257
+ connectionState,
1258
+ mainConnectionState,
1259
+ sendMessage,
1260
+ isLoadingHistory,
1261
+ reconnect,
1262
+ ],
1263
+ );
1264
+
1265
+ return (
1266
+ <ConversationWebSocketContext.Provider value={contextValue}>
1267
+ {children}
1268
+ </ConversationWebSocketContext.Provider>
1269
+ );
1270
+ }
1271
+
1272
+ export const useConversationWebSocket =
1273
+ (): ConversationWebSocketContextType | null => {
1274
+ const context = useContext(ConversationWebSocketContext);
1275
+ // Return null instead of throwing when not in provider
1276
+ // This allows the hook to be called conditionally based on conversation version
1277
+ return context || null;
1278
+ };
src/contexts/settings-section-header-context.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+
3
+ interface SettingsSectionHeaderContextValue {
4
+ setHideSectionHeader: (hide: boolean) => void;
5
+ }
6
+
7
+ const SettingsSectionHeaderContext =
8
+ React.createContext<SettingsSectionHeaderContextValue | null>(null);
9
+
10
+ export function SettingsSectionHeaderProvider({
11
+ setHideSectionHeader,
12
+ children,
13
+ }: React.PropsWithChildren<{
14
+ setHideSectionHeader: (hide: boolean) => void;
15
+ }>) {
16
+ const value = React.useMemo(
17
+ () => ({ setHideSectionHeader }),
18
+ [setHideSectionHeader],
19
+ );
20
+
21
+ return (
22
+ <SettingsSectionHeaderContext.Provider value={value}>
23
+ {children}
24
+ </SettingsSectionHeaderContext.Provider>
25
+ );
26
+ }
27
+
28
+ export function useSettingsSectionHeader() {
29
+ const context = React.useContext(SettingsSectionHeaderContext);
30
+ return (
31
+ context ?? {
32
+ setHideSectionHeader: () => {},
33
+ }
34
+ );
35
+ }
src/contexts/websocket-provider-wrapper.tsx ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
3
+ import { useActiveConversation } from "#/hooks/query/use-active-conversation";
4
+ import { useSubConversations } from "#/hooks/query/use-sub-conversations";
5
+ import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
6
+ import { useConversationStore } from "#/stores/conversation-store";
7
+ import { useActiveBackend } from "#/contexts/active-backend-context";
8
+ import { findPlannerConversationId } from "#/utils/plan-file";
9
+
10
+ interface WebSocketProviderWrapperProps {
11
+ children: React.ReactNode;
12
+ conversationId: string;
13
+ }
14
+
15
+ export function WebSocketProviderWrapper({
16
+ children,
17
+ conversationId,
18
+ }: WebSocketProviderWrapperProps) {
19
+ const { data: conversation } = useActiveConversation();
20
+ const { backend } = useActiveBackend();
21
+ const isLocalBackend = backend.kind !== "cloud";
22
+ const localPlanningConversationId = useConversationStore(
23
+ (state) => state.localPlanningConversationId,
24
+ );
25
+
26
+ // `localPlanningConversationId` is a single unscoped Zustand field. Right
27
+ // after switching conversations it can still hold the *previous*
28
+ // conversation's planner id, since the reset effect in
29
+ // routes/conversation.tsx and `useActiveConversation()`'s refetch both lag
30
+ // this render — only trust it once `conversation` matches `conversationId`.
31
+ const isConversationDataFresh = conversation?.id === conversationId;
32
+ const trustedLocalPlanningConversationId = isConversationDataFresh
33
+ ? localPlanningConversationId
34
+ : null;
35
+
36
+ // Candidate ids to resolve. On local backends `sub_conversation_ids` is the
37
+ // generic (untyped) child list, so fetch it below to find the
38
+ // `plannerparent`-tagged entry; cloud has no such ambiguity. Memoized: a
39
+ // fresh array literal each render would re-fire ConversationWebSocketProvider's
40
+ // reference-keyed effects and wipe the pending PLAN.md read.
41
+ const candidateConversationIds = React.useMemo(() => {
42
+ if (!isLocalBackend) {
43
+ return conversation?.sub_conversation_ids ?? [];
44
+ }
45
+ if (
46
+ conversation?.sub_conversation_ids &&
47
+ conversation.sub_conversation_ids.length > 0
48
+ ) {
49
+ return conversation.sub_conversation_ids;
50
+ }
51
+ return trustedLocalPlanningConversationId
52
+ ? [trustedLocalPlanningConversationId]
53
+ : [];
54
+ }, [
55
+ isLocalBackend,
56
+ conversation?.sub_conversation_ids,
57
+ trustedLocalPlanningConversationId,
58
+ ]);
59
+ const { data: subConversations } = useSubConversations(
60
+ candidateConversationIds,
61
+ );
62
+
63
+ // Identify the planner via the `plannerparent` tag rather than list
64
+ // position — an unrelated child must never be adopted as the planner.
65
+ // Kept as its own memo (a primitive) rather than inlined below: `subConversations`
66
+ // gets a new array reference on every refetch even when the planner id
67
+ // hasn't changed, and planningConversationIds must not rebuild its own
68
+ // array in that case — see candidateConversationIds above.
69
+ const plannerConversationId = React.useMemo(() => {
70
+ if (!isLocalBackend) return null;
71
+ return findPlannerConversationId(subConversations, conversation?.id);
72
+ }, [isLocalBackend, subConversations, conversation?.id]);
73
+
74
+ const planningConversationIds = React.useMemo(() => {
75
+ if (!isLocalBackend) return candidateConversationIds;
76
+ if (plannerConversationId) return [plannerConversationId];
77
+ // Tag data hasn't resolved yet — bridge with the verified store id (see
78
+ // `trustedLocalPlanningConversationId` above), otherwise stay empty
79
+ // rather than guessing an untagged child is the planner.
80
+ return trustedLocalPlanningConversationId
81
+ ? [trustedLocalPlanningConversationId]
82
+ : [];
83
+ }, [
84
+ isLocalBackend,
85
+ candidateConversationIds,
86
+ plannerConversationId,
87
+ trustedLocalPlanningConversationId,
88
+ ]);
89
+
90
+ const filteredSubConversations = subConversations?.filter(
91
+ (subConversation): subConversation is AppConversation =>
92
+ subConversation !== null &&
93
+ planningConversationIds.includes(subConversation.id),
94
+ );
95
+
96
+ // Suppress the URL while the cloud sandbox is PAUSED — it still points at
97
+ // the old (now-rejecting) host until the sandbox resumes.
98
+ const conversationUrl =
99
+ conversation?.sandbox_status === "PAUSED"
100
+ ? null
101
+ : conversation?.conversation_url;
102
+
103
+ return (
104
+ <ConversationWebSocketProvider
105
+ conversationId={conversationId}
106
+ conversationUrl={conversationUrl}
107
+ sessionApiKey={conversation?.session_api_key}
108
+ subConversationIds={planningConversationIds}
109
+ subConversations={filteredSubConversations}
110
+ >
111
+ {children}
112
+ </ConversationWebSocketProvider>
113
+ );
114
+ }
src/mocks/agent-profiles-handlers.ts ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from "msw";
2
+ import type {
3
+ AgentProfile,
4
+ AgentProfileSaveInput,
5
+ AgentProfileSummary,
6
+ } from "@openhands/typescript-client";
7
+
8
+ /**
9
+ * In-memory agent-profile store for the mock agent-server API. Keyed by name
10
+ * (agent-server uses name-based lookups, not IDs).
11
+ *
12
+ * Two consumers depend on it:
13
+ *
14
+ * - Settings → Agent *is* the agent-profile library (#1571), and the embedded
15
+ * editor behind it is the only surface that renders `AgentSettingsScreen`.
16
+ * Without a seeded store the page shows "Failed to load profiles" under
17
+ * `npm run dev:mock`, so the whole agent-settings form is unreachable there.
18
+ * - `useCreateConversation` no longer downgrades silently when the profile
19
+ * list fails (#16523): in non-mocked tests `listProfiles` would hit the real
20
+ * network, reject, and surface as a hard failure.
21
+ *
22
+ * State is per-module-load and deliberately in-memory: create, edit, rename
23
+ * and delete all round-trip so the editor's save path (a whole-profile
24
+ * overwrite) can be exercised, but nothing persists across a refresh.
25
+ *
26
+ * Imported as a live module so consumers can seed entries and reset between
27
+ * tests without re-registering handlers.
28
+ */
29
+ const profiles = new Map<string, AgentProfile>();
30
+
31
+ let activeProfileId: string | null = null;
32
+
33
+ const DEFAULT_VERIFICATION = {
34
+ critic_enabled: false,
35
+ critic_mode: "off",
36
+ enable_iterative_refinement: false,
37
+ critic_threshold: 0.5,
38
+ max_refinement_iterations: 3,
39
+ critic_server_url: null,
40
+ critic_model_name: null,
41
+ };
42
+
43
+ function makeOpenHandsProfile(
44
+ overrides: Partial<AgentProfile> & { id: string; name: string },
45
+ ): AgentProfile {
46
+ return {
47
+ schema_version: 1,
48
+ revision: 1,
49
+ mcp_server_refs: null,
50
+ agent_kind: "openhands",
51
+ llm_profile_ref: "default",
52
+ agent: "CodeActAgent",
53
+ skills: [],
54
+ system_message_suffix: null,
55
+ condenser: null,
56
+ verification: DEFAULT_VERIFICATION,
57
+ enable_sub_agents: false,
58
+ tool_concurrency_limit: 1,
59
+ ...overrides,
60
+ } as AgentProfile;
61
+ }
62
+
63
+ /** Stable id of the seeded `default` profile (the active one after a seed). */
64
+ export const MOCK_DEFAULT_AGENT_PROFILE_ID =
65
+ "3f1c1b7e-0000-4000-8000-000000000001";
66
+
67
+ const SEEDED_PROFILES: readonly AgentProfile[] = [
68
+ makeOpenHandsProfile({
69
+ id: MOCK_DEFAULT_AGENT_PROFILE_ID,
70
+ name: "default",
71
+ }),
72
+ makeOpenHandsProfile({
73
+ id: "3f1c1b7e-0000-4000-8000-000000000002",
74
+ name: "research",
75
+ enable_sub_agents: true,
76
+ tool_concurrency_limit: 4,
77
+ }),
78
+ ];
79
+
80
+ // Monotonic so ids minted after a delete never collide with a live profile.
81
+ let nextProfileSeq = SEEDED_PROFILES.length + 1;
82
+
83
+ function newProfileId(): string {
84
+ const suffix = String(nextProfileSeq).padStart(12, "0");
85
+ nextProfileSeq += 1;
86
+ return `3f1c1b7e-0000-4000-8000-${suffix}`;
87
+ }
88
+
89
+ /** Reset the in-memory store to empty (no profiles, no active pointer). */
90
+ export function resetMockAgentProfiles(): void {
91
+ profiles.clear();
92
+ activeProfileId = null;
93
+ nextProfileSeq = SEEDED_PROFILES.length + 1;
94
+ }
95
+
96
+ /**
97
+ * Restore the seeded store: a `default` profile (active, mirroring what the
98
+ * agent-server seeds on first run) plus a `research` profile so the library
99
+ * has something to switch between.
100
+ */
101
+ export function seedMockAgentProfiles(): void {
102
+ resetMockAgentProfiles();
103
+ for (const profile of SEEDED_PROFILES) {
104
+ profiles.set(profile.name, { ...profile });
105
+ }
106
+ activeProfileId = MOCK_DEFAULT_AGENT_PROFILE_ID;
107
+ }
108
+ seedMockAgentProfiles();
109
+
110
+ function toSummary(name: string, profile: AgentProfile): AgentProfileSummary {
111
+ return {
112
+ id: profile.id,
113
+ name,
114
+ agent_kind: profile.agent_kind,
115
+ revision: profile.revision,
116
+ llm_profile_ref:
117
+ profile.agent_kind === "openhands" ? profile.llm_profile_ref : null,
118
+ mcp_server_refs: profile.mcp_server_refs,
119
+ };
120
+ }
121
+
122
+ function notFound(name: string) {
123
+ return HttpResponse.json(
124
+ { detail: `Agent profile '${name}' not found` },
125
+ { status: 404 },
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Mock handlers for the agent-server `/api/agent-profiles` endpoints (the same
131
+ * contract consumed by `AgentProfilesService` and the cloud proxy).
132
+ *
133
+ * Routes mirror `agent_profiles_router.py` in the agent-server. MSW anchors its
134
+ * path matcher, so the list route never claims the `:name` routes below and no
135
+ * extra path guarding is needed.
136
+ */
137
+ export const AGENT_PROFILES_HANDLERS = [
138
+ // GET /api/agent-profiles - List all profiles + the active id.
139
+ http.get("*/api/agent-profiles", async () => {
140
+ const summaries = Array.from(profiles.entries()).map(([name, profile]) =>
141
+ toSummary(name, profile),
142
+ );
143
+ return HttpResponse.json({
144
+ profiles: summaries,
145
+ active_agent_profile_id: activeProfileId,
146
+ });
147
+ }),
148
+
149
+ // GET /api/agent-profiles/:name - Fetch a single profile.
150
+ http.get("*/api/agent-profiles/:name", async ({ params }) => {
151
+ const { name } = params;
152
+ if (typeof name !== "string") {
153
+ return HttpResponse.json({ detail: "Invalid name" }, { status: 400 });
154
+ }
155
+ const profile = profiles.get(name);
156
+ if (!profile) return notFound(name);
157
+ return HttpResponse.json({ name, profile });
158
+ }),
159
+
160
+ // POST /api/agent-profiles/:name/rename - Rename a profile.
161
+ http.post(
162
+ "*/api/agent-profiles/:name/rename",
163
+ async ({ params, request }) => {
164
+ const { name } = params;
165
+ if (typeof name !== "string") {
166
+ return HttpResponse.json({ detail: "Invalid name" }, { status: 400 });
167
+ }
168
+ const body = (await request.json()) as { new_name?: string } | null;
169
+ const newName = body?.new_name;
170
+ if (!newName) {
171
+ return HttpResponse.json(
172
+ { detail: "new_name is required" },
173
+ { status: 422 },
174
+ );
175
+ }
176
+ const profile = profiles.get(name);
177
+ if (!profile) return notFound(name);
178
+ if (newName !== name && profiles.has(newName)) {
179
+ return HttpResponse.json(
180
+ { detail: `Agent profile '${newName}' already exists` },
181
+ { status: 409 },
182
+ );
183
+ }
184
+ profiles.delete(name);
185
+ // Rename preserves the stable id, and the active pointer with it.
186
+ profiles.set(newName, { ...profile, name: newName });
187
+ return HttpResponse.json({
188
+ name: newName,
189
+ message: "Agent profile renamed.",
190
+ });
191
+ },
192
+ ),
193
+
194
+ // POST /api/agent-profiles/:id/activate - Activate by stable UUID (pointer-only).
195
+ http.post("*/api/agent-profiles/:id/activate", async ({ params }) => {
196
+ const { id } = params;
197
+ if (typeof id !== "string") {
198
+ return HttpResponse.json({ detail: "Invalid id" }, { status: 400 });
199
+ }
200
+ const profile = Array.from(profiles.values()).find((p) => p.id === id);
201
+ if (!profile) return notFound(id);
202
+ activeProfileId = id;
203
+ return HttpResponse.json({
204
+ id,
205
+ message: "Agent profile activated.",
206
+ agent_settings_applied: false,
207
+ });
208
+ }),
209
+
210
+ // POST /api/agent-profiles/:name - Create or overwrite a profile (upsert).
211
+ http.post("*/api/agent-profiles/:name", async ({ params, request }) => {
212
+ const { name } = params;
213
+ if (typeof name !== "string") {
214
+ return HttpResponse.json({ detail: "Invalid name" }, { status: 400 });
215
+ }
216
+ const body = (await request.json()) as AgentProfileSaveInput;
217
+ const existing = profiles.get(name);
218
+ // Upsert with server-managed identity: the id survives an overwrite and
219
+ // the revision counter moves, matching `save_profile_preserving_identity`.
220
+ // A caller-supplied id is honoured only on create, so tests can seed a
221
+ // profile with a known id and activate it.
222
+ profiles.set(name, {
223
+ ...(body as AgentProfile),
224
+ name,
225
+ id: existing?.id ?? body.id ?? newProfileId(),
226
+ revision: (existing?.revision ?? 0) + 1,
227
+ });
228
+ return HttpResponse.json({ name, message: "Agent profile saved." });
229
+ }),
230
+
231
+ // DELETE /api/agent-profiles/:name - Delete by name (idempotent).
232
+ http.delete("*/api/agent-profiles/:name", async ({ params }) => {
233
+ const { name } = params;
234
+ if (typeof name !== "string") {
235
+ return HttpResponse.json({ detail: "Invalid name" }, { status: 400 });
236
+ }
237
+ // Deleting the active profile clears the pointer rather than leaving it
238
+ // dangling; a missing name still resolves 200, as on the server.
239
+ if (activeProfileId === profiles.get(name)?.id) {
240
+ activeProfileId = null;
241
+ }
242
+ profiles.delete(name);
243
+ return HttpResponse.json({ name, message: "Agent profile deleted." });
244
+ }),
245
+ ];
src/mocks/analytics-handlers.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from "msw";
2
+
3
+ // Block both the direct PostHog ingestion endpoint and the OpenHands reverse
4
+ // proxy (z.openhands.dev) used by the library telemetry service so mock-mode
5
+ // builds never send analytics events to PostHog.
6
+ export const ANALYTICS_HANDLERS = [
7
+ http.post("https://us.i.posthog.com/e", async () =>
8
+ HttpResponse.json(null, { status: 200 }),
9
+ ),
10
+ http.post("https://z.openhands.dev/*", async () =>
11
+ HttpResponse.json(null, { status: 200 }),
12
+ ),
13
+ ];
src/mocks/auth-handlers.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from "msw";
2
+ import { GitUser } from "#/types/git";
3
+
4
+ export const AUTH_HANDLERS = [
5
+ // Cloud proxy is not available in mock mode; return a clean 503 so callers
6
+ // (useAllCloudOrganizations, useBackendsHealth cloud path) fail gracefully
7
+ // instead of falling through to the real dev server and getting a 502.
8
+ http.post("*/api/cloud-proxy", () =>
9
+ HttpResponse.json(
10
+ { error: "cloud proxy not available in mock mode" },
11
+ { status: 503 },
12
+ ),
13
+ ),
14
+
15
+ http.get("*/api/user/info", () => {
16
+ const user: GitUser = {
17
+ id: "1",
18
+ login: "octocat",
19
+ avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
20
+ company: "GitHub",
21
+ email: "placeholder@placeholder.placeholder",
22
+ name: "monalisa octocat",
23
+ };
24
+
25
+ return HttpResponse.json(user);
26
+ }),
27
+
28
+ http.post("*/api/authenticate", async () =>
29
+ HttpResponse.json({ message: "Authenticated" }),
30
+ ),
31
+
32
+ http.post("*/api/logout", () => HttpResponse.json(null, { status: 200 })),
33
+ ];
src/mocks/automation-handlers.ts ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse, delay } from "msw";
2
+ import capabilitiesFixture from "@openhands/extensions/testing/automations/capabilities.json";
3
+ import type {
4
+ DeploymentCapabilities,
5
+ DraftValidationError,
6
+ ValidateDraftResponse,
7
+ } from "#/manifests/types";
8
+ import type {
9
+ Automation,
10
+ AutomationsResponse,
11
+ AutomationRun,
12
+ AutomationRunsResponse,
13
+ } from "#/types/automation";
14
+ import { AutomationRunStatus } from "#/types/automation";
15
+ import { MOCK_AUTOMATIONS_RESPONSE } from "./automations.mock";
16
+ import { MOCK_AUTOMATION_RUNS } from "./automation-runs.mock";
17
+
18
+ // The "supported" deployment from the published contract fixtures. Discovery
19
+ // and preflight answer with it, so the setup flow runs against the same
20
+ // reference data the extensions contract is verified against.
21
+ const CAPABILITIES: DeploymentCapabilities =
22
+ capabilitiesFixture.responses.supported.body;
23
+
24
+ interface DraftTrigger {
25
+ type?: string;
26
+ schedule?: string;
27
+ on?: string;
28
+ source?: string;
29
+ }
30
+
31
+ // The schedules the mock can read: "* * * * *" and "*/N * * * *". Anything
32
+ // else is assumed to satisfy the deployment minimum — this stands in for the
33
+ // service's cron parser rather than reimplementing it.
34
+ const STEP_SCHEDULE_PATTERN = /^(?:\*|\*\/(\d+)) \* \* \* \*$/;
35
+
36
+ function cronIntervalSeconds(schedule: string): number | null {
37
+ const match = STEP_SCHEDULE_PATTERN.exec(schedule.trim());
38
+ if (!match) return null;
39
+ return (match[1] ? Number(match[1]) : 1) * 60;
40
+ }
41
+
42
+ // Event deliveries registered for the mock organization. Distinct from
43
+ // CAPABILITIES.eventTypes on purpose: a deployment can support an event type
44
+ // that no webhook is registered to deliver, and preflight is what catches
45
+ // that gap — the fixtures' event-type-not-delivered scenario depends on one
46
+ // supported type staying unregistered here.
47
+ const REGISTERED_EVENT_TYPES = CAPABILITIES.eventTypes.filter(
48
+ (type) => type !== "pull_request_review_comment.created",
49
+ );
50
+
51
+ // The deployment checks the contract fixtures record: a cron schedule below
52
+ // triggers.cron.minIntervalSeconds, and an event type no webhook delivers.
53
+ // Errors address the draft by dotted path, exactly as the fixtures do.
54
+ function validateDraftTrigger(
55
+ trigger: DraftTrigger | undefined,
56
+ ): DraftValidationError[] {
57
+ if (!trigger) return [];
58
+
59
+ const cron = CAPABILITIES.triggers.cron;
60
+ if (trigger.type === "cron" && trigger.schedule && cron) {
61
+ const interval = cronIntervalSeconds(trigger.schedule);
62
+ if (interval !== null && interval < cron.minIntervalSeconds) {
63
+ return [
64
+ {
65
+ field: "trigger.schedule",
66
+ code: "interval_too_short",
67
+ message: `Minimum interval for this deployment is ${cron.minIntervalSeconds / 60} minutes.`,
68
+ },
69
+ ];
70
+ }
71
+ }
72
+
73
+ if (
74
+ trigger.type === "event" &&
75
+ trigger.on &&
76
+ !REGISTERED_EVENT_TYPES.includes(trigger.on)
77
+ ) {
78
+ const source = trigger.source === "github" ? "GitHub" : trigger.source;
79
+ return [
80
+ {
81
+ field: "trigger.on",
82
+ code: "event_type_not_delivered",
83
+ message: `No ${source} webhook delivering ${trigger.on} is registered for this organization.`,
84
+ },
85
+ ];
86
+ }
87
+
88
+ return [];
89
+ }
90
+
91
+ // Mutable copy for CRUD operations within the mock session
92
+ const automations = new Map<string, Automation>(
93
+ MOCK_AUTOMATIONS_RESPONSE.automations.map((a) => [a.id, { ...a }]),
94
+ );
95
+
96
+ export const resetAutomationMockData = () => {
97
+ automations.clear();
98
+ MOCK_AUTOMATIONS_RESPONSE.automations.forEach((a) => {
99
+ automations.set(a.id, { ...a });
100
+ });
101
+ };
102
+
103
+ export const AUTOMATION_HANDLERS = [
104
+ // GET /api/automation/health — Health check
105
+ http.get("*/api/automation/health", async () => {
106
+ await delay(100);
107
+ return HttpResponse.json({ status: "ok" });
108
+ }),
109
+
110
+ // GET /api/automation/v1 — List automations
111
+ http.get("*/api/automation/v1", async ({ request }) => {
112
+ await delay(300);
113
+
114
+ const url = new URL(request.url);
115
+ const limit = Number(url.searchParams.get("limit") ?? "50");
116
+ const offset = Number(url.searchParams.get("offset") ?? "0");
117
+
118
+ const all = Array.from(automations.values());
119
+ const page = all.slice(offset, offset + limit);
120
+
121
+ const response: AutomationsResponse = {
122
+ automations: page,
123
+ total: all.length,
124
+ };
125
+
126
+ return HttpResponse.json(response);
127
+ }),
128
+
129
+ // GET /api/automation/v1/capabilities — What this deployment supports
130
+ http.get("*/api/automation/v1/capabilities", async () => {
131
+ await delay(200);
132
+ return HttpResponse.json(CAPABILITIES);
133
+ }),
134
+
135
+ // POST /api/automation/v1/validate — Preflight a draft without creating it
136
+ http.post("*/api/automation/v1/validate", async ({ request }) => {
137
+ await delay(200);
138
+
139
+ const body = (await request.clone().json()) as {
140
+ automationId?: string;
141
+ endpoint?: string;
142
+ draft?: { trigger?: DraftTrigger };
143
+ };
144
+
145
+ const errors = validateDraftTrigger(body.draft?.trigger);
146
+ const response: ValidateDraftResponse = {
147
+ valid: errors.length === 0,
148
+ errors,
149
+ };
150
+
151
+ return HttpResponse.json(response);
152
+ }),
153
+
154
+ // POST /api/automation/v1/uploads — Upload custom automation tarball
155
+ http.post("*/api/automation/v1/uploads", async ({ request }) => {
156
+ await delay(200);
157
+ const url = new URL(request.url);
158
+ const name = url.searchParams.get("name") || "automation";
159
+ return HttpResponse.json({
160
+ tarball_path: `oh-internal://uploads/${encodeURIComponent(name)}-${crypto.randomUUID()}.tar.gz`,
161
+ });
162
+ }),
163
+
164
+ // POST /api/automation/v1 — Create an automation from uploaded tarball
165
+ http.post("*/api/automation/v1", async ({ request }) => {
166
+ await delay(200);
167
+
168
+ const body = (await request.clone().json()) as {
169
+ name: string;
170
+ model?: string;
171
+ trigger: Automation["trigger"];
172
+ timeout?: number;
173
+ tarball_path?: string;
174
+ entrypoint?: string;
175
+ setup_script_path?: string;
176
+ };
177
+ const now = new Date().toISOString();
178
+ const automation: Automation = {
179
+ id: crypto.randomUUID(),
180
+ name: body.name,
181
+ prompt: null,
182
+ model: body.model ?? null,
183
+ trigger: body.trigger,
184
+ enabled: true,
185
+ created_at: now,
186
+ updated_at: now,
187
+ last_triggered_at: null,
188
+ ...(typeof body.timeout === "number" && { timeout: body.timeout }),
189
+ ...(typeof body.trigger.timezone === "string" && {
190
+ timezone: body.trigger.timezone,
191
+ }),
192
+ };
193
+
194
+ automations.set(automation.id, automation);
195
+ return HttpResponse.json(automation, { status: 201 });
196
+ }),
197
+
198
+ // POST /api/automation/v1/preset/:kind — Create a prompt/plugin automation
199
+ http.post("*/api/automation/v1/preset/:kind", async ({ params, request }) => {
200
+ await delay(200);
201
+
202
+ const body = (await request.clone().json()) as {
203
+ name: string;
204
+ prompt: string;
205
+ model?: string;
206
+ trigger: Automation["trigger"];
207
+ repos?: { url: string; ref?: string }[];
208
+ plugins?: { source: string }[];
209
+ timeout?: number;
210
+ };
211
+ const now = new Date().toISOString();
212
+ const automation: Automation = {
213
+ id: crypto.randomUUID(),
214
+ name: body.name,
215
+ prompt: body.prompt,
216
+ model: body.model ?? null,
217
+ trigger: body.trigger,
218
+ enabled: true,
219
+ created_at: now,
220
+ updated_at: now,
221
+ last_triggered_at: null,
222
+ ...(body.repos?.[0] && {
223
+ repository: body.repos[0].url,
224
+ branch: body.repos[0].ref,
225
+ }),
226
+ ...(body.plugins && {
227
+ plugins: body.plugins.map((plugin) => plugin.source),
228
+ }),
229
+ ...(typeof body.timeout === "number" && { timeout: body.timeout }),
230
+ ...(typeof body.trigger.timezone === "string" && {
231
+ timezone: body.trigger.timezone,
232
+ }),
233
+ };
234
+
235
+ if (params.kind !== "prompt" && params.kind !== "plugin") {
236
+ return HttpResponse.json(
237
+ { detail: "Unknown preset kind" },
238
+ { status: 404 },
239
+ );
240
+ }
241
+
242
+ automations.set(automation.id, automation);
243
+ return HttpResponse.json(automation, { status: 201 });
244
+ }),
245
+
246
+ // GET /api/automation/v1/:id/runs — List automation runs
247
+ http.get("*/api/automation/v1/:id/runs", async ({ params, request }) => {
248
+ await delay(200);
249
+
250
+ const id = params.id as string;
251
+ if (!automations.has(id)) {
252
+ return HttpResponse.json(
253
+ { detail: "Automation not found" },
254
+ { status: 404 },
255
+ );
256
+ }
257
+
258
+ const url = new URL(request.url);
259
+ const limit = Number(url.searchParams.get("limit") ?? "50");
260
+ const offset = Number(url.searchParams.get("offset") ?? "0");
261
+ const allRuns = MOCK_AUTOMATION_RUNS[id] ?? [];
262
+ const page = allRuns.slice(offset, offset + limit);
263
+ const statusCounts: AutomationRunsResponse["status_counts"] = {};
264
+ for (const run of allRuns) {
265
+ statusCounts[run.status] = (statusCounts[run.status] ?? 0) + 1;
266
+ }
267
+
268
+ const response: AutomationRunsResponse = {
269
+ runs: page,
270
+ total: allRuns.length,
271
+ status_counts: statusCounts,
272
+ };
273
+
274
+ return HttpResponse.json(response);
275
+ }),
276
+
277
+ // GET /api/automation/v1/:id — Get automation detail
278
+ http.get("*/api/automation/v1/:id", async ({ params }) => {
279
+ await delay(200);
280
+
281
+ const automation = automations.get(params.id as string);
282
+ if (!automation) {
283
+ return HttpResponse.json(
284
+ { detail: "Automation not found" },
285
+ { status: 404 },
286
+ );
287
+ }
288
+
289
+ return HttpResponse.json(automation);
290
+ }),
291
+
292
+ // PATCH /api/automation/v1/:id — Update automation (toggle enabled)
293
+ http.patch("*/api/automation/v1/:id", async ({ params, request }) => {
294
+ await delay(200);
295
+
296
+ const id = params.id as string;
297
+ // Clone the request before reading the body to avoid "Body has already been read" errors
298
+ // when MSW internally consumes the body during handler resolution.
299
+ const body = (await request.clone().json()) as Partial<Automation>;
300
+ const automation = automations.get(id);
301
+ if (!automation) {
302
+ return HttpResponse.json(
303
+ { detail: "Automation not found" },
304
+ { status: 404 },
305
+ );
306
+ }
307
+
308
+ const updated: Automation = {
309
+ ...automation,
310
+ ...body,
311
+ updated_at: new Date().toISOString(),
312
+ };
313
+ automations.set(id, updated);
314
+
315
+ return HttpResponse.json(updated);
316
+ }),
317
+
318
+ // POST /api/automation/v1/:id/dispatch — Manually trigger a run
319
+ http.post("*/api/automation/v1/:id/dispatch", async ({ params }) => {
320
+ await delay(200);
321
+
322
+ const id = params.id as string;
323
+ const automation = automations.get(id);
324
+ if (!automation) {
325
+ return HttpResponse.json(
326
+ { detail: "Automation not found" },
327
+ { status: 404 },
328
+ );
329
+ }
330
+
331
+ const run: AutomationRun = {
332
+ id: crypto.randomUUID(),
333
+ status: AutomationRunStatus.PENDING,
334
+ conversation_id: null,
335
+ bash_command_id: null,
336
+ error_detail: null,
337
+ // A freshly dispatched run has not reported a phase yet — the service
338
+ // sends the fields as null rather than omitting them.
339
+ phase_code: null,
340
+ phase_label: null,
341
+ phase_updated_at: null,
342
+ started_at: new Date().toISOString(),
343
+ completed_at: null,
344
+ };
345
+
346
+ return HttpResponse.json(run, { status: 201 });
347
+ }),
348
+
349
+ // DELETE /api/automation/v1/:id — Delete automation
350
+ http.delete("*/api/automation/v1/:id", async ({ params }) => {
351
+ await delay(200);
352
+
353
+ const id = params.id as string;
354
+ if (!automations.has(id)) {
355
+ return HttpResponse.json(
356
+ { detail: "Automation not found" },
357
+ { status: 404 },
358
+ );
359
+ }
360
+
361
+ automations.delete(id);
362
+ return new HttpResponse(null, { status: 204 });
363
+ }),
364
+ ];
src/mocks/automation-runs.mock.ts ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AutomationRunStatus } from "#/types/automation";
2
+ import type { AutomationRun } from "#/types/automation";
3
+
4
+ const daysAgo = (days: number, hour = 9) => {
5
+ const d = new Date(Date.now() - days * 86_400_000);
6
+ d.setHours(hour, 0, 0, 0);
7
+ return d.toISOString();
8
+ };
9
+
10
+ const minutesAgo = (minutes: number) =>
11
+ new Date(Date.now() - minutes * 60_000).toISOString();
12
+
13
+ /** Omitting this entirely is a case too: an older service sends no phase. */
14
+ interface PhaseFixture {
15
+ code: string | null;
16
+ label: string | null;
17
+ /** Minutes since the phase was last written; defaults to the run's start. */
18
+ ageMinutes?: number;
19
+ }
20
+
21
+ function phaseFields(phase: PhaseFixture | undefined, fallbackAt: string) {
22
+ if (!phase) return {};
23
+ return {
24
+ phase_code: phase.code,
25
+ phase_label: phase.label,
26
+ phase_updated_at:
27
+ phase.ageMinutes === undefined
28
+ ? fallbackAt
29
+ : minutesAgo(phase.ageMinutes),
30
+ };
31
+ }
32
+
33
+ function makeRun(
34
+ id: string,
35
+ status: AutomationRunStatus,
36
+ startedDaysAgo: number,
37
+ hour = 9,
38
+ hasConversation = true,
39
+ phase?: PhaseFixture,
40
+ ): AutomationRun {
41
+ const started = daysAgo(startedDaysAgo, hour);
42
+ return {
43
+ id,
44
+ status,
45
+ conversation_id: hasConversation ? `conv-${id}` : null,
46
+ // Runs that have a conversation also have a bash command; runs that
47
+ // failed before sandbox creation have neither.
48
+ bash_command_id: hasConversation ? `cmd-${id}` : null,
49
+ error_detail:
50
+ status === AutomationRunStatus.FAILED
51
+ ? "Process exited with code 1"
52
+ : null,
53
+ ...phaseFields(phase, started),
54
+ started_at: started,
55
+ completed_at: new Date(new Date(started).getTime() + 120_000).toISOString(),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * A run that has not reached a terminal status, started relative to page load
61
+ * so its elapsed time keeps growing while the dashboard stays open. These are
62
+ * the rows the phase exists for: without one they render as a bare
63
+ * "Running"/"Pending" pill for however long the run takes.
64
+ */
65
+ function makeInFlightRun(
66
+ id: string,
67
+ status: AutomationRunStatus.RUNNING | AutomationRunStatus.PENDING,
68
+ startedMinutesAgo: number,
69
+ hasConversation = true,
70
+ phase?: PhaseFixture,
71
+ ): AutomationRun {
72
+ const startedAt = minutesAgo(startedMinutesAgo);
73
+ return {
74
+ id,
75
+ status,
76
+ conversation_id: hasConversation ? `conv-${id}` : null,
77
+ bash_command_id: hasConversation ? `cmd-${id}` : null,
78
+ error_detail: null,
79
+ ...phaseFields(phase, startedAt),
80
+ started_at: startedAt,
81
+ completed_at: null,
82
+ };
83
+ }
84
+
85
+ // Newest run first: the cards and home rows read runs[0] as the latest, so
86
+ // the in-flight rows below are the ones they show. Only three automations get
87
+ // one: `latestRun` also drives health, "Last run" and the status badge, so a
88
+ // never-terminal fixture pins all three to "running".
89
+ export const MOCK_AUTOMATION_RUNS: Record<string, AutomationRun[]> = {
90
+ "a1000000-0000-0000-0000-000000000001": [
91
+ makeRun("r1-01", AutomationRunStatus.COMPLETED, 0),
92
+ makeRun("r1-02", AutomationRunStatus.COMPLETED, 1),
93
+ // Failed, keeping the phase it stopped at: died while the sandbox came up.
94
+ makeRun("r1-03", AutomationRunStatus.FAILED, 2, 9, true, {
95
+ code: "sandbox_provisioning",
96
+ label: null,
97
+ }),
98
+ // A phase on record that no screen shows, but the export still carries.
99
+ makeRun("r1-04", AutomationRunStatus.COMPLETED, 3, 9, true, {
100
+ code: "running_agent",
101
+ label: null,
102
+ }),
103
+ makeRun("r1-05", AutomationRunStatus.COMPLETED, 4),
104
+ makeRun("r1-06", AutomationRunStatus.COMPLETED, 7),
105
+ makeRun("r1-07", AutomationRunStatus.FAILED, 8),
106
+ makeRun("r1-08", AutomationRunStatus.COMPLETED, 9),
107
+ makeRun("r1-09", AutomationRunStatus.COMPLETED, 10),
108
+ makeRun("r1-10", AutomationRunStatus.COMPLETED, 11),
109
+ ],
110
+ "a1000000-0000-0000-0000-000000000002": [
111
+ // Queued: no sandbox or conversation yet, and only the phase says so.
112
+ makeInFlightRun("r2-00", AutomationRunStatus.PENDING, 2, false, {
113
+ code: "queued",
114
+ label: null,
115
+ }),
116
+ makeRun("r2-01", AutomationRunStatus.COMPLETED, 0, 1),
117
+ makeRun("r2-02", AutomationRunStatus.COMPLETED, 1, 1),
118
+ makeRun("r2-03", AutomationRunStatus.COMPLETED, 2, 1),
119
+ makeRun("r2-04", AutomationRunStatus.FAILED, 3, 1),
120
+ makeRun("r2-05", AutomationRunStatus.COMPLETED, 4, 1),
121
+ ],
122
+ "a1000000-0000-0000-0000-000000000003": [
123
+ // The case the issue is about: 52 minutes into a job that usually takes
124
+ // two, stuck on one step for 41 of them. The code is the automation's
125
+ // own, so its label is shown verbatim — and is long enough to be clipped.
126
+ makeInFlightRun("r3-00", AutomationRunStatus.RUNNING, 52, true, {
127
+ code: "diffing_docs_tree",
128
+ label: "Diffing 340 changed files against main",
129
+ ageMinutes: 41,
130
+ }),
131
+ makeRun("r3-01", AutomationRunStatus.COMPLETED, 1),
132
+ // Terminal statuses the backend emits besides COMPLETED/FAILED.
133
+ makeRun("r3-02", AutomationRunStatus.CANCELLED, 2),
134
+ makeRun("r3-03", AutomationRunStatus.SKIPPED, 3, 9, false),
135
+ ],
136
+ "a1000000-0000-0000-0000-000000000004": [
137
+ makeRun("r4-01", AutomationRunStatus.FAILED, 14, 11, false), // Failed before sandbox creation
138
+ makeRun("r4-02", AutomationRunStatus.COMPLETED, 21, 11),
139
+ ],
140
+ "a1000000-0000-0000-0000-000000000005": [],
141
+ "a1000000-0000-0000-0000-000000000006": [
142
+ // A label with no code: the service accepts that, so the UI must show it.
143
+ makeInFlightRun("r6-00", AutomationRunStatus.RUNNING, 13, true, {
144
+ code: null,
145
+ label: "Running QA checks on PR #4821",
146
+ ageMinutes: 4,
147
+ }),
148
+ // A code with no label: also accepted, and shown as the raw code.
149
+ makeInFlightRun("r6-06", AutomationRunStatus.RUNNING, 6, true, {
150
+ code: "checking_out",
151
+ label: null,
152
+ ageMinutes: 2,
153
+ }),
154
+ // No phase fields at all — an older service: a status pill, no empty
155
+ // slot. Below the head, so it costs no card its health.
156
+ makeInFlightRun("r6-07", AutomationRunStatus.RUNNING, 21),
157
+ makeRun("r6-01", AutomationRunStatus.COMPLETED, 0, 14),
158
+ makeRun("r6-02", AutomationRunStatus.COMPLETED, 0, 11),
159
+ makeRun("r6-03", AutomationRunStatus.FAILED, 1, 16),
160
+ makeRun("r6-04", AutomationRunStatus.COMPLETED, 2, 10),
161
+ makeRun("r6-05", AutomationRunStatus.COMPLETED, 3, 9),
162
+ ],
163
+ "a1000000-0000-0000-0000-000000000007": [
164
+ makeRun("r7-01", AutomationRunStatus.COMPLETED, 3, 15),
165
+ makeRun("r7-02", AutomationRunStatus.COMPLETED, 10, 12),
166
+ ],
167
+ };
src/mocks/automations.mock.ts ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AutomationsResponse } from "#/types/automation";
2
+
3
+ /**
4
+ * Mock automations data matching the AutomationResponse schema from the backend.
5
+ *
6
+ * Backend schema fields: id, user_id, org_id, name, trigger (JSONB),
7
+ * tarball_path, setup_script_path, entrypoint, timeout, enabled,
8
+ * last_triggered_at, created_at, updated_at.
9
+ *
10
+ * The frontend Automation type only uses a subset of these fields.
11
+ * Additional backend fields are included here for API fidelity.
12
+ */
13
+
14
+ const now = new Date().toISOString();
15
+ const daysAgo = (days: number) =>
16
+ new Date(Date.now() - days * 86_400_000).toISOString();
17
+
18
+ export const MOCK_AUTOMATIONS_RESPONSE: AutomationsResponse = {
19
+ automations: [
20
+ {
21
+ id: "a1000000-0000-0000-0000-000000000001",
22
+ name: "PR Triage Digest",
23
+ trigger: {
24
+ type: "cron",
25
+ schedule: "0 9 * * 1-5",
26
+ schedule_human: "Weekdays at 09:00",
27
+ },
28
+ enabled: true,
29
+ repository: "acme/frontend-app",
30
+ model: "triage-fast",
31
+ timeout: 600,
32
+ created_at: daysAgo(90),
33
+ updated_at: now,
34
+ prompt:
35
+ "Review newly opened pull requests in acme/frontend-app, identify risky changes, summarize likely impact, and prepare a concise digest with priority ordering for the engineering review channel.",
36
+ branch: "main",
37
+ plugins: ["GitHub", "Slack", "Linear"],
38
+ notification: "Slack digest to #eng-reviews",
39
+ timezone: "America/Los_Angeles",
40
+ last_triggered_at: daysAgo(0),
41
+ },
42
+ {
43
+ id: "a1000000-0000-0000-0000-000000000002",
44
+ name: "Nightly Security Pass",
45
+ trigger: {
46
+ type: "cron",
47
+ schedule: "30 1 * * *",
48
+ schedule_human: "Daily at 01:30",
49
+ },
50
+ enabled: true,
51
+ repository: "acme/backend-api",
52
+ model: "security-careful",
53
+ timeout: 900,
54
+ created_at: daysAgo(60),
55
+ updated_at: now,
56
+ prompt:
57
+ "Scan the acme/backend-api repository for known security vulnerabilities, outdated dependencies, and insecure code patterns. Produce a prioritized remediation summary.",
58
+ branch: "main",
59
+ plugins: ["GitHub"],
60
+ notification: "Email to security-team@acme.com",
61
+ timezone: "UTC",
62
+ last_triggered_at: daysAgo(0),
63
+ },
64
+ {
65
+ id: "a1000000-0000-0000-0000-000000000003",
66
+ name: "Docs Sync on Push",
67
+ trigger: {
68
+ type: "cron",
69
+ schedule: "*/5 * * * *",
70
+ schedule_human: "Runs on every push",
71
+ },
72
+ enabled: true,
73
+ repository: "acme/docs",
74
+ model: "docs-fast",
75
+ created_at: daysAgo(45),
76
+ updated_at: now,
77
+ prompt:
78
+ "Monitor acme/docs for new pushes. For each push, generate a changelog-ready summary of what changed and why.",
79
+ branch: "main",
80
+ plugins: ["GitHub", "Slack"],
81
+ notification: "Slack to #docs-updates",
82
+ timezone: "America/New_York",
83
+ last_triggered_at: daysAgo(1),
84
+ },
85
+ {
86
+ id: "a1000000-0000-0000-0000-000000000004",
87
+ name: "Release Readiness Review",
88
+ trigger: {
89
+ type: "cron",
90
+ schedule: "0 11 * * 5",
91
+ schedule_human: "Fridays at 11:00",
92
+ },
93
+ enabled: false,
94
+ repository: "acme/realtime-service",
95
+ model: "release-review",
96
+ created_at: daysAgo(80),
97
+ updated_at: now,
98
+ prompt:
99
+ "Compile a release readiness report: list open blockers, active incidents, and pending approvals for acme/realtime-service.",
100
+ branch: "release",
101
+ plugins: ["GitHub", "Linear"],
102
+ notification: "Slack to #releases",
103
+ timezone: "America/Chicago",
104
+ last_triggered_at: daysAgo(14),
105
+ },
106
+ {
107
+ id: "a1000000-0000-0000-0000-000000000005",
108
+ name: "Incident Webhook Summary",
109
+ trigger: {
110
+ type: "cron",
111
+ schedule: "0 */2 * * *",
112
+ schedule_human: "On incident webhook",
113
+ },
114
+ enabled: false,
115
+ repository: "acme/incident-service",
116
+ model: "incident-summary",
117
+ created_at: daysAgo(30),
118
+ updated_at: now,
119
+ prompt:
120
+ "Summarize incoming incident webhooks, categorize by severity, and post a digest to the on-call Slack channel.",
121
+ branch: "main",
122
+ plugins: ["Slack"],
123
+ notification: "Slack to #oncall",
124
+ timezone: "UTC",
125
+ last_triggered_at: null,
126
+ },
127
+ {
128
+ id: "a1000000-0000-0000-0000-000000000006",
129
+ name: "PR Review on Open",
130
+ trigger: {
131
+ type: "event",
132
+ source: "github",
133
+ on: "pull_request.opened",
134
+ filter: "repository.full_name == 'acme/frontend-app'",
135
+ },
136
+ enabled: true,
137
+ repository: "acme/frontend-app",
138
+ model: "review-fast",
139
+ timeout: 1800,
140
+ created_at: daysAgo(15),
141
+ updated_at: now,
142
+ prompt:
143
+ "When a new PR is opened, perform a thorough code review focusing on correctness, security, and performance. Post findings as inline comments.",
144
+ branch: "main",
145
+ plugins: ["GitHub"],
146
+ notification: "GitHub PR comment",
147
+ last_triggered_at: daysAgo(0),
148
+ // Template provenance, as the setup flow stores it. The other mock
149
+ // automations stay without one to exercise the no-statement path.
150
+ preset_metadata: {
151
+ template: { id: "github-pr-reviewer", version: "1.0.0", config: {} },
152
+ },
153
+ },
154
+ {
155
+ id: "a1000000-0000-0000-0000-000000000007",
156
+ name: "Release Notes Generator",
157
+ trigger: {
158
+ type: "event",
159
+ source: "github",
160
+ on: "release.published",
161
+ filter: "glob(release.tag_name, 'v*') && !release.prerelease",
162
+ },
163
+ enabled: true,
164
+ repository: "acme/backend-api",
165
+ model: "docs-fast",
166
+ created_at: daysAgo(10),
167
+ updated_at: now,
168
+ prompt:
169
+ "Generate comprehensive release notes from the commits since the last release. Include breaking changes, new features, and bug fixes.",
170
+ branch: "main",
171
+ plugins: ["GitHub"],
172
+ notification: "GitHub release body update",
173
+ last_triggered_at: daysAgo(3),
174
+ },
175
+ ],
176
+ total: 7,
177
+ };
src/mocks/browser.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import { setupWorker } from "msw/browser";
2
+ import { handlers } from "./handlers";
3
+
4
+ export const worker = setupWorker(...handlers);
src/mocks/canvas-extensions-handlers.test.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* eslint-disable local/no-direct-agent-server-fetch --
2
+ These fetches deliberately bypass the typed client to exercise the MSW
3
+ network-interception layer that dev:mock relies on. */
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ CANVAS_EXTENSION_DEMO_SOURCE,
7
+ resetCanvasExtensionsMockData,
8
+ } from "#/mocks/canvas-extensions-handlers";
9
+
10
+ afterEach(() => {
11
+ resetCanvasExtensionsMockData();
12
+ });
13
+
14
+ async function installDemo(): Promise<Response> {
15
+ return fetch("http://127.0.0.1:8000/api/canvas-extensions/install", {
16
+ method: "POST",
17
+ headers: { "Content-Type": "application/json" },
18
+ body: JSON.stringify({ source: CANVAS_EXTENSION_DEMO_SOURCE }),
19
+ });
20
+ }
21
+
22
+ describe("Canvas Extension MSW handlers", () => {
23
+ it("installs the fixture disabled and then enables it explicitly", async () => {
24
+ const installResponse = await installDemo();
25
+ expect(installResponse.status).toBe(200);
26
+ await expect(installResponse.json()).resolves.toMatchObject({
27
+ name: "demo-page",
28
+ enabled: false,
29
+ source: CANVAS_EXTENSION_DEMO_SOURCE,
30
+ });
31
+
32
+ const enableResponse = await fetch(
33
+ "http://127.0.0.1:8000/api/canvas-extensions/installed/demo-page",
34
+ {
35
+ method: "PATCH",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify({ enabled: true }),
38
+ },
39
+ );
40
+ await expect(enableResponse.json()).resolves.toEqual({
41
+ name: "demo-page",
42
+ enabled: true,
43
+ });
44
+
45
+ const listResponse = await fetch(
46
+ "http://127.0.0.1:8000/api/canvas-extensions/installed",
47
+ );
48
+ await expect(listResponse.json()).resolves.toMatchObject({
49
+ canvas_extensions: [{ name: "demo-page", enabled: true }],
50
+ });
51
+ });
52
+
53
+ it("serves the fixture as JavaScript text", async () => {
54
+ await installDemo();
55
+
56
+ const response = await fetch(
57
+ "http://127.0.0.1:8000/api/canvas-extensions/installed/demo-page/bundle",
58
+ );
59
+
60
+ expect(response.headers.get("content-type")).toContain(
61
+ "application/javascript",
62
+ );
63
+ await expect(response.text()).resolves.toContain(
64
+ "export function activate",
65
+ );
66
+ });
67
+
68
+ it("rejects arbitrary sources in mock mode", async () => {
69
+ const response = await fetch(
70
+ "http://127.0.0.1:8000/api/canvas-extensions/install",
71
+ {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({ source: "github:example/unknown" }),
75
+ },
76
+ );
77
+
78
+ expect(response.status).toBe(400);
79
+ await expect(response.json()).resolves.toEqual({
80
+ detail: `Mock mode only installs '${CANVAS_EXTENSION_DEMO_SOURCE}'.`,
81
+ });
82
+ });
83
+ });
src/mocks/canvas-extensions-handlers.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from "msw";
2
+ import type {
3
+ CanvasExtensionManifest,
4
+ InstallCanvasExtensionRequest,
5
+ InstalledCanvasExtensionInfo,
6
+ } from "#/types/canvas-extension";
7
+ import demoManifestSource from "#/fixtures/canvas-extensions/demo-page/canvas-extension.json?raw";
8
+ // eslint-disable-next-line import-x/extensions -- Vite requires the real filename before ?raw.
9
+ import demoBundle from "#/fixtures/canvas-extensions/demo-page/extension.js?raw";
10
+
11
+ export const CANVAS_EXTENSION_DEMO_SOURCE =
12
+ "src/fixtures/canvas-extensions/demo-page";
13
+
14
+ const demoManifest = JSON.parse(demoManifestSource) as CanvasExtensionManifest;
15
+
16
+ const MOCK_STORAGE_KEY = "openhands-canvas-extension-dev-installation";
17
+
18
+ function readPersistedExtension(): InstalledCanvasExtensionInfo | null {
19
+ try {
20
+ const value = globalThis.sessionStorage?.getItem(MOCK_STORAGE_KEY);
21
+ return value ? (JSON.parse(value) as InstalledCanvasExtensionInfo) : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ let installedExtension = readPersistedExtension();
28
+
29
+ function persistInstalledExtension(
30
+ extension: InstalledCanvasExtensionInfo | null,
31
+ ): void {
32
+ installedExtension = extension;
33
+ try {
34
+ if (extension) {
35
+ globalThis.sessionStorage?.setItem(
36
+ MOCK_STORAGE_KEY,
37
+ JSON.stringify(extension),
38
+ );
39
+ } else {
40
+ globalThis.sessionStorage?.removeItem(MOCK_STORAGE_KEY);
41
+ }
42
+ } catch {
43
+ // The module variable still provides an in-memory fallback.
44
+ }
45
+ }
46
+
47
+ function isDemoSource(source: string): boolean {
48
+ return source.replaceAll("\\", "/").endsWith(CANVAS_EXTENSION_DEMO_SOURCE);
49
+ }
50
+
51
+ function getRequestedName(
52
+ value: string | readonly string[] | undefined,
53
+ ): string {
54
+ if (!value) return "";
55
+ return decodeURIComponent(Array.isArray(value) ? value[0] : value);
56
+ }
57
+
58
+ function extensionNotFound(name: string) {
59
+ return HttpResponse.json(
60
+ { detail: `Canvas extension '${name}' is not installed.` },
61
+ { status: 404 },
62
+ );
63
+ }
64
+
65
+ function getInstalledExtension(
66
+ name: string,
67
+ ): InstalledCanvasExtensionInfo | null {
68
+ if (installedExtension?.name !== name) return null;
69
+ return installedExtension;
70
+ }
71
+
72
+ export function resetCanvasExtensionsMockData(): void {
73
+ persistInstalledExtension(null);
74
+ }
75
+
76
+ /**
77
+ * Browser-development implementation of the Canvas Extension API contract.
78
+ * It intentionally supports only the checked-in demo fixture. Production and
79
+ * ordinary dev stacks continue to use the active Agent Server exclusively.
80
+ */
81
+ export const CANVAS_EXTENSIONS_HANDLERS = [
82
+ http.get("*/api/canvas-extensions/installed", () =>
83
+ HttpResponse.json({
84
+ canvas_extensions: installedExtension ? [installedExtension] : [],
85
+ }),
86
+ ),
87
+
88
+ http.post("*/api/canvas-extensions/install", async ({ request }) => {
89
+ const body = (await request.json()) as InstallCanvasExtensionRequest;
90
+ if (!body.source || !isDemoSource(body.source)) {
91
+ return HttpResponse.json(
92
+ {
93
+ detail: `Mock mode only installs '${CANVAS_EXTENSION_DEMO_SOURCE}'.`,
94
+ },
95
+ { status: 400 },
96
+ );
97
+ }
98
+
99
+ if (installedExtension && !body.force) {
100
+ return HttpResponse.json(
101
+ {
102
+ detail: `Canvas extension '${demoManifest.name}' is already installed.`,
103
+ },
104
+ { status: 409 },
105
+ );
106
+ }
107
+
108
+ persistInstalledExtension({
109
+ name: demoManifest.name,
110
+ version: demoManifest.version,
111
+ description: demoManifest.description,
112
+ enabled: false,
113
+ source: body.source,
114
+ resolved_ref: body.ref || "mock-working-tree",
115
+ repo_path: body.repo_path || null,
116
+ installed_at: new Date().toISOString(),
117
+ install_path: `/mock/canvas-extensions/${demoManifest.name}`,
118
+ // The real backend only returns `manifest` once OSS-10048 lands; the
119
+ // mock previews that shape so the demo fixture's pages work in dev mode.
120
+ manifest: demoManifest,
121
+ });
122
+
123
+ return HttpResponse.json(installedExtension);
124
+ }),
125
+
126
+ http.get("*/api/canvas-extensions/installed/:name/bundle", ({ params }) => {
127
+ const name = getRequestedName(params.name);
128
+ if (!getInstalledExtension(name)) return extensionNotFound(name);
129
+ return new HttpResponse(demoBundle, {
130
+ headers: {
131
+ "Content-Type": "application/javascript; charset=utf-8",
132
+ "Cache-Control": "no-cache",
133
+ },
134
+ });
135
+ }),
136
+
137
+ http.get("*/api/canvas-extensions/installed/:name", ({ params }) => {
138
+ const name = getRequestedName(params.name);
139
+ const extension = getInstalledExtension(name);
140
+ return extension ? HttpResponse.json(extension) : extensionNotFound(name);
141
+ }),
142
+
143
+ http.patch(
144
+ "*/api/canvas-extensions/installed/:name",
145
+ async ({ params, request }) => {
146
+ const name = getRequestedName(params.name);
147
+ const extension = getInstalledExtension(name);
148
+ if (!extension) return extensionNotFound(name);
149
+
150
+ const body = (await request.json()) as { enabled?: unknown };
151
+ if (typeof body.enabled !== "boolean") {
152
+ return HttpResponse.json(
153
+ { detail: "'enabled' must be a boolean." },
154
+ { status: 422 },
155
+ );
156
+ }
157
+
158
+ persistInstalledExtension({ ...extension, enabled: body.enabled });
159
+ return HttpResponse.json({ name, enabled: body.enabled });
160
+ },
161
+ ),
162
+
163
+ http.delete("*/api/canvas-extensions/installed/:name", ({ params }) => {
164
+ const name = getRequestedName(params.name);
165
+ if (!getInstalledExtension(name)) return extensionNotFound(name);
166
+ persistInstalledExtension(null);
167
+ return HttpResponse.json({ message: "Canvas extension uninstalled." });
168
+ }),
169
+ ];
src/mocks/conversation-handlers.ts ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, delay, HttpResponse, passthrough } from "msw";
2
+ import type { DirectConversationInfo } from "#/api/agent-server-adapter";
3
+ import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
4
+ import {
5
+ ExecutionStatus,
6
+ type OpenHandsEvent,
7
+ } from "#/types/agent-server/core";
8
+ import {
9
+ TABLE_DEMO_CONVERSATION_ID,
10
+ TABLE_DEMO_EVENTS,
11
+ } from "#/fixtures/table-demo-conversation";
12
+ import {
13
+ CANVAS_DEMO_CONVERSATION_ID,
14
+ CANVAS_DEMO_EVENTS,
15
+ CANVAS_DEMO_FILE_PATH,
16
+ CANVAS_DEMO_MARKDOWN,
17
+ } from "#/fixtures/canvas-demo-conversation";
18
+
19
+ /** Map from conversation id → events returned by GET /events/search */
20
+ const CONVERSATION_EVENTS: Record<string, unknown[]> = {
21
+ [TABLE_DEMO_CONVERSATION_ID]: TABLE_DEMO_EVENTS,
22
+ [CANVAS_DEMO_CONVERSATION_ID]: CANVAS_DEMO_EVENTS,
23
+ };
24
+
25
+ const now = Date.now();
26
+ const PAGINATION_LOCAL_CONVERSATION_ID = "pagination-local";
27
+ const PAGINATION_CLOUD_CONVERSATION_ID = "pagination-cloud";
28
+ const PAGINATION_EVENT_COUNT = 100;
29
+ const PAGINATION_PAGE_DELAY_MS = 500;
30
+ const PAGINATION_BASE_TIME = Date.UTC(2026, 4, 13, 0, 0, 0);
31
+
32
+ type MockConversation = DirectConversationInfo & {
33
+ selected_repository?: string | null;
34
+ selected_branch?: string | null;
35
+ git_provider?: string | null;
36
+ };
37
+
38
+ type CloudProxyEnvelope = {
39
+ method?: string;
40
+ path?: string;
41
+ };
42
+
43
+ const conversations: MockConversation[] = [
44
+ {
45
+ id: "1",
46
+ title: "My New Project",
47
+ created_at: new Date(now).toISOString(),
48
+ updated_at: new Date(now).toISOString(),
49
+ execution_status: "waiting_for_confirmation",
50
+ // User-authored tags so the layouts menu's Tag Filters section and the
51
+ // card chips have something to show in mock mode.
52
+ tags: { project: "vault", work: "" },
53
+ },
54
+ {
55
+ id: "2",
56
+ title: "Repo Testing",
57
+ created_at: new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString(),
58
+ updated_at: new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString(),
59
+ execution_status: "idle",
60
+ selected_repository: "octocat/hello-world",
61
+ git_provider: "github",
62
+ },
63
+ {
64
+ id: "3",
65
+ title: "Another Project",
66
+ created_at: new Date(now - 5 * 24 * 60 * 60 * 1000).toISOString(),
67
+ updated_at: new Date(now - 5 * 24 * 60 * 60 * 1000).toISOString(),
68
+ execution_status: "idle",
69
+ selected_repository: "octocat/earth",
70
+ selected_branch: "main",
71
+ },
72
+ // Conversation whose sandbox has been removed (MISSING). The conversation
73
+ // history is still readable but the sandbox cannot be resumed — the chat
74
+ // input is replaced with a read-only archived banner.
75
+ {
76
+ id: "4",
77
+ title: "Archived Project",
78
+ created_at: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(),
79
+ updated_at: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(),
80
+ execution_status: "idle",
81
+ sandbox_status: "MISSING",
82
+ },
83
+ // Conversation whose sandbox encountered an unrecoverable error. Same
84
+ // read-only treatment but with the "Sandbox error" variant of the banner.
85
+ {
86
+ id: "5",
87
+ title: "Errored Project",
88
+ created_at: new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(),
89
+ updated_at: new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(),
90
+ execution_status: "idle",
91
+ sandbox_status: "ERROR",
92
+ },
93
+ {
94
+ id: PAGINATION_LOCAL_CONVERSATION_ID,
95
+ title: "Local pagination fixture",
96
+ created_at: new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString(),
97
+ updated_at: new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString(),
98
+ execution_status: "idle",
99
+ workspace: { working_dir: "/workspace/project" },
100
+ },
101
+ {
102
+ id: TABLE_DEMO_CONVERSATION_ID,
103
+ title: "Wide table demo",
104
+ created_at: new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString(),
105
+ updated_at: new Date(now - 1 * 24 * 60 * 60 * 1000).toISOString(),
106
+ execution_status: "idle",
107
+ workspace: { working_dir: "/workspace/project" },
108
+ },
109
+ {
110
+ id: CANVAS_DEMO_CONVERSATION_ID,
111
+ title: "Generated canvas demo",
112
+ created_at: new Date(now - 12 * 60 * 60 * 1000).toISOString(),
113
+ updated_at: new Date(now - 12 * 60 * 60 * 1000).toISOString(),
114
+ execution_status: "idle",
115
+ workspace: { working_dir: "/workspace/project" },
116
+ },
117
+ ];
118
+
119
+ const CONVERSATIONS = new Map<string, MockConversation>(
120
+ conversations.map((conversation) => [conversation.id, conversation]),
121
+ );
122
+
123
+ const paginationEventsByConversation = new Map<string, OpenHandsEvent[]>([
124
+ [
125
+ PAGINATION_LOCAL_CONVERSATION_ID,
126
+ createPaginationEvents("Local pagination message"),
127
+ ],
128
+ [
129
+ PAGINATION_CLOUD_CONVERSATION_ID,
130
+ createPaginationEvents("Cloud pagination message"),
131
+ ],
132
+ ]);
133
+
134
+ function createPaginationEvent(
135
+ index: number,
136
+ messagePrefix: string,
137
+ ): OpenHandsEvent {
138
+ return {
139
+ id: `${messagePrefix.toLowerCase().replaceAll(" ", "-")}-${index}`,
140
+ timestamp: new Date(PAGINATION_BASE_TIME + index * 60_000).toISOString(),
141
+ source: "agent",
142
+ llm_message: {
143
+ role: "assistant",
144
+ content: [{ type: "text", text: `${messagePrefix} ${index}` }],
145
+ },
146
+ activated_skills: [],
147
+ extended_content: [],
148
+ };
149
+ }
150
+
151
+ function createPaginationEvents(messagePrefix: string): OpenHandsEvent[] {
152
+ return Array.from({ length: PAGINATION_EVENT_COUNT }, (_, index) =>
153
+ createPaginationEvent(index + 1, messagePrefix),
154
+ );
155
+ }
156
+
157
+ function searchPaginationEvents(
158
+ events: OpenHandsEvent[],
159
+ searchParams: URLSearchParams,
160
+ ) {
161
+ const limit = Number(searchParams.get("limit") ?? "100");
162
+ const timestampLt = searchParams.get("timestamp__lt");
163
+ const sortOrder = searchParams.get("sort_order");
164
+ const filtered = timestampLt
165
+ ? events.filter((event) => (event.timestamp ?? "") < timestampLt)
166
+ : events;
167
+ const sorted = [...filtered].sort((a, b) =>
168
+ sortOrder === "TIMESTAMP_DESC"
169
+ ? (b.timestamp ?? "").localeCompare(a.timestamp ?? "")
170
+ : (a.timestamp ?? "").localeCompare(b.timestamp ?? ""),
171
+ );
172
+
173
+ return {
174
+ items: sorted.slice(0, limit),
175
+ next_page_id: sorted.length > limit ? "next-page" : null,
176
+ };
177
+ }
178
+
179
+ async function maybeReturnPaginationEvents(
180
+ conversationId: string,
181
+ searchParams: URLSearchParams,
182
+ ) {
183
+ const events = paginationEventsByConversation.get(conversationId);
184
+ if (!events) return null;
185
+ if (searchParams.has("timestamp__lt")) {
186
+ await delay(PAGINATION_PAGE_DELAY_MS);
187
+ }
188
+ return searchPaginationEvents(events, searchParams);
189
+ }
190
+
191
+ function createCloudPaginationConversation(): AppConversation {
192
+ const createdAt = new Date(now - 6 * 24 * 60 * 60 * 1000).toISOString();
193
+ const updatedAt = createdAt;
194
+
195
+ return {
196
+ id: PAGINATION_CLOUD_CONVERSATION_ID,
197
+ created_by_user_id: null,
198
+ selected_repository: null,
199
+ selected_branch: null,
200
+ git_provider: null,
201
+ title: "Cloud pagination fixture",
202
+ trigger: null,
203
+ pr_number: [],
204
+ llm_model: "openhands/claude-haiku-4-5-20251001",
205
+ metrics: null,
206
+ created_at: createdAt,
207
+ updated_at: updatedAt,
208
+ execution_status: ExecutionStatus.IDLE,
209
+ conversation_url: null,
210
+ session_api_key: null,
211
+ sandbox_id: null,
212
+ workspace: { working_dir: "/workspace/project" },
213
+ public: false,
214
+ sub_conversation_ids: [],
215
+ };
216
+ }
217
+
218
+ const CLOUD_PAGINATION_CONVERSATION = createCloudPaginationConversation();
219
+
220
+ function createConversationResponse(
221
+ conversation: MockConversation,
222
+ ): DirectConversationInfo {
223
+ return {
224
+ id: conversation.id,
225
+ title: conversation.title ?? null,
226
+ created_at: conversation.created_at,
227
+ updated_at: conversation.updated_at,
228
+ execution_status: conversation.execution_status ?? "idle",
229
+ sandbox_status: conversation.sandbox_status ?? null,
230
+ metrics: conversation.metrics ?? null,
231
+ agent: conversation.agent ?? null,
232
+ workspace: conversation.workspace ?? null,
233
+ tags: conversation.tags ?? null,
234
+ };
235
+ }
236
+
237
+ function listConversationResponses(ids?: string[] | null) {
238
+ if (!ids || ids.length === 0) {
239
+ return Array.from(CONVERSATIONS.values()).map(createConversationResponse);
240
+ }
241
+
242
+ return ids.map((id) => {
243
+ const conversation = CONVERSATIONS.get(id);
244
+ return conversation ? createConversationResponse(conversation) : null;
245
+ });
246
+ }
247
+
248
+ export const CONVERSATION_HANDLERS = [
249
+ http.get("*/api/conversations/search", async ({ request }) => {
250
+ const url = new URL(request.url);
251
+ const limit = Number(url.searchParams.get("limit") ?? "20");
252
+ const items = Array.from(CONVERSATIONS.values())
253
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at))
254
+ .slice(0, limit)
255
+ .map(createConversationResponse);
256
+
257
+ return HttpResponse.json({ items, next_page_id: null });
258
+ }),
259
+
260
+ http.get("*/api/conversations", async ({ request }) => {
261
+ const url = new URL(request.url);
262
+ // Axios serializes arrays as `ids[]=a&ids[]=b` (bracket notation).
263
+ // Fall back to plain `ids` to support both formats.
264
+ const ids =
265
+ url.searchParams.getAll("ids[]").length > 0
266
+ ? url.searchParams.getAll("ids[]")
267
+ : url.searchParams.getAll("ids");
268
+ return HttpResponse.json(listConversationResponses(ids));
269
+ }),
270
+
271
+ http.get("*/api/conversations/:conversationId", async ({ params }) => {
272
+ const conversationId = params.conversationId as string;
273
+ const conversation = CONVERSATIONS.get(conversationId);
274
+ if (conversation) {
275
+ return HttpResponse.json(createConversationResponse(conversation));
276
+ }
277
+ return HttpResponse.json(null, { status: 404 });
278
+ }),
279
+
280
+ http.post("*/api/conversations", async () => {
281
+ await delay();
282
+ const conversation: MockConversation = {
283
+ id: `${Math.floor(Math.random() * 100000)}`,
284
+ title: "New Conversation",
285
+ created_at: new Date().toISOString(),
286
+ updated_at: new Date().toISOString(),
287
+ execution_status: "idle",
288
+ };
289
+ CONVERSATIONS.set(conversation.id, conversation);
290
+ return HttpResponse.json(createConversationResponse(conversation), {
291
+ status: 201,
292
+ });
293
+ }),
294
+
295
+ http.patch(
296
+ "/api/conversations/:conversationId",
297
+ async ({ params, request }) => {
298
+ const conversationId = params.conversationId as string;
299
+ const conversation = CONVERSATIONS.get(conversationId);
300
+
301
+ if (conversation) {
302
+ const body = (await request.json()) as {
303
+ title?: string;
304
+ tags?: Record<string, string>;
305
+ } | null;
306
+ // PATCH replaces the complete tags map (agent-server semantics);
307
+ // title-only and tags-only patches both persist.
308
+ if (body?.title || body?.tags) {
309
+ CONVERSATIONS.set(conversationId, {
310
+ ...conversation,
311
+ ...(body.title ? { title: body.title } : {}),
312
+ ...(body.tags ? { tags: body.tags } : {}),
313
+ updated_at: new Date().toISOString(),
314
+ });
315
+ return HttpResponse.json(null, { status: 200 });
316
+ }
317
+ }
318
+ return HttpResponse.json(null, { status: 404 });
319
+ },
320
+ ),
321
+
322
+ http.delete("*/api/conversations/:conversationId", async ({ params }) => {
323
+ const conversationId = params.conversationId as string;
324
+ if (CONVERSATIONS.has(conversationId)) {
325
+ CONVERSATIONS.delete(conversationId);
326
+ return HttpResponse.json(null, { status: 200 });
327
+ }
328
+ return HttpResponse.json(null, { status: 404 });
329
+ }),
330
+
331
+ http.get("*/api/conversations/:conversationId/events/count", async () =>
332
+ HttpResponse.json(0),
333
+ ),
334
+
335
+ http.get(
336
+ "*/api/conversations/:conversationId/events/search",
337
+ async ({ params, request }) => {
338
+ const conversationId = params.conversationId as string;
339
+ const searchParams = new URL(request.url).searchParams;
340
+ const paginationPage = await maybeReturnPaginationEvents(
341
+ conversationId,
342
+ searchParams,
343
+ );
344
+ if (paginationPage) return HttpResponse.json(paginationPage);
345
+
346
+ const staticEvents = CONVERSATION_EVENTS[conversationId];
347
+ if (staticEvents) {
348
+ return HttpResponse.json(
349
+ searchPaginationEvents(
350
+ staticEvents as OpenHandsEvent[],
351
+ searchParams,
352
+ ),
353
+ );
354
+ }
355
+
356
+ return HttpResponse.json({ items: [], next_page_id: null });
357
+ },
358
+ ),
359
+
360
+ http.post("*/api/conversations/:conversationId/events", async () =>
361
+ HttpResponse.json({ ok: true }),
362
+ ),
363
+
364
+ // The generated-canvas fixture behaves like a real workspace artifact:
365
+ // after the chat chip opens the Files drawer, its static-session URL serves
366
+ // the same Markdown bytes carried by the file-editor observation.
367
+ http.get(
368
+ "*/api/conversations/:conversationId/workspace/*",
369
+ ({ params, request }) => {
370
+ const conversationId = params.conversationId?.toString();
371
+ const url = new URL(request.url);
372
+ if (
373
+ conversationId === CANVAS_DEMO_CONVERSATION_ID &&
374
+ url.pathname.endsWith(`/${CANVAS_DEMO_FILE_PATH}`)
375
+ ) {
376
+ return HttpResponse.text(CANVAS_DEMO_MARKDOWN, {
377
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
378
+ });
379
+ }
380
+ // Leave every other workspace-file GET on its previous bypass behavior
381
+ // so this fixture handler does not force 404s for unrelated mocks.
382
+ return passthrough();
383
+ },
384
+ ),
385
+
386
+ http.post("*/api/conversations/:conversationId/pause", async () =>
387
+ HttpResponse.json({ success: true }),
388
+ ),
389
+
390
+ http.post("*/api/conversations/:conversationId/interrupt", async () =>
391
+ HttpResponse.json({ success: true }),
392
+ ),
393
+
394
+ http.post("*/api/conversations/:conversationId/run", async () =>
395
+ HttpResponse.json({ success: true }),
396
+ ),
397
+
398
+ http.post("*/api/cloud-proxy", async ({ request }) => {
399
+ const envelope = (await request.json()) as CloudProxyEnvelope;
400
+ const upstreamPath = envelope.path ?? "/";
401
+ const upstreamUrl = new URL(upstreamPath, "https://mock-cloud.test");
402
+
403
+ if (upstreamUrl.pathname === "/api/v1/app-conversations") {
404
+ const ids = upstreamUrl.searchParams.getAll("ids");
405
+ if (ids.length > 0) {
406
+ return HttpResponse.json(
407
+ ids.map((id) =>
408
+ id === PAGINATION_CLOUD_CONVERSATION_ID
409
+ ? CLOUD_PAGINATION_CONVERSATION
410
+ : null,
411
+ ),
412
+ );
413
+ }
414
+ }
415
+
416
+ if (upstreamUrl.pathname === "/api/v1/app-conversations/search") {
417
+ return HttpResponse.json({
418
+ items: [CLOUD_PAGINATION_CONVERSATION],
419
+ next_page_id: null,
420
+ });
421
+ }
422
+
423
+ if (
424
+ upstreamUrl.pathname ===
425
+ `/api/v1/conversation/${PAGINATION_CLOUD_CONVERSATION_ID}/events/search`
426
+ ) {
427
+ const paginationPage = await maybeReturnPaginationEvents(
428
+ PAGINATION_CLOUD_CONVERSATION_ID,
429
+ upstreamUrl.searchParams,
430
+ );
431
+ return HttpResponse.json(paginationPage);
432
+ }
433
+
434
+ if (upstreamUrl.pathname === "/api/v1/settings") {
435
+ return HttpResponse.json({
436
+ llm_model: "openhands/claude-haiku-4-5-20251001",
437
+ llm_base_url: "",
438
+ llm_api_key: null,
439
+ llm_api_key_set: false,
440
+ search_api_key_set: false,
441
+ agent: "CodeActAgent",
442
+ language: "en",
443
+ user_consents_to_analytics: false,
444
+ provider_tokens_set: { github: "" },
445
+ });
446
+ }
447
+
448
+ if (upstreamUrl.pathname === "/api/keys/current") {
449
+ return HttpResponse.json({
450
+ id: "mock-key",
451
+ name: "Mock key",
452
+ org_id: "org-1",
453
+ user_id: "user-1",
454
+ auth_type: "api_key",
455
+ });
456
+ }
457
+
458
+ if (upstreamUrl.pathname === "/api/organizations") {
459
+ return HttpResponse.json({
460
+ items: [{ id: "org-1", name: "Mock Org", is_personal: true }],
461
+ current_org_id: "org-1",
462
+ });
463
+ }
464
+
465
+ if (upstreamUrl.pathname === "/api/organizations/org-1/me") {
466
+ return HttpResponse.json({ org_id: "org-1", user_id: "org-1" });
467
+ }
468
+
469
+ if (upstreamUrl.pathname === "/api/authenticate") {
470
+ return HttpResponse.json({ ok: true });
471
+ }
472
+
473
+ return HttpResponse.json({});
474
+ }),
475
+
476
+ http.post("*/api/conversations/:conversationId/ask_agent", async () =>
477
+ HttpResponse.json({ response: "Mock agent response" }),
478
+ ),
479
+
480
+ http.get("*/api/vscode/url", async () => HttpResponse.json({ url: null })),
481
+
482
+ http.post("*/api/skills", async () => HttpResponse.json({ skills: [] })),
483
+
484
+ http.post(
485
+ "/api/v1/conversations/:conversationId/pending-messages",
486
+ async () => HttpResponse.json({ id: "mock-pending-id", position: 0 }),
487
+ ),
488
+ ];
src/mocks/feedback-handlers.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, delay, HttpResponse } from "msw";
2
+
3
+ export const FEEDBACK_HANDLERS = [
4
+ http.post("*/api/submit-feedback", async () => {
5
+ await delay(1200);
6
+ return HttpResponse.json({
7
+ statusCode: 200,
8
+ body: { message: "Success", link: "fake-url.com", password: "abc123" },
9
+ });
10
+ }),
11
+
12
+ http.post("*/api/submit-feedback", async () =>
13
+ HttpResponse.json({ statusCode: 200 }, { status: 200 }),
14
+ ),
15
+ ];
src/mocks/file-service-handlers.ts ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { delay, http, HttpResponse } from "msw";
2
+
3
+ export const FILE_VARIANTS_1 = ["file1.txt", "file2.txt", "file3.txt"];
4
+ export const FILE_VARIANTS_2 = [
5
+ "reboot_skynet.exe",
6
+ "target_list.txt",
7
+ "terminator_blueprint.txt",
8
+ ];
9
+
10
+ const MOCK_FILE_BROWSER_HOME = {
11
+ home: "/home/openhands",
12
+ favorites: [{ label: "Downloads", path: "/home/openhands/Downloads" }],
13
+ locations: [],
14
+ };
15
+
16
+ const MOCK_SUBDIRECTORIES_BY_PATH: Record<
17
+ string,
18
+ { name: string; path: string }[]
19
+ > = {
20
+ "/projects": [
21
+ { name: "demo-app", path: "/projects/demo-app" },
22
+ { name: "sample-tools", path: "/projects/sample-tools" },
23
+ { name: "notes-service", path: "/projects/notes-service" },
24
+ ],
25
+ "/projects/demo-app": [
26
+ {
27
+ name: "web-client",
28
+ path: "/projects/demo-app/web-client",
29
+ },
30
+ {
31
+ name: "api-service",
32
+ path: "/projects/demo-app/api-service",
33
+ },
34
+ ],
35
+ "/projects/demo-app/web-client": [],
36
+ };
37
+
38
+ const shouldReturnProjectMock =
39
+ typeof import.meta.env.MODE === "string" && import.meta.env.MODE !== "test";
40
+
41
+ export const FILE_SERVICE_HANDLERS = [
42
+ http.all("*/api/bash/execute_bash_command", async () =>
43
+ HttpResponse.json({
44
+ command: "",
45
+ exit_code: 0,
46
+ output: "",
47
+ }),
48
+ ),
49
+
50
+ http.get("*/api/file/home", async () =>
51
+ HttpResponse.json(MOCK_FILE_BROWSER_HOME),
52
+ ),
53
+
54
+ http.get("*/api/file/search_subdirs", async ({ request }) => {
55
+ const url = new URL(request.url);
56
+ const path = url.searchParams.get("path") ?? "";
57
+ const items = shouldReturnProjectMock
58
+ ? (MOCK_SUBDIRECTORIES_BY_PATH[path] ?? [])
59
+ : [];
60
+
61
+ return HttpResponse.json({
62
+ path,
63
+ items,
64
+ subdirs: items,
65
+ next_page_id: null,
66
+ });
67
+ }),
68
+
69
+ http.get("*/api/file/:path", async ({ params }) =>
70
+ HttpResponse.json({
71
+ path: `/${params.path?.toString() ?? "home"}`,
72
+ subdirs: [],
73
+ }),
74
+ ),
75
+
76
+ http.get(
77
+ "/api/conversations/:conversationId/list-files",
78
+ async ({ params }) => {
79
+ await delay();
80
+
81
+ const cid = params.conversationId?.toString();
82
+ if (!cid) return HttpResponse.json(null, { status: 400 });
83
+
84
+ return cid === "test-conversation-id-2"
85
+ ? HttpResponse.json(FILE_VARIANTS_2)
86
+ : HttpResponse.json(FILE_VARIANTS_1);
87
+ },
88
+ ),
89
+
90
+ http.get(
91
+ "/api/conversations/:conversationId/select-file",
92
+ async ({ request }) => {
93
+ await delay();
94
+
95
+ const url = new URL(request.url);
96
+ const file = url.searchParams.get("file")?.toString();
97
+ if (file) {
98
+ return HttpResponse.json({ code: `Content of ${file}` });
99
+ }
100
+
101
+ return HttpResponse.json(null, { status: 404 });
102
+ },
103
+ ),
104
+ ];
src/mocks/git-repository-handlers.ts ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { delay, http, HttpResponse } from "msw";
2
+ import { GitRepository, Branch, PaginatedBranchesResponse } from "#/types/git";
3
+ import { Provider } from "#/types/settings";
4
+
5
+ // Generate a list of mock repositories with realistic data
6
+ const generateMockRepositories = (
7
+ count: number,
8
+ provider: Provider,
9
+ ): GitRepository[] =>
10
+ Array.from({ length: count }, (_, i) => ({
11
+ id: `${i + 1}`,
12
+ full_name: `user/repo-${i + 1}`,
13
+ git_provider: provider,
14
+ is_public: Math.random() > 0.3, // 70% chance of being public
15
+ stargazers_count: Math.floor(Math.random() * 1000),
16
+ pushed_at: new Date(
17
+ Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000,
18
+ ).toISOString(), // Last 90 days
19
+ }));
20
+
21
+ // Generate mock branches for a repository
22
+ const generateMockBranches = (count: number): Branch[] =>
23
+ Array.from({ length: count }, (_, i) => ({
24
+ name: (() => {
25
+ if (i === 0) return "main";
26
+ if (i === 1) return "develop";
27
+ return `feature/branch-${i}`;
28
+ })(),
29
+ commit_sha: `abc123${i.toString().padStart(3, "0")}`,
30
+ protected: i === 0, // main branch is protected
31
+ last_push_date: new Date(
32
+ Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000,
33
+ ).toISOString(),
34
+ }));
35
+
36
+ // Mock repositories for each provider
37
+ const MOCK_REPOSITORIES = {
38
+ github: generateMockRepositories(120, "github"),
39
+ gitlab: generateMockRepositories(120, "gitlab"),
40
+ bitbucket: generateMockRepositories(120, "bitbucket"),
41
+ };
42
+
43
+ // Mock branches (same for all repos for simplicity)
44
+ const MOCK_BRANCHES = generateMockBranches(25);
45
+
46
+ // ── Git workspace mock data ────────────────────────────────────────────────
47
+ // Pre-seeded with one modified, one added, and one deleted file so mock-mode
48
+ // conversations exercise the full file-list / diff-editor / deleted-file
49
+ // placeholder UI.
50
+
51
+ // Three representative files: modified, added, deleted.
52
+ // Uses AgentServerGitChangeStatus values ("UPDATED", "ADDED", "DELETED") as
53
+ // returned by the real /api/git/changes endpoint; AgentServerGitService maps
54
+ // them via mapAnyGitStatusToClientStatus before handing them to the UI.
55
+ const MOCK_GIT_CHANGES: Array<{ path: string; status: string }> = [
56
+ { path: "src/components/hello.tsx", status: "UPDATED" },
57
+ { path: "src/utils/new-helper.ts", status: "ADDED" },
58
+ { path: "src/old-module.py", status: "DELETED" },
59
+ ];
60
+
61
+ export const GIT_REPOSITORY_HANDLERS = [
62
+ http.get("*/api/user/repositories", async ({ request }) => {
63
+ await delay(500); // Simulate network delay
64
+
65
+ const url = new URL(request.url);
66
+ const selectedProvider = url.searchParams.get("selected_provider");
67
+ const page = parseInt(url.searchParams.get("page") || "1", 10);
68
+ const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
69
+ const sort = url.searchParams.get("sort") || "pushed";
70
+ const installationId = url.searchParams.get("installation_id");
71
+
72
+ // Simulate authentication error if no provider token
73
+ if (!selectedProvider) {
74
+ return HttpResponse.json(
75
+ "Git provider token required. (such as GitHub).",
76
+ { status: 401 },
77
+ );
78
+ }
79
+
80
+ // Get repositories for the selected provider
81
+ const repositories =
82
+ MOCK_REPOSITORIES[selectedProvider as keyof typeof MOCK_REPOSITORIES] ||
83
+ [];
84
+
85
+ // Sort repositories based on the sort parameter
86
+ let sortedRepos = [...repositories];
87
+ if (sort === "pushed") {
88
+ sortedRepos.sort(
89
+ (a, b) =>
90
+ new Date(b.pushed_at!).getTime() - new Date(a.pushed_at!).getTime(),
91
+ );
92
+ } else if (sort === "stars") {
93
+ sortedRepos.sort(
94
+ (a, b) => (b.stargazers_count || 0) - (a.stargazers_count || 0),
95
+ );
96
+ }
97
+
98
+ // Handle installation filtering (for GitHub Apps)
99
+ if (installationId && selectedProvider === "github") {
100
+ // Simulate filtering by installation - in real API this would filter by access
101
+ const installationIndex = parseInt(installationId, 10) || 0;
102
+ const startRepo = installationIndex * 20; // Each installation has ~20 repos
103
+ sortedRepos = sortedRepos.slice(startRepo, startRepo + 20);
104
+ }
105
+
106
+ // Calculate pagination
107
+ const startIndex = (page - 1) * perPage;
108
+ const endIndex = startIndex + perPage;
109
+ const paginatedRepos = sortedRepos.slice(startIndex, endIndex);
110
+ const hasNextPage = endIndex < sortedRepos.length;
111
+ const hasPrevPage = page > 1;
112
+ const totalPages = Math.ceil(sortedRepos.length / perPage);
113
+
114
+ // Generate GitHub-style link header for pagination
115
+ let linkHeader = "";
116
+ if (hasNextPage || hasPrevPage) {
117
+ const links = [];
118
+ if (hasPrevPage) {
119
+ links.push(
120
+ `</api/user/repositories?page=${page - 1}&per_page=${perPage}>; rel="prev"`,
121
+ );
122
+ }
123
+ if (hasNextPage) {
124
+ links.push(
125
+ `</api/user/repositories?page=${page + 1}&per_page=${perPage}>; rel="next"`,
126
+ );
127
+ }
128
+ links.push(
129
+ `</api/user/repositories?page=${totalPages}&per_page=${perPage}>; rel="last"`,
130
+ );
131
+ links.push(
132
+ `</api/user/repositories?page=1&per_page=${perPage}>; rel="first"`,
133
+ );
134
+ linkHeader = links.join(", ");
135
+ }
136
+
137
+ // Add link_header to the first repository if pagination info exists
138
+ const responseRepos = [...paginatedRepos];
139
+ if (responseRepos.length > 0 && linkHeader) {
140
+ responseRepos[0] = { ...responseRepos[0], link_header: linkHeader };
141
+ }
142
+
143
+ // Return response as direct Repository array (matching real API)
144
+ return HttpResponse.json(responseRepos);
145
+ }),
146
+
147
+ http.get("*/api/user/search/repositories", async ({ request }) => {
148
+ await delay(300); // Simulate network delay
149
+
150
+ const url = new URL(request.url);
151
+ const query = url.searchParams.get("query") || "";
152
+ const selectedProvider = url.searchParams.get("selected_provider");
153
+ const perPage = parseInt(url.searchParams.get("per_page") || "5", 10);
154
+ const sort = url.searchParams.get("sort") || "stars";
155
+ const order = url.searchParams.get("order") || "desc";
156
+
157
+ // Simulate authentication error if no provider token
158
+ if (!selectedProvider) {
159
+ return HttpResponse.json("Git provider token required.", {
160
+ status: 401,
161
+ });
162
+ }
163
+
164
+ // Get repositories for the selected provider
165
+ const repositories =
166
+ MOCK_REPOSITORIES[selectedProvider as keyof typeof MOCK_REPOSITORIES] ||
167
+ [];
168
+
169
+ // Filter repositories by search query
170
+ const filteredRepos = repositories.filter((repo) =>
171
+ repo.full_name.toLowerCase().includes(query.toLowerCase()),
172
+ );
173
+
174
+ // Sort repositories
175
+ const sortedRepos = [...filteredRepos];
176
+ if (sort === "stars") {
177
+ sortedRepos.sort((a, b) => {
178
+ const aStars = a.stargazers_count || 0;
179
+ const bStars = b.stargazers_count || 0;
180
+ return order === "desc" ? bStars - aStars : aStars - bStars;
181
+ });
182
+ }
183
+
184
+ // Limit results
185
+ const limitedRepos = sortedRepos.slice(0, perPage);
186
+
187
+ return HttpResponse.json(limitedRepos);
188
+ }),
189
+
190
+ // Repository branches endpoint
191
+ http.get("*/api/user/repository/branches", async ({ request }) => {
192
+ await delay(300);
193
+
194
+ const url = new URL(request.url);
195
+ const repository = url.searchParams.get("repository");
196
+ const page = parseInt(url.searchParams.get("page") || "1", 10);
197
+ const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
198
+
199
+ if (!repository) {
200
+ return HttpResponse.json("Repository parameter is required", {
201
+ status: 400,
202
+ });
203
+ }
204
+
205
+ // Calculate pagination
206
+ const startIndex = (page - 1) * perPage;
207
+ const endIndex = startIndex + perPage;
208
+ const paginatedBranches = MOCK_BRANCHES.slice(startIndex, endIndex);
209
+ const hasNextPage = endIndex < MOCK_BRANCHES.length;
210
+
211
+ const response: PaginatedBranchesResponse = {
212
+ branches: paginatedBranches,
213
+ has_next_page: hasNextPage,
214
+ current_page: page,
215
+ per_page: perPage,
216
+ total_count: MOCK_BRANCHES.length,
217
+ };
218
+
219
+ return HttpResponse.json(response);
220
+ }),
221
+
222
+ // Search repository branches endpoint
223
+ http.get("*/api/user/search/branches", async ({ request }) => {
224
+ await delay(200);
225
+
226
+ const url = new URL(request.url);
227
+ const repository = url.searchParams.get("repository");
228
+ const query = url.searchParams.get("query") || "";
229
+ const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
230
+
231
+ if (!repository) {
232
+ return HttpResponse.json("Repository parameter is required", {
233
+ status: 400,
234
+ });
235
+ }
236
+
237
+ // Filter branches by search query
238
+ const filteredBranches = MOCK_BRANCHES.filter((branch) =>
239
+ branch.name.toLowerCase().includes(query.toLowerCase()),
240
+ );
241
+
242
+ // Limit results
243
+ const limitedBranches = filteredBranches.slice(0, perPage);
244
+
245
+ return HttpResponse.json(limitedBranches);
246
+ }),
247
+
248
+ http.get("*/api/git/changes", async () =>
249
+ HttpResponse.json(MOCK_GIT_CHANGES),
250
+ ),
251
+
252
+ http.get("*/api/git/diff", async () =>
253
+ HttpResponse.json({
254
+ original: 'def greet(name):\n return f"Hello, {name}!"\n',
255
+ modified:
256
+ 'def greet(name: str) -> str:\n return f"Hello, {name}! Welcome."\n',
257
+ }),
258
+ ),
259
+ ];
src/mocks/handlers.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
2
+ import { SECRETS_HANDLERS } from "./secrets-handlers";
3
+ import {
4
+ AGENT_PROFILES_HANDLERS,
5
+ resetMockAgentProfiles,
6
+ seedMockAgentProfiles,
7
+ } from "./agent-profiles-handlers";
8
+ import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers";
9
+ import {
10
+ SETTINGS_HANDLERS,
11
+ MOCK_DEFAULT_USER_SETTINGS,
12
+ resetTestHandlersMockSettings,
13
+ } from "./settings-handlers";
14
+ import { CONVERSATION_HANDLERS } from "./conversation-handlers";
15
+ import { AUTH_HANDLERS } from "./auth-handlers";
16
+ import { FEEDBACK_HANDLERS } from "./feedback-handlers";
17
+ import { ANALYTICS_HANDLERS } from "./analytics-handlers";
18
+ import {
19
+ AUTOMATION_HANDLERS,
20
+ resetAutomationMockData,
21
+ } from "./automation-handlers";
22
+ import { MCP_HANDLERS } from "./mcp-handlers";
23
+ import {
24
+ WORKSPACES_HANDLERS,
25
+ resetMockWorkspaces,
26
+ } from "./workspaces-handlers";
27
+ import {
28
+ CANVAS_EXTENSIONS_HANDLERS,
29
+ resetCanvasExtensionsMockData,
30
+ } from "./canvas-extensions-handlers";
31
+
32
+ export const handlers = [
33
+ ...FILE_SERVICE_HANDLERS,
34
+ ...SECRETS_HANDLERS,
35
+ ...AGENT_PROFILES_HANDLERS,
36
+ ...GIT_REPOSITORY_HANDLERS,
37
+ ...SETTINGS_HANDLERS,
38
+ ...CONVERSATION_HANDLERS,
39
+ ...AUTH_HANDLERS,
40
+ ...FEEDBACK_HANDLERS,
41
+ ...ANALYTICS_HANDLERS,
42
+ ...AUTOMATION_HANDLERS,
43
+ ...MCP_HANDLERS,
44
+ ...WORKSPACES_HANDLERS,
45
+ ...CANVAS_EXTENSIONS_HANDLERS,
46
+ ];
47
+
48
+ export {
49
+ MOCK_DEFAULT_USER_SETTINGS,
50
+ resetTestHandlersMockSettings,
51
+ resetAutomationMockData,
52
+ resetMockWorkspaces,
53
+ resetCanvasExtensionsMockData,
54
+ };
55
+
56
+ export {
57
+ AGENT_PROFILES_HANDLERS,
58
+ resetMockAgentProfiles,
59
+ seedMockAgentProfiles,
60
+ };
src/mocks/node.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import { setupServer } from "msw/node";
2
+ import { handlers } from "./handlers";
3
+
4
+ export const server = setupServer(...handlers);
src/mocks/secrets-handlers.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from "msw";
2
+
3
+ /**
4
+ * In-memory secrets storage for mock agent-server API.
5
+ * Uses name as the key (agent-server uses name-based lookups, not IDs).
6
+ */
7
+ const secrets = new Map<string, { value: string; description?: string }>([
8
+ ["OpenAI_API_Key", { value: "test-123", description: "OpenAI API Key" }],
9
+ [
10
+ "Google_Maps_API_Key",
11
+ { value: "test-123", description: "Google Maps API Key" },
12
+ ],
13
+ ]);
14
+
15
+ /**
16
+ * Mock handlers for the agent-server secrets API.
17
+ * Routes: /api/settings/secrets and /api/settings/secrets/:name
18
+ *
19
+ * Uses wildcard "*" prefix to match both relative paths and absolute URLs
20
+ * (e.g., http://127.0.0.1:8000/api/...) since the code uses absolute URLs
21
+ * when VITE_BACKEND_BASE_URL is configured.
22
+ */
23
+ export const SECRETS_HANDLERS = [
24
+ // GET /api/settings/secrets - List all secrets (names and descriptions only)
25
+ http.get("*/api/settings/secrets", async ({ request }) => {
26
+ // Exclude requests with a :name param (handled by the next handler)
27
+ const url = new URL(request.url);
28
+ const pathParts = url.pathname.split("/").filter(Boolean);
29
+ if (pathParts.length > 3) return undefined; // Let it pass through to :name handler
30
+
31
+ const secretsList = Array.from(secrets.entries()).map(
32
+ ([name, { description }]) => ({ name, description }),
33
+ );
34
+ return HttpResponse.json({ secrets: secretsList });
35
+ }),
36
+
37
+ // GET /api/settings/secrets/:name - Get secret value by name
38
+ http.get("*/api/settings/secrets/:name", async ({ params }) => {
39
+ const { name } = params;
40
+ if (typeof name !== "string") {
41
+ return HttpResponse.json({ detail: "Invalid name" }, { status: 400 });
42
+ }
43
+
44
+ const secret = secrets.get(name);
45
+ if (!secret) {
46
+ return HttpResponse.json({ detail: "Secret not found" }, { status: 404 });
47
+ }
48
+
49
+ return new HttpResponse(secret.value, {
50
+ headers: { "Content-Type": "text/plain" },
51
+ });
52
+ }),
53
+
54
+ // PUT /api/settings/secrets - Create or update a secret (upsert)
55
+ http.put("*/api/settings/secrets", async ({ request }) => {
56
+ const body = (await request.json()) as {
57
+ name: string;
58
+ value: string;
59
+ description?: string;
60
+ } | null;
61
+
62
+ if (!body?.name || !body?.value) {
63
+ return HttpResponse.json(
64
+ { detail: "name and value are required" },
65
+ { status: 400 },
66
+ );
67
+ }
68
+
69
+ secrets.set(body.name, {
70
+ value: body.value,
71
+ description: body.description,
72
+ });
73
+
74
+ return HttpResponse.json({
75
+ name: body.name,
76
+ description: body.description,
77
+ });
78
+ }),
79
+
80
+ // DELETE /api/settings/secrets/:name - Delete a secret by name
81
+ http.delete("*/api/settings/secrets/:name", async ({ params }) => {
82
+ const { name } = params;
83
+ if (typeof name !== "string") {
84
+ return HttpResponse.json({ detail: "Invalid name" }, { status: 400 });
85
+ }
86
+
87
+ const deleted = secrets.delete(name);
88
+ if (!deleted) {
89
+ return HttpResponse.json({ detail: "Secret not found" }, { status: 404 });
90
+ }
91
+
92
+ return HttpResponse.json({ deleted: true });
93
+ }),
94
+ ];
src/mocks/settings-handlers.ts ADDED
@@ -0,0 +1,1256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, delay, HttpResponse } from "msw";
2
+ import { WebClientConfig } from "#/api/option-service/option.types";
3
+ import type { SaveProfileRequest } from "#/api/profiles-service/profiles-service.api";
4
+ import { DEFAULT_SETTINGS } from "#/services/settings";
5
+ import { Settings, SettingsValue } from "#/types/settings";
6
+ import {
7
+ OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH,
8
+ OPENAI_SUBSCRIPTION_DEVICE_START_PATH,
9
+ OPENAI_SUBSCRIPTION_LOGOUT_PATH,
10
+ OPENAI_SUBSCRIPTION_MODELS_PATH,
11
+ OPENAI_SUBSCRIPTION_STATUS_PATH,
12
+ OPENAI_SUBSCRIPTION_VENDOR,
13
+ } from "#/constants/llm-subscription";
14
+
15
+ /** Simple recursive merge — objects merge, scalars overwrite. */
16
+ function deepMerge(
17
+ base: Record<string, unknown>,
18
+ patch: Record<string, unknown>,
19
+ ): Record<string, unknown> {
20
+ const result = { ...base };
21
+ for (const [key, value] of Object.entries(patch)) {
22
+ if (
23
+ value != null &&
24
+ typeof value === "object" &&
25
+ !Array.isArray(value) &&
26
+ typeof result[key] === "object" &&
27
+ result[key] != null &&
28
+ !Array.isArray(result[key])
29
+ ) {
30
+ result[key] = deepMerge(
31
+ result[key] as Record<string, unknown>,
32
+ value as Record<string, unknown>,
33
+ );
34
+ } else {
35
+ result[key] = value;
36
+ }
37
+ }
38
+ return result;
39
+ }
40
+
41
+ const DEFAULT_AGENT_SETTINGS = DEFAULT_SETTINGS.agent_settings ?? {};
42
+ const llmDefaults = (DEFAULT_AGENT_SETTINGS as Record<string, unknown>).llm as
43
+ | Record<string, unknown>
44
+ | undefined;
45
+ const DEFAULT_MODEL =
46
+ typeof llmDefaults?.model === "string"
47
+ ? llmDefaults.model
48
+ : "openhands/claude-opus-4-5-20251101";
49
+
50
+ export const createMockWebClientConfig = (
51
+ overrides: Partial<WebClientConfig> = {},
52
+ ): WebClientConfig => ({
53
+ feature_flags: {
54
+ hide_llm_settings: false,
55
+ hide_users_page: false,
56
+ ...overrides.feature_flags,
57
+ },
58
+ providers_configured: [],
59
+ maintenance_start_time: null,
60
+ recaptcha_site_key: null,
61
+ faulty_models: [],
62
+ error_message: null,
63
+ updated_at: new Date().toISOString(),
64
+ ...overrides,
65
+ });
66
+
67
+ const MOCK_AGENT_SETTINGS_SCHEMA: NonNullable<
68
+ Settings["agent_settings_schema"]
69
+ > = {
70
+ model_name: "AgentSettings",
71
+ sections: [
72
+ {
73
+ key: "general",
74
+ label: "General",
75
+ fields: [
76
+ {
77
+ key: "enable_sub_agents",
78
+ label: "Enable sub-agents",
79
+ description:
80
+ "Allow the agent to delegate work to specialized built-in sub-agents.",
81
+ section: "general",
82
+ section_label: "General",
83
+ value_type: "boolean",
84
+ default: false,
85
+ choices: [],
86
+ depends_on: [],
87
+ prominence: "major",
88
+ secret: false,
89
+ required: false,
90
+ },
91
+ {
92
+ key: "enable_switch_llm_tool",
93
+ label: "Enable LLM switching tool",
94
+ description:
95
+ "Allow the agent to move the conversation to another saved LLM profile on its own.",
96
+ section: "general",
97
+ section_label: "General",
98
+ value_type: "boolean",
99
+ default: true,
100
+ choices: [],
101
+ depends_on: [],
102
+ prominence: "major",
103
+ secret: false,
104
+ required: false,
105
+ },
106
+ {
107
+ key: "tool_concurrency_limit",
108
+ label: "Parallel tool calls",
109
+ description:
110
+ "Maximum number of tool calls to execute concurrently per agent step. 1 = sequential (default).",
111
+ section: "general",
112
+ section_label: "General",
113
+ value_type: "integer",
114
+ default: 1,
115
+ choices: [],
116
+ depends_on: [],
117
+ prominence: "major",
118
+ secret: false,
119
+ required: false,
120
+ },
121
+ ],
122
+ },
123
+ {
124
+ key: "llm",
125
+ label: "LLM",
126
+ fields: [
127
+ {
128
+ key: "llm.model",
129
+ label: "Model",
130
+ description: "Select the model to use for this conversation.",
131
+ section: "llm",
132
+ section_label: "LLM",
133
+ value_type: "string",
134
+ default: DEFAULT_MODEL,
135
+ choices: [],
136
+ depends_on: [],
137
+ prominence: "critical",
138
+ secret: false,
139
+ required: true,
140
+ },
141
+ {
142
+ key: "llm.api_key",
143
+ label: "API Key",
144
+ description:
145
+ "Provide the API key used to authenticate requests for the selected model.",
146
+
147
+ section: "llm",
148
+ section_label: "LLM",
149
+ value_type: "string",
150
+ default: null,
151
+ choices: [],
152
+ depends_on: [],
153
+ prominence: "critical",
154
+ secret: true,
155
+ required: false,
156
+ },
157
+ {
158
+ key: "llm.base_url",
159
+ description:
160
+ "Override the model provider's default API base URL when needed.",
161
+
162
+ label: "Base URL",
163
+ section: "llm",
164
+ section_label: "LLM",
165
+ value_type: "string",
166
+ default: null,
167
+ choices: [],
168
+ depends_on: [],
169
+ prominence: "critical",
170
+ secret: false,
171
+ required: false,
172
+ },
173
+ {
174
+ key: "llm.temperature",
175
+ label: "Temperature",
176
+ description: "Adjust randomness for non-deterministic model outputs.",
177
+ section: "llm",
178
+ section_label: "LLM",
179
+ value_type: "number",
180
+ default: null,
181
+ choices: [],
182
+ depends_on: [],
183
+ prominence: "minor",
184
+ secret: false,
185
+ required: false,
186
+ },
187
+ ],
188
+ },
189
+ {
190
+ key: "verification",
191
+ label: "Verification",
192
+ fields: [
193
+ {
194
+ key: "verification.critic_enabled",
195
+ label: "Enable Critic",
196
+ description:
197
+ "Enable an additional critic pass to review the agent's work.",
198
+ section: "verification",
199
+ section_label: "Verification",
200
+ value_type: "boolean",
201
+ default: false,
202
+ choices: [],
203
+ depends_on: [],
204
+ prominence: "critical",
205
+ secret: false,
206
+ required: false,
207
+ },
208
+ {
209
+ key: "verification.critic_mode",
210
+ label: "Critic Mode",
211
+ description: "Choose when the critic should review and intervene.",
212
+ section: "verification",
213
+ section_label: "Verification",
214
+ value_type: "string",
215
+ default: "finish_and_message",
216
+ choices: [
217
+ {
218
+ label: "finish_and_message",
219
+ value: "finish_and_message",
220
+ },
221
+ { label: "all_actions", value: "all_actions" },
222
+ ],
223
+ depends_on: ["verification.critic_enabled"],
224
+ prominence: "major",
225
+ secret: false,
226
+ required: false,
227
+ },
228
+ {
229
+ key: "verification.enable_iterative_refinement",
230
+ label: "Enable Iterative Refinement",
231
+ description:
232
+ "Let the critic send the agent back to refine its work when issues are found.",
233
+ section: "verification",
234
+ section_label: "Verification",
235
+ value_type: "boolean",
236
+ default: false,
237
+ choices: [],
238
+ depends_on: ["verification.critic_enabled"],
239
+ prominence: "critical",
240
+ secret: false,
241
+ required: false,
242
+ },
243
+ // Rendered as a full-width row (see FIELD_FULL_WIDTH_KEYS) below the
244
+ // two critical-prominence toggles so the input + OpenHands Cloud help
245
+ // link have room to breathe.
246
+ {
247
+ key: "verification.critic_api_key",
248
+ label: "Critic API Key",
249
+ description:
250
+ "If OpenHands is selected as your active LLM provider, leave this empty; the critic reuses the OpenHands Provider LLM Key.",
251
+ section: "verification",
252
+ section_label: "Verification",
253
+ value_type: "string",
254
+ default: null,
255
+ choices: [],
256
+ depends_on: ["verification.critic_enabled"],
257
+ prominence: "critical",
258
+ secret: true,
259
+ required: false,
260
+ },
261
+ {
262
+ key: "verification.critic_threshold",
263
+ label: "Critic Threshold",
264
+ description:
265
+ "Critic success threshold used for iterative refinement.",
266
+ section: "verification",
267
+ section_label: "Verification",
268
+ value_type: "number",
269
+ default: 0.6,
270
+ choices: [],
271
+ depends_on: [
272
+ "verification.critic_enabled",
273
+ "verification.enable_iterative_refinement",
274
+ ],
275
+ prominence: "minor",
276
+ secret: false,
277
+ required: false,
278
+ },
279
+ {
280
+ key: "verification.max_refinement_iterations",
281
+ label: "Max Refinement Iterations",
282
+ description:
283
+ "Maximum number of refinement attempts after critic feedback.",
284
+ section: "verification",
285
+ section_label: "Verification",
286
+ value_type: "integer",
287
+ default: 3,
288
+ choices: [],
289
+ depends_on: [
290
+ "verification.critic_enabled",
291
+ "verification.enable_iterative_refinement",
292
+ ],
293
+ prominence: "minor",
294
+ secret: false,
295
+ required: false,
296
+ },
297
+ {
298
+ key: "verification.critic_server_url",
299
+ label: "Critic Server URL",
300
+ description: "Override the critic service URL.",
301
+ section: "verification",
302
+ section_label: "Verification",
303
+ value_type: "string",
304
+ default: null,
305
+ choices: [],
306
+ depends_on: ["verification.critic_enabled"],
307
+ prominence: "minor",
308
+ secret: false,
309
+ required: false,
310
+ },
311
+ {
312
+ key: "verification.critic_model_name",
313
+ label: "Critic Model Name",
314
+ description: "Override the critic model name.",
315
+ section: "verification",
316
+ section_label: "Verification",
317
+ value_type: "string",
318
+ default: null,
319
+ choices: [],
320
+ depends_on: ["verification.critic_enabled"],
321
+ prominence: "minor",
322
+ secret: false,
323
+ required: false,
324
+ },
325
+ ],
326
+ },
327
+ {
328
+ key: "condenser",
329
+ label: "Condenser",
330
+ fields: [
331
+ {
332
+ description:
333
+ "Enable the default LLM-based condenser to summarize long conversation histories.",
334
+ key: "condenser.enable_default_condenser",
335
+ label: "Enable default condenser",
336
+ section: "condenser",
337
+ section_label: "Condenser",
338
+ value_type: "boolean",
339
+ default: true,
340
+ choices: [],
341
+ depends_on: [],
342
+ prominence: "critical",
343
+ secret: false,
344
+ required: true,
345
+ },
346
+ {
347
+ description:
348
+ "Maximum number of tokens the condenser keeps after summarization. Leave blank for unlimited.",
349
+ key: "condenser.condenser_max_size",
350
+ label: "Condenser max size",
351
+ section: "condenser",
352
+ section_label: "Condenser",
353
+ value_type: "integer",
354
+ default: null,
355
+ choices: [],
356
+ depends_on: [],
357
+ prominence: "major",
358
+ secret: false,
359
+ required: false,
360
+ },
361
+ ],
362
+ },
363
+ {
364
+ // Curated agent_context section (SDK fields_opt_in): only load_memory
365
+ // is exposed, never the raw AgentContext model. ``label`` mirrors the
366
+ // server's literal string ("Memory"); the GUI names the page itself via
367
+ // the SCHEMA$AGENT_CONTEXT$SECTION_LABEL translation, which wins.
368
+ key: "agent_context",
369
+ label: "Memory",
370
+ fields: [
371
+ {
372
+ description:
373
+ "The agent keeps notes under .openhands/memory/ and loads them at the start of each new conversation, learning your codebase and preferences over time.",
374
+ key: "agent_context.load_memory",
375
+ label: "Persistent memory",
376
+ section: "agent_context",
377
+ section_label: "Memory",
378
+ value_type: "boolean",
379
+ default: false,
380
+ choices: [],
381
+ depends_on: [],
382
+ prominence: "major",
383
+ secret: false,
384
+ required: false,
385
+ },
386
+ ],
387
+ },
388
+ ],
389
+ };
390
+
391
+ const MOCK_CONVERSATION_SETTINGS_SCHEMA: NonNullable<
392
+ Settings["conversation_settings_schema"]
393
+ > = {
394
+ model_name: "ConversationSettings",
395
+ sections: [
396
+ {
397
+ key: "general",
398
+ label: "General",
399
+ fields: [
400
+ {
401
+ key: "max_iterations",
402
+ label: "Max iterations",
403
+ section: "general",
404
+ description:
405
+ "Maximum number of agent steps allowed before the conversation stops.",
406
+
407
+ section_label: "General",
408
+ value_type: "integer",
409
+ default: 500,
410
+ choices: [],
411
+ depends_on: [],
412
+ prominence: "major",
413
+ secret: false,
414
+ required: true,
415
+ },
416
+ ],
417
+ },
418
+ {
419
+ key: "verification",
420
+ label: "Verification",
421
+ fields: [
422
+ {
423
+ key: "confirmation_mode",
424
+ label: "Confirmation mode",
425
+ description:
426
+ "Pause for confirmation before the agent performs high-risk actions.",
427
+
428
+ section: "verification",
429
+ section_label: "Verification",
430
+ value_type: "boolean",
431
+ default: false,
432
+ choices: [],
433
+ depends_on: [],
434
+ prominence: "major",
435
+ secret: false,
436
+ required: true,
437
+ },
438
+ {
439
+ key: "security_analyzer",
440
+ label: "Security analyzer",
441
+ description:
442
+ "Choose how OpenHands should analyze actions before asking for confirmation.",
443
+
444
+ section: "verification",
445
+ section_label: "Verification",
446
+ value_type: "string",
447
+ default: "llm",
448
+ choices: [
449
+ { label: "llm", value: "llm" },
450
+ { label: "none", value: "none" },
451
+ ],
452
+ depends_on: ["confirmation_mode"],
453
+ prominence: "major",
454
+ secret: false,
455
+ required: false,
456
+ },
457
+ ],
458
+ },
459
+ ],
460
+ };
461
+
462
+ export const MOCK_DEFAULT_USER_SETTINGS: Settings = {
463
+ ...DEFAULT_SETTINGS,
464
+ provider_tokens_set: {},
465
+ agent_settings_schema: MOCK_AGENT_SETTINGS_SCHEMA,
466
+ agent_settings: {
467
+ ...DEFAULT_AGENT_SETTINGS,
468
+ verification: {
469
+ critic_enabled: false,
470
+ enable_iterative_refinement: false,
471
+ },
472
+ llm: {
473
+ ...(llmDefaults ?? {}),
474
+ api_key: null,
475
+ model: DEFAULT_MODEL,
476
+ },
477
+ condenser: {
478
+ enable_default_condenser: true,
479
+ condenser_max_size: null,
480
+ },
481
+ enable_sub_agents: false,
482
+ tool_concurrency_limit: 1,
483
+ },
484
+ conversation_settings_schema: MOCK_CONVERSATION_SETTINGS_SCHEMA,
485
+ conversation_settings: {
486
+ ...(DEFAULT_SETTINGS.conversation_settings ?? {}),
487
+ },
488
+ };
489
+
490
+ const MOCK_USER_PREFERENCES: {
491
+ settings: Settings | null;
492
+ } = {
493
+ settings: structuredClone(MOCK_DEFAULT_USER_SETTINGS),
494
+ };
495
+
496
+ interface MockLlmProfile {
497
+ name: string;
498
+ config: Record<string, SettingsValue>;
499
+ api_key_set: boolean;
500
+ }
501
+
502
+ const MOCK_LLM_PROFILES: {
503
+ profiles: Map<string, MockLlmProfile>;
504
+ activeProfile: string | null;
505
+ } = {
506
+ profiles: new Map(),
507
+ activeProfile: null,
508
+ };
509
+
510
+ let mockOpenAISubscriptionConnected = false;
511
+
512
+ const getProfileNameParam = (value: unknown): string =>
513
+ decodeURIComponent(
514
+ Array.isArray(value) ? String(value[0] ?? "") : String(value ?? ""),
515
+ );
516
+
517
+ const profileToListItem = (profile: MockLlmProfile) => ({
518
+ name: profile.name,
519
+ model: typeof profile.config.model === "string" ? profile.config.model : null,
520
+ base_url:
521
+ typeof profile.config.base_url === "string"
522
+ ? profile.config.base_url
523
+ : null,
524
+ api_key_set: profile.api_key_set,
525
+ });
526
+
527
+ const applyProfileToMockSettings = (profile: MockLlmProfile) => {
528
+ const current =
529
+ MOCK_USER_PREFERENCES.settings ||
530
+ structuredClone(MOCK_DEFAULT_USER_SETTINGS);
531
+
532
+ MOCK_USER_PREFERENCES.settings = {
533
+ ...current,
534
+ agent_settings: {
535
+ ...(current.agent_settings ?? {}),
536
+ llm: structuredClone(profile.config),
537
+ },
538
+ llm_api_key_set: profile.api_key_set,
539
+ };
540
+ };
541
+
542
+ const buildProfileDetail = (
543
+ profile: MockLlmProfile,
544
+ exposeSecrets: string | null,
545
+ ) => {
546
+ const config = structuredClone(profile.config);
547
+
548
+ if (profile.api_key_set && "api_key" in config) {
549
+ if (exposeSecrets === "encrypted") {
550
+ config.api_key = `gAAAAA_mock_encrypted_${profile.name}`;
551
+ } else if (exposeSecrets === "plaintext") {
552
+ // Keep the mock plaintext value.
553
+ } else {
554
+ config.api_key = null;
555
+ }
556
+ }
557
+
558
+ return {
559
+ name: profile.name,
560
+ config,
561
+ api_key_set: profile.api_key_set,
562
+ };
563
+ };
564
+
565
+ const saveMockProfile = (name: string, request: SaveProfileRequest) => {
566
+ const llm = structuredClone(
567
+ (request.llm ?? {}) as Record<string, SettingsValue>,
568
+ );
569
+
570
+ if (typeof llm.model !== "string" || llm.model.trim().length === 0) {
571
+ return null;
572
+ }
573
+
574
+ const profile: MockLlmProfile = {
575
+ name,
576
+ config: llm,
577
+ api_key_set:
578
+ typeof llm.api_key === "string" && llm.api_key.trim().length > 0,
579
+ };
580
+
581
+ MOCK_LLM_PROFILES.profiles.set(name, profile);
582
+
583
+ if (MOCK_LLM_PROFILES.activeProfile === name) {
584
+ applyProfileToMockSettings(profile);
585
+ }
586
+
587
+ return profile;
588
+ };
589
+
590
+ export const resetTestHandlersMockSettings = () => {
591
+ MOCK_USER_PREFERENCES.settings = structuredClone(MOCK_DEFAULT_USER_SETTINGS);
592
+ MOCK_LLM_PROFILES.profiles.clear();
593
+ MOCK_LLM_PROFILES.activeProfile = null;
594
+ mockOpenAISubscriptionConnected = false;
595
+ };
596
+
597
+ // Mock model data used by provider/model endpoints
598
+ const MOCK_MODELS = [
599
+ "anthropic/claude-3.5",
600
+ "anthropic/claude-sonnet-4-20250514",
601
+ "anthropic/claude-sonnet-4-5-20250929",
602
+ "anthropic/claude-haiku-4-5-20251001",
603
+ "anthropic/claude-opus-4-5-20251101",
604
+ "anthropic/claude-opus-4-8",
605
+ "openai/gpt-3.5-turbo",
606
+ "openai/gpt-5.5",
607
+ "openai/gpt-4o",
608
+ "openai/gpt-4o-mini",
609
+ "openhands/claude-sonnet-4-20250514",
610
+ "openhands/claude-sonnet-4-5-20250929",
611
+ "openhands/claude-haiku-4-5-20251001",
612
+ "openhands/claude-opus-4-5-20251101",
613
+ "openai/gpt-5.6-sol",
614
+ "openai/gpt-6-astra",
615
+ "openhands/deepseek-v4-flash",
616
+ "openhands/glm-5.2",
617
+ "sambanova/Meta-Llama-3.1-8B-Instruct",
618
+ ];
619
+
620
+ const MOCK_OPENAI_SUBSCRIPTION_MODELS = ["gpt-5.2", "gpt-5.3-codex"];
621
+
622
+ const MOCK_VERIFIED_MODELS = new Set([
623
+ "anthropic/claude-opus-4-5-20251101",
624
+ "anthropic/claude-opus-4-8",
625
+ "anthropic/claude-sonnet-4-5-20250929",
626
+ "openai/gpt-5.5",
627
+ "openhands/claude-opus-4-5-20251101",
628
+ "openhands/claude-sonnet-4-5-20250929",
629
+ "openai/gpt-5.6-sol",
630
+ "openai/gpt-6-astra",
631
+ "openhands/deepseek-v4-flash",
632
+ "openhands/glm-5.2",
633
+ ]);
634
+
635
+ // DB-driven free / default flags for the OpenHands provider. Mirrors the
636
+ // enterprise verified-models seed used to render the "Free" badge and preselect
637
+ // the default model.
638
+ const MOCK_FREE_MODELS = new Set(["openhands/glm-5.2"]);
639
+ const MOCK_DEFAULT_MODEL = "openhands/glm-5.2";
640
+
641
+ const MOCK_VERIFIED_PROVIDERS = [
642
+ "openhands",
643
+ "anthropic",
644
+ "openai",
645
+ "mistral",
646
+ "gemini",
647
+ "deepseek",
648
+ "moonshot",
649
+ "minimax",
650
+ ];
651
+
652
+ const MOCK_MODEL_PROVIDERS = Array.from(
653
+ new Set(
654
+ MOCK_MODELS.map((model) => model.split("/")[0]).filter(
655
+ (provider): provider is string => Boolean(provider),
656
+ ),
657
+ ),
658
+ );
659
+
660
+ const MOCK_VERIFIED_MODELS_BY_PROVIDER = MOCK_MODELS.reduce<
661
+ Record<string, string[]>
662
+ >((acc, model) => {
663
+ if (!MOCK_VERIFIED_MODELS.has(model)) return acc;
664
+
665
+ const [provider, ...rest] = model.split("/");
666
+ if (!provider || rest.length === 0) return acc;
667
+
668
+ acc[provider] ??= [];
669
+ acc[provider].push(rest.join("/"));
670
+ return acc;
671
+ }, {});
672
+
673
+ // Matches the pinned `@openhands/typescript-client`, so mock mode models a
674
+ // server that actually ships this schema. At 1.29.3 the mocked settings schema
675
+ // advertised fields (`enable_switch_llm_tool`) that the mocked server's own
676
+ // profile model would have rejected, and version-gated UI hid controls the
677
+ // rest of the mocks were serving.
678
+ const MOCK_AGENT_SERVER_VERSION = "1.36.1";
679
+
680
+ // --- Handlers for options/config/settings ---
681
+ // Uses wildcard "*" prefix to match both relative paths and absolute URLs
682
+ // (e.g., http://127.0.0.1:8000/api/...) since the code uses absolute URLs
683
+ // when VITE_BACKEND_BASE_URL is configured.
684
+
685
+ export const SETTINGS_HANDLERS = [
686
+ http.get("*/server_info", async () =>
687
+ HttpResponse.json({
688
+ uptime: 0,
689
+ idle_time: 0,
690
+ version: MOCK_AGENT_SERVER_VERSION,
691
+ usable_tools: [
692
+ "terminal",
693
+ "file_editor",
694
+ "task_tracker",
695
+ "browser_tool_set",
696
+ ],
697
+ agents: ["CodeActAgent"],
698
+ default_agent: "CodeActAgent",
699
+ models: MOCK_MODELS,
700
+ security_analyzers: ["llm", "none"],
701
+ }),
702
+ ),
703
+
704
+ http.get("*/api/llm/models", async () =>
705
+ HttpResponse.json({ models: MOCK_MODELS }),
706
+ ),
707
+
708
+ http.get("*/api/llm/models/verified", async () =>
709
+ HttpResponse.json({ models: MOCK_VERIFIED_MODELS_BY_PROVIDER }),
710
+ ),
711
+
712
+ http.get("*/api/llm/providers", async () =>
713
+ HttpResponse.json({ providers: MOCK_MODEL_PROVIDERS }),
714
+ ),
715
+
716
+ http.get(`*${OPENAI_SUBSCRIPTION_MODELS_PATH}`, async () =>
717
+ HttpResponse.json({
718
+ vendor: OPENAI_SUBSCRIPTION_VENDOR,
719
+ models: MOCK_OPENAI_SUBSCRIPTION_MODELS,
720
+ }),
721
+ ),
722
+
723
+ http.get(`*${OPENAI_SUBSCRIPTION_STATUS_PATH}`, async () =>
724
+ HttpResponse.json({
725
+ connected: mockOpenAISubscriptionConnected,
726
+ account_email: mockOpenAISubscriptionConnected
727
+ ? "mock-chatgpt@example.com"
728
+ : null,
729
+ expires_at: null,
730
+ }),
731
+ ),
732
+
733
+ http.post(`*${OPENAI_SUBSCRIPTION_DEVICE_START_PATH}`, async () =>
734
+ HttpResponse.json({
735
+ device_code: "mock-device-code",
736
+ user_code: "MOCK-CODE",
737
+ verification_uri: "https://auth.openai.com/activate",
738
+ verification_uri_complete:
739
+ "https://auth.openai.com/activate?user_code=MOCK-CODE",
740
+ interval: 1,
741
+ expires_in: 900,
742
+ }),
743
+ ),
744
+
745
+ http.post(`*${OPENAI_SUBSCRIPTION_DEVICE_POLL_PATH}`, async () => {
746
+ mockOpenAISubscriptionConnected = true;
747
+ return HttpResponse.json({
748
+ connected: true,
749
+ account_email: "mock-chatgpt@example.com",
750
+ expires_at: null,
751
+ });
752
+ }),
753
+
754
+ http.post(`*${OPENAI_SUBSCRIPTION_LOGOUT_PATH}`, async () => {
755
+ mockOpenAISubscriptionConnected = false;
756
+ return HttpResponse.json({ connected: false });
757
+ }),
758
+
759
+ // V0 (legacy) models endpoint – still used for default_model
760
+ http.get("*/api/options/models", async () =>
761
+ HttpResponse.json({
762
+ models: MOCK_MODELS,
763
+ verified_models: [
764
+ "claude-opus-4-5-20251101",
765
+ "claude-sonnet-4-5-20250929",
766
+ ],
767
+ verified_providers: MOCK_VERIFIED_PROVIDERS,
768
+ default_model: "openai/gpt-5.6-sol",
769
+ }),
770
+ ),
771
+
772
+ // V1 providers search
773
+ http.get("*/api/v1/config/providers/search", async ({ request }) => {
774
+ const url = new URL(request.url);
775
+ const query = url.searchParams.get("query")?.toLowerCase();
776
+ const verifiedEq = url.searchParams.get("verified__eq");
777
+
778
+ // Build unique provider list from models
779
+ const seen = new Set<string>();
780
+ let providers: { name: string; verified: boolean }[] = [];
781
+ for (const model of MOCK_MODELS) {
782
+ const [providerName] = model.split("/");
783
+ if (providerName && !seen.has(providerName)) {
784
+ seen.add(providerName);
785
+ providers.push({
786
+ name: providerName,
787
+ verified: MOCK_VERIFIED_PROVIDERS.includes(providerName),
788
+ });
789
+ }
790
+ }
791
+
792
+ if (query) {
793
+ providers = providers.filter((p) => p.name.toLowerCase().includes(query));
794
+ }
795
+ if (verifiedEq !== null && verifiedEq !== undefined) {
796
+ const wantVerified = verifiedEq === "true";
797
+ providers = providers.filter((p) => p.verified === wantVerified);
798
+ }
799
+
800
+ return HttpResponse.json({ items: providers, next_page_id: null });
801
+ }),
802
+
803
+ // V1 models search
804
+ http.get("*/api/v1/config/models/search", async ({ request }) => {
805
+ const url = new URL(request.url);
806
+ const query = url.searchParams.get("query")?.toLowerCase();
807
+ const verifiedEq = url.searchParams.get("verified__eq");
808
+ const providerEq = url.searchParams.get("provider__eq");
809
+
810
+ let models = MOCK_MODELS.map((m) => {
811
+ const [provider, ...rest] = m.split("/");
812
+ const name = rest.join("/");
813
+ return {
814
+ provider: provider || null,
815
+ name,
816
+ verified: MOCK_VERIFIED_MODELS.has(m),
817
+ free: MOCK_FREE_MODELS.has(m),
818
+ default: m === MOCK_DEFAULT_MODEL,
819
+ };
820
+ });
821
+
822
+ if (providerEq) {
823
+ models = models.filter((m) => m.provider === providerEq);
824
+ }
825
+ if (query) {
826
+ models = models.filter((m) => m.name.toLowerCase().includes(query));
827
+ }
828
+ if (verifiedEq !== null && verifiedEq !== undefined) {
829
+ const wantVerified = verifiedEq === "true";
830
+ models = models.filter((m) => m.verified === wantVerified);
831
+ }
832
+
833
+ return HttpResponse.json({ items: models, next_page_id: null });
834
+ }),
835
+
836
+ http.get("*/api/options/security-analyzers", async () =>
837
+ HttpResponse.json(["llm", "none"]),
838
+ ),
839
+
840
+ http.get("*/api/profiles", async () =>
841
+ HttpResponse.json({
842
+ profiles: Array.from(MOCK_LLM_PROFILES.profiles.values()).map(
843
+ profileToListItem,
844
+ ),
845
+ active_profile: MOCK_LLM_PROFILES.activeProfile,
846
+ }),
847
+ ),
848
+
849
+ http.get("*/api/profiles/:name", async ({ params, request }) => {
850
+ const name = getProfileNameParam(params.name);
851
+ const profile = MOCK_LLM_PROFILES.profiles.get(name);
852
+
853
+ if (!profile) {
854
+ return HttpResponse.json(
855
+ { detail: `Profile '${name}' not found` },
856
+ { status: 404 },
857
+ );
858
+ }
859
+
860
+ return HttpResponse.json(
861
+ buildProfileDetail(profile, request.headers.get("X-Expose-Secrets")),
862
+ );
863
+ }),
864
+
865
+ http.post("*/api/profiles/:name", async ({ params, request }) => {
866
+ const name = getProfileNameParam(params.name);
867
+ const body = (await request.json()) as SaveProfileRequest | null;
868
+
869
+ if (!body) {
870
+ return HttpResponse.json({ detail: "Empty body" }, { status: 400 });
871
+ }
872
+
873
+ const profile = saveMockProfile(name, body);
874
+ if (!profile) {
875
+ return HttpResponse.json(
876
+ { detail: "Profile requires llm.model" },
877
+ { status: 400 },
878
+ );
879
+ }
880
+
881
+ return HttpResponse.json(
882
+ { name: profile.name, message: `Profile '${profile.name}' saved` },
883
+ { status: 201 },
884
+ );
885
+ }),
886
+
887
+ http.delete("*/api/profiles/:name", async ({ params }) => {
888
+ const name = getProfileNameParam(params.name);
889
+ MOCK_LLM_PROFILES.profiles.delete(name);
890
+
891
+ if (MOCK_LLM_PROFILES.activeProfile === name) {
892
+ MOCK_LLM_PROFILES.activeProfile = null;
893
+ }
894
+
895
+ return HttpResponse.json({
896
+ name,
897
+ message: `Profile '${name}' deleted`,
898
+ });
899
+ }),
900
+
901
+ http.post("*/api/profiles/:name/rename", async ({ params, request }) => {
902
+ const name = getProfileNameParam(params.name);
903
+ const body = (await request.json()) as { new_name?: string } | null;
904
+ const newName = body?.new_name?.trim() ?? "";
905
+ const profile = MOCK_LLM_PROFILES.profiles.get(name);
906
+
907
+ if (!profile) {
908
+ return HttpResponse.json(
909
+ { detail: `Profile '${name}' not found` },
910
+ { status: 404 },
911
+ );
912
+ }
913
+
914
+ if (!newName) {
915
+ return HttpResponse.json(
916
+ { detail: "new_name is required" },
917
+ { status: 400 },
918
+ );
919
+ }
920
+
921
+ if (newName !== name && MOCK_LLM_PROFILES.profiles.has(newName)) {
922
+ return HttpResponse.json(
923
+ { detail: `Profile '${newName}' already exists` },
924
+ { status: 409 },
925
+ );
926
+ }
927
+
928
+ MOCK_LLM_PROFILES.profiles.delete(name);
929
+ const renamedProfile = { ...profile, name: newName };
930
+ MOCK_LLM_PROFILES.profiles.set(newName, renamedProfile);
931
+
932
+ if (MOCK_LLM_PROFILES.activeProfile === name) {
933
+ MOCK_LLM_PROFILES.activeProfile = newName;
934
+ applyProfileToMockSettings(renamedProfile);
935
+ }
936
+
937
+ return HttpResponse.json({
938
+ name: newName,
939
+ message: `Profile '${name}' renamed to '${newName}'`,
940
+ });
941
+ }),
942
+
943
+ http.post("*/api/profiles/:name/activate", async ({ params }) => {
944
+ const name = getProfileNameParam(params.name);
945
+ const profile = MOCK_LLM_PROFILES.profiles.get(name);
946
+
947
+ if (!profile) {
948
+ return HttpResponse.json(
949
+ { detail: `Profile '${name}' not found` },
950
+ { status: 404 },
951
+ );
952
+ }
953
+
954
+ MOCK_LLM_PROFILES.activeProfile = name;
955
+ applyProfileToMockSettings(profile);
956
+
957
+ return HttpResponse.json({
958
+ name,
959
+ message: `Profile '${name}' activated and applied to current settings`,
960
+ llm_applied: true,
961
+ });
962
+ }),
963
+
964
+ http.get("*/api/v1/web-client/config", () => {
965
+ const config: WebClientConfig = {
966
+ feature_flags: {
967
+ hide_llm_settings: false,
968
+ hide_users_page: false,
969
+ },
970
+ providers_configured: [],
971
+ maintenance_start_time: null,
972
+ recaptcha_site_key: null,
973
+ faulty_models: [],
974
+ error_message: null,
975
+ updated_at: new Date().toISOString(),
976
+ };
977
+
978
+ return HttpResponse.json(config);
979
+ }),
980
+
981
+ http.get("*/api/settings/conversation-schema", async () => {
982
+ await delay();
983
+ return HttpResponse.json(MOCK_CONVERSATION_SETTINGS_SCHEMA);
984
+ }),
985
+
986
+ http.get("*/api/v1/settings/conversation-schema", async () => {
987
+ await delay();
988
+ return HttpResponse.json(MOCK_CONVERSATION_SETTINGS_SCHEMA);
989
+ }),
990
+
991
+ http.get("*/api/v1/settings", async () => {
992
+ await delay();
993
+ const { settings } = MOCK_USER_PREFERENCES;
994
+
995
+ if (!settings) return HttpResponse.json(null, { status: 404 });
996
+
997
+ return HttpResponse.json(settings);
998
+ }),
999
+
1000
+ // New settings API endpoints (GET /api/settings with X-Expose-Secrets header support)
1001
+ http.get("*/api/settings", async ({ request }) => {
1002
+ // Exclude sub-paths like /api/settings/agent-schema (handled by separate handlers)
1003
+ const url = new URL(request.url);
1004
+ const pathParts = url.pathname.split("/").filter(Boolean);
1005
+ if (pathParts.length > 2) return undefined;
1006
+
1007
+ await delay();
1008
+ const { settings } = MOCK_USER_PREFERENCES;
1009
+
1010
+ const DEFAULT_APP_PREFERENCES = {
1011
+ language: null,
1012
+ user_consents_to_analytics: null,
1013
+ enable_sound_notifications: null,
1014
+ git_user_name: null,
1015
+ git_user_email: null,
1016
+ title_llm_profile: null,
1017
+ disabled_skills: [],
1018
+ };
1019
+
1020
+ if (!settings) {
1021
+ return HttpResponse.json({
1022
+ agent_settings: {},
1023
+ conversation_settings: {},
1024
+ llm_api_key_is_set: false,
1025
+ misc_settings: { app_preferences: DEFAULT_APP_PREFERENCES },
1026
+ });
1027
+ }
1028
+
1029
+ const exposeSecrets = request.headers.get("X-Expose-Secrets");
1030
+
1031
+ // Build agent_settings, handling secrets based on header
1032
+ const agentSettings = structuredClone(
1033
+ settings.agent_settings ?? {},
1034
+ ) as Record<string, unknown>;
1035
+ const llm = agentSettings.llm as Record<string, unknown> | undefined;
1036
+ if (llm?.api_key) {
1037
+ if (exposeSecrets === "encrypted") {
1038
+ // Return a mock "encrypted" value
1039
+ llm.api_key = `gAAAAA_mock_encrypted_${String(llm.api_key).slice(0, 8)}`;
1040
+ } else if (exposeSecrets === "plaintext") {
1041
+ // Keep as-is (plaintext)
1042
+ } else {
1043
+ // Redact
1044
+ llm.api_key = "**********";
1045
+ }
1046
+ }
1047
+
1048
+ const llmApiKeySet =
1049
+ !!settings.llm_api_key_set ||
1050
+ (!!(settings.agent_settings as Record<string, unknown> | undefined)
1051
+ ?.llm &&
1052
+ !!(
1053
+ (settings.agent_settings as Record<string, unknown>).llm as Record<
1054
+ string,
1055
+ unknown
1056
+ >
1057
+ )?.api_key);
1058
+
1059
+ // Reuse the persisted misc_settings.app_preferences for repeat fetches,
1060
+ // but always fall back to the default-empty block so the GUI sees a
1061
+ // deterministic shape on first read.
1062
+ const storedMisc = (settings as Record<string, unknown>).misc_settings as
1063
+ | { app_preferences?: Record<string, unknown> }
1064
+ | undefined;
1065
+ const appPreferences = {
1066
+ ...DEFAULT_APP_PREFERENCES,
1067
+ ...(storedMisc?.app_preferences ?? {}),
1068
+ };
1069
+
1070
+ return HttpResponse.json({
1071
+ agent_settings: agentSettings,
1072
+ conversation_settings: settings.conversation_settings ?? {},
1073
+ llm_api_key_is_set: llmApiKeySet,
1074
+ misc_settings: { app_preferences: appPreferences },
1075
+ });
1076
+ }),
1077
+
1078
+ // PATCH /api/settings - incremental updates
1079
+ http.patch("*/api/settings", async ({ request }) => {
1080
+ await delay();
1081
+ const body = (await request.json()) as {
1082
+ agent_settings_diff?: Record<string, unknown>;
1083
+ conversation_settings_diff?: Record<string, SettingsValue>;
1084
+ misc_settings_diff?: {
1085
+ app_preferences?: Record<string, unknown>;
1086
+ };
1087
+ } | null;
1088
+
1089
+ if (!body) {
1090
+ return HttpResponse.json({ error: "Empty body" }, { status: 400 });
1091
+ }
1092
+
1093
+ if (
1094
+ !body.agent_settings_diff &&
1095
+ !body.conversation_settings_diff &&
1096
+ !body.misc_settings_diff
1097
+ ) {
1098
+ return HttpResponse.json(
1099
+ {
1100
+ error:
1101
+ "At least one of agent_settings_diff, conversation_settings_diff, or misc_settings_diff must be provided",
1102
+ },
1103
+ { status: 400 },
1104
+ );
1105
+ }
1106
+
1107
+ const current =
1108
+ MOCK_USER_PREFERENCES.settings ||
1109
+ structuredClone(MOCK_DEFAULT_USER_SETTINGS);
1110
+ const nextSettings: Settings = { ...current };
1111
+
1112
+ if (body.agent_settings_diff) {
1113
+ const merged = deepMerge(
1114
+ (current.agent_settings ?? {}) as Record<string, unknown>,
1115
+ body.agent_settings_diff,
1116
+ );
1117
+ nextSettings.agent_settings = merged as Settings["agent_settings"];
1118
+
1119
+ // Sync llm_api_key_set
1120
+ const llm = merged.llm as Record<string, unknown> | undefined;
1121
+ if (
1122
+ llm?.api_key &&
1123
+ typeof llm.api_key === "string" &&
1124
+ llm.api_key.trim().length > 0
1125
+ ) {
1126
+ nextSettings.llm_api_key_set = true;
1127
+ }
1128
+ }
1129
+
1130
+ if (body.conversation_settings_diff) {
1131
+ nextSettings.conversation_settings = {
1132
+ ...(current.conversation_settings ?? {}),
1133
+ ...body.conversation_settings_diff,
1134
+ };
1135
+ }
1136
+
1137
+ if (body.misc_settings_diff) {
1138
+ const existingMisc = (current as Record<string, unknown>)
1139
+ .misc_settings as
1140
+ | { app_preferences?: Record<string, unknown> }
1141
+ | undefined;
1142
+ // Deep-merge: nested `app_preferences` overlays field-by-field;
1143
+ // `disabled_skills` lists are replaced wholesale. This mirrors the
1144
+ // SDK's `_deep_merge` behaviour for the two-level shape currently
1145
+ // stored in `misc_settings`.
1146
+ const nextMisc: { app_preferences?: Record<string, unknown> } = {
1147
+ ...(existingMisc ?? {}),
1148
+ };
1149
+ if (body.misc_settings_diff.app_preferences) {
1150
+ nextMisc.app_preferences = {
1151
+ ...(existingMisc?.app_preferences ?? {}),
1152
+ ...body.misc_settings_diff.app_preferences,
1153
+ };
1154
+ }
1155
+ (nextSettings as Record<string, unknown>).misc_settings = nextMisc;
1156
+ }
1157
+
1158
+ MOCK_USER_PREFERENCES.settings = nextSettings;
1159
+
1160
+ // Return the updated settings (without secrets exposed)
1161
+ return HttpResponse.json({
1162
+ agent_settings: nextSettings.agent_settings ?? {},
1163
+ conversation_settings: nextSettings.conversation_settings ?? {},
1164
+ llm_api_key_is_set: nextSettings.llm_api_key_set ?? false,
1165
+ misc_settings: ((nextSettings as Record<string, unknown>)
1166
+ .misc_settings as
1167
+ | { app_preferences?: Record<string, unknown> }
1168
+ | undefined) ?? { app_preferences: {} },
1169
+ });
1170
+ }),
1171
+
1172
+ http.post("*/api/mcp/test", async () => {
1173
+ await delay();
1174
+ return HttpResponse.json({ ok: true, tools: ["mock_tool"] });
1175
+ }),
1176
+
1177
+ http.get("*/api/settings/agent-schema", async () => {
1178
+ await delay();
1179
+ return HttpResponse.json(MOCK_AGENT_SETTINGS_SCHEMA);
1180
+ }),
1181
+
1182
+ http.get("*/api/v1/settings/agent-schema", async () => {
1183
+ await delay();
1184
+ return HttpResponse.json(MOCK_AGENT_SETTINGS_SCHEMA);
1185
+ }),
1186
+
1187
+ http.post("*/api/v1/settings", async ({ request }) => {
1188
+ await delay();
1189
+ const body = (await request.json()) as Record<string, unknown> | null;
1190
+
1191
+ if (body) {
1192
+ const current =
1193
+ MOCK_USER_PREFERENCES.settings ||
1194
+ structuredClone(MOCK_DEFAULT_USER_SETTINGS);
1195
+
1196
+ if ("agent_settings" in body || "conversation_settings" in body) {
1197
+ return HttpResponse.json(
1198
+ {
1199
+ error: "Use *_diff nested settings payloads",
1200
+ keys: ["agent_settings", "conversation_settings"].filter(
1201
+ (key) => key in body,
1202
+ ),
1203
+ },
1204
+ { status: 422 },
1205
+ );
1206
+ }
1207
+
1208
+ const nextSettings: Settings = { ...current };
1209
+
1210
+ const agentSettingsPatch = body.agent_settings_diff as
1211
+ | Record<string, unknown>
1212
+ | undefined;
1213
+ if (agentSettingsPatch) {
1214
+ const merged = deepMerge(
1215
+ (current.agent_settings ?? {}) as Record<string, unknown>,
1216
+ agentSettingsPatch,
1217
+ );
1218
+ nextSettings.agent_settings = merged as Settings["agent_settings"];
1219
+ }
1220
+
1221
+ const conversationSettingsPatch = body.conversation_settings_diff as
1222
+ | Record<string, SettingsValue>
1223
+ | undefined;
1224
+ if (conversationSettingsPatch) {
1225
+ nextSettings.conversation_settings = {
1226
+ ...(current.conversation_settings ?? {}),
1227
+ ...conversationSettingsPatch,
1228
+ };
1229
+ }
1230
+
1231
+ // Apply top-level fields (excluding nested settings)
1232
+ for (const [key, value] of Object.entries(body)) {
1233
+ if (
1234
+ key !== "agent_settings_diff" &&
1235
+ key !== "conversation_settings_diff" &&
1236
+ key !== "agent_settings_schema" &&
1237
+ key !== "conversation_settings_schema"
1238
+ ) {
1239
+ (nextSettings as Record<string, unknown>)[key] = value;
1240
+ }
1241
+ }
1242
+
1243
+ MOCK_USER_PREFERENCES.settings = nextSettings;
1244
+ return HttpResponse.json(null, { status: 200 });
1245
+ }
1246
+
1247
+ return HttpResponse.json(null, { status: 400 });
1248
+ }),
1249
+
1250
+ // POST /api/mcp/test – MCP server connectivity check before install.
1251
+ // Returns ok:true so the install modal can proceed to save and close.
1252
+ http.post("*/api/mcp/test", async () => {
1253
+ await delay();
1254
+ return HttpResponse.json({ ok: true, tools: [] });
1255
+ }),
1256
+ ];
src/mocks/should-start-mock-worker.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export function shouldStartMockWorker({
2
+ mockApi = import.meta.env.VITE_MOCK_API,
3
+ hasWindow = typeof window !== "undefined",
4
+ }: {
5
+ mockApi?: string;
6
+ hasWindow?: boolean;
7
+ } = {}): boolean {
8
+ return hasWindow && mockApi === "true";
9
+ }
src/mocks/workspaces-handlers.ts ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { http, HttpResponse } from "msw";
2
+ import type {
3
+ WorkspaceItem,
4
+ WorkspaceParentItem,
5
+ WorkspacesListResponse,
6
+ } from "@openhands/typescript-client/clients";
7
+
8
+ /**
9
+ * MSW handlers for the agent-server's `/api/workspaces` and
10
+ * `/api/auth/workspace-session` endpoints used by `useLocalWorkspaces`,
11
+ * `WorkspacesService`, and `useWorkspaceSession`.
12
+ *
13
+ * Without these handlers, mock-mode renders of the home / sidebar /
14
+ * conversation pages let those requests fall through to the Vite proxy
15
+ * (`127.0.0.1:8000` ECONNREFUSED), which spams logs and surfaces
16
+ * global error toasts in mock-mode screens.
17
+ *
18
+ * The mock keeps an in-memory list so the install / remove flow round-trips
19
+ * cleanly within a single page load. State resets on every test reload.
20
+ */
21
+
22
+ let workspaces: WorkspaceItem[] = [];
23
+ let workspaceParents: WorkspaceParentItem[] = [];
24
+
25
+ function snapshot(): WorkspacesListResponse {
26
+ return {
27
+ workspaces: workspaces.map((w) => ({ ...w })),
28
+ workspaceParents: workspaceParents.map((p) => ({ ...p })),
29
+ };
30
+ }
31
+
32
+ export function resetMockWorkspaces(): void {
33
+ workspaces = [];
34
+ workspaceParents = [];
35
+ }
36
+
37
+ export const WORKSPACES_HANDLERS = [
38
+ http.get("*/api/workspaces", async () => HttpResponse.json(snapshot())),
39
+
40
+ http.post("*/api/workspaces", async ({ request }) => {
41
+ // `workspaces` is the top-level key used by WorkspacesClient.addWorkspaces()
42
+ // in @openhands/typescript-client — aligns with the agent-server SDK contract.
43
+ const body = (await request.json()) as { workspaces?: WorkspaceItem[] };
44
+ for (const incoming of body.workspaces ?? []) {
45
+ const existingIndex = workspaces.findIndex(
46
+ (w) => w.path === incoming.path,
47
+ );
48
+ if (existingIndex >= 0) {
49
+ workspaces[existingIndex] = { ...incoming };
50
+ } else {
51
+ workspaces.push({ ...incoming });
52
+ }
53
+ }
54
+ return HttpResponse.json(snapshot());
55
+ }),
56
+
57
+ http.delete("*/api/workspaces", async ({ request }) => {
58
+ const url = new URL(request.url);
59
+ const path = url.searchParams.get("path");
60
+ const before = workspaces.length;
61
+ workspaces = workspaces.filter((w) => w.path !== path);
62
+ // WorkspacesService.removeWorkspace() discards the response body (returns
63
+ // void), so the shape here is mock-only and not coupled to the real contract.
64
+ return HttpResponse.json({ deleted: workspaces.length !== before });
65
+ }),
66
+
67
+ http.post("*/api/workspaces/parents", async ({ request }) => {
68
+ const body = (await request.json()) as { parents?: WorkspaceParentItem[] };
69
+ for (const incoming of body.parents ?? []) {
70
+ const existingIndex = workspaceParents.findIndex(
71
+ (p) => p.path === incoming.path,
72
+ );
73
+ if (existingIndex >= 0) {
74
+ workspaceParents[existingIndex] = { ...incoming };
75
+ } else {
76
+ workspaceParents.push({ ...incoming });
77
+ }
78
+ }
79
+ return HttpResponse.json(snapshot());
80
+ }),
81
+
82
+ http.delete("*/api/workspaces/parents", async ({ request }) => {
83
+ const url = new URL(request.url);
84
+ const path = url.searchParams.get("path");
85
+ const before = workspaceParents.length;
86
+ workspaceParents = workspaceParents.filter((p) => p.path !== path);
87
+ // WorkspacesService.removeWorkspaceParent() discards the response body
88
+ // (returns void), so the shape here is mock-only and not coupled to the
89
+ // real contract.
90
+ return HttpResponse.json({ deleted: workspaceParents.length !== before });
91
+ }),
92
+
93
+ // Workspace static-asset session: minted on conversation pages by
94
+ // `useWorkspaceSession`. The real endpoint sets an HttpOnly cookie; in
95
+ // mock mode we just acknowledge the POST/DELETE so the call doesn't fall
96
+ // through to the Vite proxy.
97
+ http.post("*/api/auth/workspace-session", async () =>
98
+ HttpResponse.json({ ok: true }),
99
+ ),
100
+
101
+ http.delete(
102
+ "*/api/auth/workspace-session",
103
+ async () => new HttpResponse(null, { status: 204 }),
104
+ ),
105
+ ];
src/stores/agent-store.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { AgentState } from "#/types/agent-state";
3
+
4
+ interface AgentStateData {
5
+ curAgentState: AgentState;
6
+ }
7
+
8
+ interface AgentStore extends AgentStateData {
9
+ setCurrentAgentState: (state: AgentState) => void;
10
+ reset: () => void;
11
+ }
12
+
13
+ const initialState: AgentStateData = {
14
+ curAgentState: AgentState.LOADING,
15
+ };
16
+
17
+ export const useAgentStore = create<AgentStore>((set) => ({
18
+ ...initialState,
19
+ setCurrentAgentState: (state: AgentState) => set({ curAgentState: state }),
20
+ reset: () => set(initialState),
21
+ }));
src/stores/archived-conversations-store.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { persist, createJSONStorage } from "zustand/middleware";
3
+
4
+ export const ARCHIVED_CONVERSATIONS_STORAGE_KEY = "archived-conversations";
5
+
6
+ interface ArchivedConversationsState {
7
+ archivesByBackendId: Record<string, string[]>;
8
+ }
9
+
10
+ interface ArchivedConversationsActions {
11
+ archiveConversation: (backendId: string, conversationId: string) => void;
12
+ removeArchivedConversation: (
13
+ backendId: string,
14
+ conversationId: string,
15
+ ) => void;
16
+ isArchived: (backendId: string, conversationId: string) => boolean;
17
+ }
18
+
19
+ type ArchivedConversationsStore = ArchivedConversationsState &
20
+ ArchivedConversationsActions;
21
+
22
+ const initialState: ArchivedConversationsState = {
23
+ archivesByBackendId: {},
24
+ };
25
+
26
+ function getArchivesForBackend(
27
+ archivesByBackendId: Record<string, string[]>,
28
+ backendId: string,
29
+ ): string[] {
30
+ return archivesByBackendId[backendId] ?? [];
31
+ }
32
+
33
+ export const useArchivedConversationsStore =
34
+ create<ArchivedConversationsStore>()(
35
+ persist(
36
+ (set, get) => ({
37
+ ...initialState,
38
+
39
+ archiveConversation: (backendId, conversationId) => {
40
+ const current = getArchivesForBackend(
41
+ get().archivesByBackendId,
42
+ backendId,
43
+ );
44
+ if (current.includes(conversationId)) {
45
+ return;
46
+ }
47
+ set((state) => ({
48
+ archivesByBackendId: {
49
+ ...state.archivesByBackendId,
50
+ [backendId]: [conversationId, ...current],
51
+ },
52
+ }));
53
+ },
54
+
55
+ removeArchivedConversation: (backendId, conversationId) => {
56
+ const current = getArchivesForBackend(
57
+ get().archivesByBackendId,
58
+ backendId,
59
+ );
60
+ if (!current.includes(conversationId)) {
61
+ return;
62
+ }
63
+ set((state) => ({
64
+ archivesByBackendId: {
65
+ ...state.archivesByBackendId,
66
+ [backendId]: current.filter((id) => id !== conversationId),
67
+ },
68
+ }));
69
+ },
70
+
71
+ isArchived: (backendId, conversationId) =>
72
+ getArchivesForBackend(get().archivesByBackendId, backendId).includes(
73
+ conversationId,
74
+ ),
75
+ }),
76
+ {
77
+ name: ARCHIVED_CONVERSATIONS_STORAGE_KEY,
78
+ storage: createJSONStorage(() => localStorage),
79
+ partialize: (state): ArchivedConversationsState => ({
80
+ archivesByBackendId: state.archivesByBackendId,
81
+ }),
82
+ },
83
+ ),
84
+ );
src/stores/browser-store.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+
3
+ interface BrowserState {
4
+ // URL of the last page the agent navigated to in the browser panel.
5
+ url: string;
6
+ // Base64-encoded screenshot of the browser window, when the tool provides one.
7
+ screenshotSrc: string;
8
+ }
9
+
10
+ interface BrowserStore extends BrowserState {
11
+ setUrl: (url: string) => void;
12
+ setScreenshotSrc: (screenshotSrc: string) => void;
13
+ reset: () => void;
14
+ }
15
+
16
+ const initialState: BrowserState = {
17
+ url: "",
18
+ screenshotSrc: "",
19
+ };
20
+
21
+ export const useBrowserStore = create<BrowserStore>((set) => ({
22
+ ...initialState,
23
+ setUrl: (url: string) => set({ url }),
24
+ setScreenshotSrc: (screenshotSrc: string) => set({ screenshotSrc }),
25
+ reset: () => set(initialState),
26
+ }));
src/stores/btw-store.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { devtools } from "zustand/middleware";
3
+ import { v4 as uuidv4 } from "uuid";
4
+
5
+ export type BtwStatus = "pending" | "done" | "error";
6
+
7
+ export interface BtwEntry {
8
+ id: string;
9
+ question: string;
10
+ response?: string;
11
+ status: BtwStatus;
12
+ }
13
+
14
+ interface BtwState {
15
+ entriesByConversation: Record<string, BtwEntry[]>;
16
+ }
17
+
18
+ interface BtwActions {
19
+ addPending: (conversationId: string, question: string) => string;
20
+ resolve: (conversationId: string, id: string, response: string) => void;
21
+ fail: (conversationId: string, id: string, error: string) => void;
22
+ dismiss: (conversationId: string, id: string) => void;
23
+ }
24
+
25
+ type BtwStore = BtwState & BtwActions;
26
+
27
+ const initialState: BtwState = { entriesByConversation: {} };
28
+
29
+ const updateEntries = (
30
+ state: BtwState,
31
+ conversationId: string,
32
+ updater: (entries: BtwEntry[]) => BtwEntry[],
33
+ ): Pick<BtwState, "entriesByConversation"> => ({
34
+ entriesByConversation: {
35
+ ...state.entriesByConversation,
36
+ [conversationId]: updater(
37
+ state.entriesByConversation[conversationId] ?? [],
38
+ ),
39
+ },
40
+ });
41
+
42
+ export const useBtwStore = create<BtwStore>()(
43
+ devtools(
44
+ (set) => ({
45
+ ...initialState,
46
+ addPending: (conversationId, question) => {
47
+ const id = uuidv4();
48
+ set((s) =>
49
+ updateEntries(s, conversationId, (entries) => [
50
+ ...entries,
51
+ { id, question, status: "pending" },
52
+ ]),
53
+ );
54
+ return id;
55
+ },
56
+ resolve: (conversationId, id, response) =>
57
+ set((s) =>
58
+ updateEntries(s, conversationId, (entries) =>
59
+ entries.map((e) =>
60
+ e.id === id ? { ...e, response, status: "done" } : e,
61
+ ),
62
+ ),
63
+ ),
64
+ fail: (conversationId, id, error) =>
65
+ set((s) =>
66
+ updateEntries(s, conversationId, (entries) =>
67
+ entries.map((e) =>
68
+ e.id === id ? { ...e, response: error, status: "error" } : e,
69
+ ),
70
+ ),
71
+ ),
72
+ dismiss: (conversationId, id) =>
73
+ set((s) =>
74
+ updateEntries(s, conversationId, (entries) =>
75
+ entries.filter((e) => e.id !== id),
76
+ ),
77
+ ),
78
+ }),
79
+ { name: "BtwStore" },
80
+ ),
81
+ );
src/stores/command-menu-store.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+
3
+ interface CommandMenuState {
4
+ isOpen: boolean;
5
+ open: () => void;
6
+ close: () => void;
7
+ toggle: () => void;
8
+ }
9
+
10
+ export const useCommandMenuStore = create<CommandMenuState>((set) => ({
11
+ isOpen: false,
12
+ open: () => set({ isOpen: true }),
13
+ close: () => set({ isOpen: false }),
14
+ toggle: () => set((state) => ({ isOpen: !state.isOpen })),
15
+ }));
src/stores/command-store.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+
3
+ export type Command = {
4
+ content: string;
5
+ type: "input" | "output";
6
+ };
7
+
8
+ interface CommandState {
9
+ commands: Command[];
10
+ appendInput: (content: string) => void;
11
+ appendOutput: (content: string) => void;
12
+ clearTerminal: () => void;
13
+ }
14
+
15
+ export const useCommandStore = create<CommandState>((set) => ({
16
+ commands: [],
17
+ appendInput: (content: string) =>
18
+ set((state) => ({
19
+ commands: [...state.commands, { content, type: "input" }],
20
+ })),
21
+ appendOutput: (content: string) =>
22
+ set((state) => ({
23
+ commands: [...state.commands, { content, type: "output" }],
24
+ })),
25
+ clearTerminal: () => set({ commands: [] }),
26
+ }));
src/stores/conversation-panel-preferences-store.ts ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { persist, createJSONStorage } from "zustand/middleware";
3
+ import {
4
+ DEFAULT_OLDER_CONVERSATION_CUTOFF,
5
+ isOlderConversationCutoff,
6
+ type AutomationFilterMode,
7
+ type ConversationSortField,
8
+ type OlderConversationCutoff,
9
+ type OrganizeMode,
10
+ type ThreadScope,
11
+ } from "#/components/features/conversation-panel/conversation-panel-list-helpers";
12
+
13
+ /**
14
+ * User-toggleable display preferences for the sidebar conversation list
15
+ * (the layouts menu and the advanced-options modal behind it). These are
16
+ * intentionally persisted to localStorage (via the same `zustand/persist`
17
+ * pattern used by `home-store` and `workspaces-store`) so the menu state
18
+ * survives full reloads.
19
+ *
20
+ * Every persisted narrowing field must have a control that can see and undo
21
+ * it, or a reload silently hides conversations with nothing on screen to say
22
+ * why — see `ConversationActiveTagFilters`.
23
+ *
24
+ * To add a new preference exposed by those menus:
25
+ * 1. Add a field here with a sensible default in `initialState`.
26
+ * 2. Add matching `setX`/`toggleX` actions below.
27
+ * 3. Read/write through the store in `conversation-panel.tsx`.
28
+ * No additional plumbing (storage keys, sanitization, etc.) is required —
29
+ * `persist` handles migration of unknown fields gracefully.
30
+ */
31
+ interface ConversationPanelPreferencesState {
32
+ showOlderConversations: boolean;
33
+ /**
34
+ * Age threshold used when `showOlderConversations` is false. Conversations
35
+ * last updated before this interval are hidden.
36
+ */
37
+ olderConversationCutoff: OlderConversationCutoff;
38
+ showArchivedConversations: boolean;
39
+ showRepoBranchMetadata: boolean;
40
+ showLlmProfiles: boolean;
41
+ showTagsMetadata: boolean;
42
+ showHoverMetadata: boolean;
43
+ organizeMode: OrganizeMode;
44
+ conversationSort: ConversationSortField;
45
+ threadScope: ThreadScope;
46
+ automationFilterMode: AutomationFilterMode;
47
+ selectedAutomationNames: string[];
48
+ selectedTagFacets: string[];
49
+ groupFolderOrder: string[];
50
+ }
51
+
52
+ /** The complete preference bundle controlled by conversation layout presets. */
53
+ export type LayoutSettingsSlice = Pick<
54
+ ConversationPanelPreferencesState,
55
+ | "organizeMode"
56
+ | "conversationSort"
57
+ | "threadScope"
58
+ | "showOlderConversations"
59
+ | "showRepoBranchMetadata"
60
+ | "showLlmProfiles"
61
+ | "showTagsMetadata"
62
+ | "showHoverMetadata"
63
+ >;
64
+
65
+ export const DEFAULT_LAYOUT_SETTINGS: LayoutSettingsSlice = {
66
+ organizeMode: "chronological",
67
+ conversationSort: "updated",
68
+ threadScope: "all",
69
+ showOlderConversations: true,
70
+ showRepoBranchMetadata: false,
71
+ showLlmProfiles: false,
72
+ showTagsMetadata: true,
73
+ showHoverMetadata: true,
74
+ };
75
+
76
+ interface ConversationPanelPreferencesActions {
77
+ setShowOlderConversations: (value: boolean) => void;
78
+ toggleShowOlderConversations: () => void;
79
+ setOlderConversationCutoff: (value: OlderConversationCutoff) => void;
80
+ setShowArchivedConversations: (value: boolean) => void;
81
+ toggleShowArchivedConversations: () => void;
82
+ setShowRepoBranchMetadata: (value: boolean) => void;
83
+ toggleShowRepoBranchMetadata: () => void;
84
+ setShowLlmProfiles: (value: boolean) => void;
85
+ toggleShowLlmProfiles: () => void;
86
+ setShowTagsMetadata: (value: boolean) => void;
87
+ toggleShowTagsMetadata: () => void;
88
+ setShowHoverMetadata: (value: boolean) => void;
89
+ toggleShowHoverMetadata: () => void;
90
+ setOrganizeMode: (value: OrganizeMode) => void;
91
+ setConversationSort: (value: ConversationSortField) => void;
92
+ setThreadScope: (value: ThreadScope) => void;
93
+ setAutomationFilterMode: (value: AutomationFilterMode) => void;
94
+ toggleAutomationName: (name: string) => void;
95
+ /**
96
+ * Clears both facet selections (tags and automation names) — the two
97
+ * narrowings the active-filter strip renders as chips.
98
+ *
99
+ * Deliberately leaves `automationFilterMode` alone: the strip shows no chip
100
+ * for the mode, and it must not silently switch a surface it doesn't show.
101
+ * The mode keeps its own rows in the advanced-options modal.
102
+ */
103
+ clearFilterSelections: () => void;
104
+ toggleTagFacet: (facet: string) => void;
105
+ /** Applies a layout preset's partial bundle in one set(). */
106
+ applyLayoutSettings: (settings: Partial<LayoutSettingsSlice>) => void;
107
+ setGroupFolderOrder: (order: readonly string[]) => void;
108
+ }
109
+
110
+ type ConversationPanelPreferencesStore = ConversationPanelPreferencesState &
111
+ ConversationPanelPreferencesActions;
112
+
113
+ const initialState: ConversationPanelPreferencesState = {
114
+ ...DEFAULT_LAYOUT_SETTINGS,
115
+ olderConversationCutoff: DEFAULT_OLDER_CONVERSATION_CUTOFF,
116
+ showArchivedConversations: false,
117
+ automationFilterMode: "all",
118
+ selectedAutomationNames: [],
119
+ selectedTagFacets: [],
120
+ groupFolderOrder: [],
121
+ };
122
+
123
+ export const useConversationPanelPreferencesStore =
124
+ create<ConversationPanelPreferencesStore>()(
125
+ persist(
126
+ (set) => ({
127
+ ...initialState,
128
+
129
+ setShowOlderConversations: (value) =>
130
+ set(() => ({ showOlderConversations: value })),
131
+ toggleShowOlderConversations: () =>
132
+ set((state) => ({
133
+ showOlderConversations: !state.showOlderConversations,
134
+ })),
135
+ setOlderConversationCutoff: (value) =>
136
+ set(() => ({
137
+ olderConversationCutoff: isOlderConversationCutoff(value)
138
+ ? value
139
+ : DEFAULT_OLDER_CONVERSATION_CUTOFF,
140
+ })),
141
+
142
+ setShowArchivedConversations: (value) =>
143
+ set(() => ({ showArchivedConversations: value })),
144
+ toggleShowArchivedConversations: () =>
145
+ set((state) => ({
146
+ showArchivedConversations: !state.showArchivedConversations,
147
+ })),
148
+
149
+ setShowRepoBranchMetadata: (value) =>
150
+ set(() => ({ showRepoBranchMetadata: value })),
151
+ toggleShowRepoBranchMetadata: () =>
152
+ set((state) => ({
153
+ showRepoBranchMetadata: !state.showRepoBranchMetadata,
154
+ })),
155
+
156
+ setShowLlmProfiles: (value) => set(() => ({ showLlmProfiles: value })),
157
+ toggleShowLlmProfiles: () =>
158
+ set((state) => ({
159
+ showLlmProfiles: !state.showLlmProfiles,
160
+ })),
161
+
162
+ setShowTagsMetadata: (value) =>
163
+ set(() => ({ showTagsMetadata: value })),
164
+ toggleShowTagsMetadata: () =>
165
+ set((state) => ({
166
+ showTagsMetadata: !state.showTagsMetadata,
167
+ })),
168
+
169
+ setShowHoverMetadata: (value) =>
170
+ set(() => ({ showHoverMetadata: value })),
171
+ toggleShowHoverMetadata: () =>
172
+ set((state) => ({
173
+ showHoverMetadata: !state.showHoverMetadata,
174
+ })),
175
+
176
+ setOrganizeMode: (value) => set(() => ({ organizeMode: value })),
177
+ setConversationSort: (value) =>
178
+ set(() => ({ conversationSort: value })),
179
+ setThreadScope: (value) => set(() => ({ threadScope: value })),
180
+ setAutomationFilterMode: (value) =>
181
+ set((state) => ({
182
+ automationFilterMode: value,
183
+ // Name selections only make sense in only-automations mode;
184
+ // leaving the mode clears them (self-healing — a stale selection
185
+ // must never silently narrow an unfiltered-looking list).
186
+ selectedAutomationNames:
187
+ value === "only-automations" ? state.selectedAutomationNames : [],
188
+ })),
189
+ toggleAutomationName: (name) =>
190
+ set((state) => ({
191
+ selectedAutomationNames: state.selectedAutomationNames.includes(
192
+ name,
193
+ )
194
+ ? state.selectedAutomationNames.filter(
195
+ (existing) => existing !== name,
196
+ )
197
+ : [...state.selectedAutomationNames, name],
198
+ })),
199
+ clearFilterSelections: () =>
200
+ set(() => ({
201
+ selectedTagFacets: [],
202
+ selectedAutomationNames: [],
203
+ })),
204
+ toggleTagFacet: (facet) =>
205
+ set((state) => ({
206
+ selectedTagFacets: state.selectedTagFacets.includes(facet)
207
+ ? state.selectedTagFacets.filter((existing) => existing !== facet)
208
+ : [...state.selectedTagFacets, facet],
209
+ })),
210
+ setGroupFolderOrder: (order) =>
211
+ set(() => ({ groupFolderOrder: [...order] })),
212
+
213
+ applyLayoutSettings: (settings) => set(() => ({ ...settings })),
214
+ }),
215
+ {
216
+ name: "conversation-panel-preferences",
217
+ storage: createJSONStorage(() => localStorage),
218
+ // Only persist the data fields — actions are recreated on each load.
219
+ partialize: (state): ConversationPanelPreferencesState => ({
220
+ showOlderConversations: state.showOlderConversations,
221
+ olderConversationCutoff: isOlderConversationCutoff(
222
+ state.olderConversationCutoff,
223
+ )
224
+ ? state.olderConversationCutoff
225
+ : DEFAULT_OLDER_CONVERSATION_CUTOFF,
226
+ showArchivedConversations: state.showArchivedConversations,
227
+ showRepoBranchMetadata: state.showRepoBranchMetadata,
228
+ showLlmProfiles: state.showLlmProfiles,
229
+ showTagsMetadata: state.showTagsMetadata,
230
+ showHoverMetadata: state.showHoverMetadata,
231
+ organizeMode: state.organizeMode,
232
+ conversationSort: state.conversationSort,
233
+ threadScope: state.threadScope,
234
+ automationFilterMode: state.automationFilterMode,
235
+ selectedAutomationNames: state.selectedAutomationNames,
236
+ selectedTagFacets: state.selectedTagFacets,
237
+ groupFolderOrder: state.groupFolderOrder,
238
+ }),
239
+ },
240
+ ),
241
+ );
src/stores/conversation-state-store.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { ExecutionStatus } from "#/types/agent-server/core/base/common";
3
+
4
+ interface ConversationStateStore {
5
+ /**
6
+ * Latest execution status per conversation, fed by the main and planning
7
+ * WebSocket handlers. Scoped by conversation id so the planning helper
8
+ * conversation's own run/idle transitions can never overwrite the main
9
+ * conversation's status (or vice versa) — see conversation-websocket-context.tsx.
10
+ */
11
+ executionStatusByConversation: Record<string, ExecutionStatus>;
12
+
13
+ /**
14
+ * Set the agent status for a specific conversation.
15
+ */
16
+ setExecutionStatus: (
17
+ conversationId: string,
18
+ execution_status: ExecutionStatus,
19
+ ) => void;
20
+
21
+ /**
22
+ * Reset the store to initial state
23
+ */
24
+ reset: () => void;
25
+ }
26
+
27
+ export const useConversationStateStore = create<ConversationStateStore>(
28
+ (set) => ({
29
+ executionStatusByConversation: {},
30
+
31
+ setExecutionStatus: (conversationId, execution_status) =>
32
+ set((state) => ({
33
+ executionStatusByConversation: {
34
+ ...state.executionStatusByConversation,
35
+ [conversationId]: execution_status,
36
+ },
37
+ })),
38
+
39
+ reset: () => set({ executionStatusByConversation: {} }),
40
+ }),
41
+ );
src/stores/conversation-store.ts ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { devtools } from "zustand/middleware";
3
+ import {
4
+ getConversationState,
5
+ setConversationState,
6
+ } from "#/utils/conversation-local-storage";
7
+
8
+ export type ConversationTab =
9
+ | "files"
10
+ | "commits"
11
+ | "browser"
12
+ | "terminal"
13
+ | "planner"
14
+ | "tasklist"
15
+ | "usage";
16
+
17
+ export type ConversationMode = "code" | "plan";
18
+
19
+ export type CommitsPaneSection = "uncommitted";
20
+
21
+ export interface IMessageToSend {
22
+ text: string;
23
+ timestamp: number;
24
+ }
25
+
26
+ interface ConversationState {
27
+ isRightPanelShown: boolean;
28
+ isOverviewPanelShown: boolean;
29
+ isOverviewPanelPeeked: boolean;
30
+ selectedTab: ConversationTab | null;
31
+ commitsAutoExpandSection: CommitsPaneSection | null;
32
+ images: File[];
33
+ files: File[];
34
+ /** Image file names (e.g. pasted screenshots) to send via file upload instead of vision embed. */
35
+ imagesMarkedUploadAsFile: string[];
36
+ /** Image file names attached in chat (controls per-image upload-as-file UI). */
37
+ pastedImageNames: string[];
38
+ loadingFiles: string[]; // File names currently being processed
39
+ loadingImages: string[]; // Image names currently being processed
40
+ messageToSend: IMessageToSend | null;
41
+ /** One-shot restore request consumed by the chat input when empty. */
42
+ messageRestoreIfEmpty: IMessageToSend | null;
43
+ shouldShownAgentLoading: boolean;
44
+ submittedMessage: string | null;
45
+ shouldHideSuggestions: boolean; // New state to hide suggestions when input expands
46
+ hasRightPanelToggled: boolean;
47
+ planContent: string | null;
48
+ conversationMode: ConversationMode;
49
+ subConversationTaskId: string | null; // Task ID for cloud sub-conversation creation
50
+ localPlanningConversationId: string | null;
51
+ }
52
+
53
+ interface ConversationActions {
54
+ setIsRightPanelShown: (isRightPanelShown: boolean) => void;
55
+ setIsOverviewPanelShown: (isOverviewPanelShown: boolean) => void;
56
+ setIsOverviewPanelPeeked: (isOverviewPanelPeeked: boolean) => void;
57
+ setSelectedTab: (selectedTab: ConversationTab | null) => void;
58
+ setCommitsAutoExpandSection: (
59
+ commitsAutoExpandSection: CommitsPaneSection | null,
60
+ ) => void;
61
+ setShouldShownAgentLoading: (shouldShownAgentLoading: boolean) => void;
62
+ setShouldHideSuggestions: (shouldHideSuggestions: boolean) => void;
63
+ addImages: (images: File[]) => void;
64
+ addFiles: (files: File[]) => void;
65
+ toggleImageUploadAsFile: (fileName: string) => void;
66
+ markImagesAsPasted: (fileNames: string[]) => void;
67
+ removeImage: (index: number) => void;
68
+ removeFile: (index: number) => void;
69
+ clearImages: () => void;
70
+ clearFiles: () => void;
71
+ clearAllFiles: () => void;
72
+ addFileLoading: (fileName: string) => void;
73
+ removeFileLoading: (fileName: string) => void;
74
+ addImageLoading: (imageName: string) => void;
75
+ removeImageLoading: (imageName: string) => void;
76
+ clearAllLoading: () => void;
77
+ setMessageToSend: (text: string) => void;
78
+ clearMessageToSend: () => void;
79
+ restoreMessageToInputIfEmpty: (text: string) => void;
80
+ clearMessageRestoreIfEmpty: () => void;
81
+ setSubmittedMessage: (message: string | null) => void;
82
+ resetConversationState: () => void;
83
+ setHasRightPanelToggled: (hasRightPanelToggled: boolean) => void;
84
+ setConversationMode: (conversationMode: ConversationMode) => void;
85
+ setSubConversationTaskId: (taskId: string | null) => void;
86
+ setLocalPlanningConversationId: (conversationId: string | null) => void;
87
+ setPlanContent: (planContent: string | null) => void;
88
+ }
89
+
90
+ type ConversationStore = ConversationState & ConversationActions;
91
+
92
+ const getConversationIdFromLocation = (): string | null => {
93
+ if (typeof window === "undefined") {
94
+ return null;
95
+ }
96
+
97
+ const match = window.location.pathname.match(/\/conversations\/([^/]+)/);
98
+ return match ? match[1] : null;
99
+ };
100
+
101
+ const getInitialConversationMode = (): ConversationMode => {
102
+ if (typeof window === "undefined") {
103
+ return "code";
104
+ }
105
+
106
+ const conversationId = getConversationIdFromLocation();
107
+ if (!conversationId) {
108
+ return "code";
109
+ }
110
+
111
+ const state = getConversationState(conversationId);
112
+ return state.conversationMode;
113
+ };
114
+
115
+ export const useConversationStore = create<ConversationStore>()(
116
+ devtools(
117
+ (set) => ({
118
+ // Initial state.
119
+ //
120
+ // The right-side drawer (`isRightPanelShown` / `hasRightPanelToggled`)
121
+ // is intentionally *session-only* state: it always starts closed on
122
+ // app load (or on opening a fresh/existing conversation after a
123
+ // restart), but it survives in-app navigation because the Zustand
124
+ // store stays alive across React Router transitions. Persisting the
125
+ // open/closed state in localStorage made the panel feel sticky in
126
+ // a way users didn't expect — they want a clean, focused chat view
127
+ // when they come back to the app and only want the panel back when
128
+ // they themselves opened it during the current session.
129
+ isRightPanelShown: false,
130
+ isOverviewPanelShown: false,
131
+ isOverviewPanelPeeked: false,
132
+ selectedTab: "files" as ConversationTab,
133
+ commitsAutoExpandSection: null,
134
+ images: [],
135
+ files: [],
136
+ imagesMarkedUploadAsFile: [],
137
+ pastedImageNames: [],
138
+ loadingFiles: [],
139
+ loadingImages: [],
140
+ messageToSend: null,
141
+ messageRestoreIfEmpty: null,
142
+ shouldShownAgentLoading: false,
143
+ submittedMessage: null,
144
+ shouldHideSuggestions: false,
145
+ hasRightPanelToggled: false,
146
+ planContent: null,
147
+ conversationMode: getInitialConversationMode(),
148
+ subConversationTaskId: null,
149
+ localPlanningConversationId: null,
150
+
151
+ // Actions
152
+ setIsRightPanelShown: (isRightPanelShown) =>
153
+ set({ isRightPanelShown }, false, "setIsRightPanelShown"),
154
+
155
+ setIsOverviewPanelShown: (isOverviewPanelShown) =>
156
+ set(
157
+ { isOverviewPanelShown, isOverviewPanelPeeked: false },
158
+ false,
159
+ "setIsOverviewPanelShown",
160
+ ),
161
+
162
+ setIsOverviewPanelPeeked: (isOverviewPanelPeeked) =>
163
+ set({ isOverviewPanelPeeked }, false, "setIsOverviewPanelPeeked"),
164
+
165
+ setSelectedTab: (selectedTab) =>
166
+ set({ selectedTab }, false, "setSelectedTab"),
167
+
168
+ setCommitsAutoExpandSection: (commitsAutoExpandSection) =>
169
+ set({ commitsAutoExpandSection }, false, "setCommitsAutoExpandSection"),
170
+
171
+ setShouldShownAgentLoading: (shouldShownAgentLoading) =>
172
+ set({ shouldShownAgentLoading }, false, "setShouldShownAgentLoading"),
173
+
174
+ setShouldHideSuggestions: (shouldHideSuggestions) =>
175
+ set({ shouldHideSuggestions }, false, "setShouldHideSuggestions"),
176
+
177
+ addImages: (images) =>
178
+ set(
179
+ (state) => ({ images: [...state.images, ...images] }),
180
+ false,
181
+ "addImages",
182
+ ),
183
+
184
+ addFiles: (files) =>
185
+ set(
186
+ (state) => ({ files: [...state.files, ...files] }),
187
+ false,
188
+ "addFiles",
189
+ ),
190
+
191
+ toggleImageUploadAsFile: (fileName) =>
192
+ set(
193
+ (state) => {
194
+ const marked = new Set(state.imagesMarkedUploadAsFile);
195
+ if (marked.has(fileName)) {
196
+ marked.delete(fileName);
197
+ } else {
198
+ marked.add(fileName);
199
+ }
200
+ return { imagesMarkedUploadAsFile: [...marked] };
201
+ },
202
+ false,
203
+ "toggleImageUploadAsFile",
204
+ ),
205
+
206
+ markImagesAsPasted: (fileNames) =>
207
+ set(
208
+ (state) => {
209
+ const merged = new Set([...state.pastedImageNames, ...fileNames]);
210
+ return { pastedImageNames: [...merged] };
211
+ },
212
+ false,
213
+ "markImagesAsPasted",
214
+ ),
215
+
216
+ removeImage: (index) =>
217
+ set(
218
+ (state) => {
219
+ const removed = state.images[index];
220
+ const newImages = [...state.images];
221
+ newImages.splice(index, 1);
222
+ return {
223
+ images: newImages,
224
+ imagesMarkedUploadAsFile: removed
225
+ ? state.imagesMarkedUploadAsFile.filter(
226
+ (name) => name !== removed.name,
227
+ )
228
+ : state.imagesMarkedUploadAsFile,
229
+ pastedImageNames: removed
230
+ ? state.pastedImageNames.filter((name) => name !== removed.name)
231
+ : state.pastedImageNames,
232
+ };
233
+ },
234
+ false,
235
+ "removeImage",
236
+ ),
237
+
238
+ removeFile: (index) =>
239
+ set(
240
+ (state) => {
241
+ const newFiles = [...state.files];
242
+ newFiles.splice(index, 1);
243
+ return { files: newFiles };
244
+ },
245
+ false,
246
+ "removeFile",
247
+ ),
248
+
249
+ clearImages: () => set({ images: [] }, false, "clearImages"),
250
+
251
+ clearFiles: () => set({ files: [] }, false, "clearFiles"),
252
+
253
+ clearAllFiles: () =>
254
+ set(
255
+ {
256
+ images: [],
257
+ files: [],
258
+ imagesMarkedUploadAsFile: [],
259
+ pastedImageNames: [],
260
+ loadingFiles: [],
261
+ loadingImages: [],
262
+ },
263
+ false,
264
+ "clearAllFiles",
265
+ ),
266
+
267
+ addFileLoading: (fileName) =>
268
+ set(
269
+ (state) => {
270
+ if (!state.loadingFiles.includes(fileName)) {
271
+ return { loadingFiles: [...state.loadingFiles, fileName] };
272
+ }
273
+ return state;
274
+ },
275
+ false,
276
+ "addFileLoading",
277
+ ),
278
+
279
+ removeFileLoading: (fileName) =>
280
+ set(
281
+ (state) => ({
282
+ loadingFiles: state.loadingFiles.filter(
283
+ (name) => name !== fileName,
284
+ ),
285
+ }),
286
+ false,
287
+ "removeFileLoading",
288
+ ),
289
+
290
+ addImageLoading: (imageName) =>
291
+ set(
292
+ (state) => {
293
+ if (!state.loadingImages.includes(imageName)) {
294
+ return { loadingImages: [...state.loadingImages, imageName] };
295
+ }
296
+ return state;
297
+ },
298
+ false,
299
+ "addImageLoading",
300
+ ),
301
+
302
+ removeImageLoading: (imageName) =>
303
+ set(
304
+ (state) => ({
305
+ loadingImages: state.loadingImages.filter(
306
+ (name) => name !== imageName,
307
+ ),
308
+ }),
309
+ false,
310
+ "removeImageLoading",
311
+ ),
312
+
313
+ clearAllLoading: () =>
314
+ set({ loadingFiles: [], loadingImages: [] }, false, "clearAllLoading"),
315
+
316
+ setMessageToSend: (text) =>
317
+ set(
318
+ {
319
+ messageToSend: {
320
+ text,
321
+ timestamp: Date.now(),
322
+ },
323
+ },
324
+ false,
325
+ "setMessageToSend",
326
+ ),
327
+
328
+ // One-shot consume: clear after the composer applies it, so a never-sent
329
+ // value can't replay into another conversation's composer on remount.
330
+ clearMessageToSend: () =>
331
+ set({ messageToSend: null }, false, "clearMessageToSend"),
332
+
333
+ restoreMessageToInputIfEmpty: (text) =>
334
+ set(
335
+ {
336
+ messageRestoreIfEmpty: {
337
+ text,
338
+ timestamp: Date.now(),
339
+ },
340
+ },
341
+ false,
342
+ "restoreMessageToInputIfEmpty",
343
+ ),
344
+
345
+ clearMessageRestoreIfEmpty: () =>
346
+ set(
347
+ { messageRestoreIfEmpty: null },
348
+ false,
349
+ "clearMessageRestoreIfEmpty",
350
+ ),
351
+
352
+ setSubmittedMessage: (submittedMessage) =>
353
+ set({ submittedMessage }, false, "setSubmittedMessage"),
354
+
355
+ resetConversationState: () =>
356
+ set(
357
+ {
358
+ shouldHideSuggestions: false,
359
+ conversationMode: getInitialConversationMode(),
360
+ subConversationTaskId: null,
361
+ localPlanningConversationId: null,
362
+ planContent: null,
363
+ },
364
+ false,
365
+ "resetConversationState",
366
+ ),
367
+
368
+ setHasRightPanelToggled: (hasRightPanelToggled) =>
369
+ set({ hasRightPanelToggled }, false, "setHasRightPanelToggled"),
370
+
371
+ setConversationMode: (conversationMode) => {
372
+ const conversationId = getConversationIdFromLocation();
373
+ if (conversationId) {
374
+ setConversationState(conversationId, { conversationMode });
375
+ }
376
+ set({ conversationMode }, false, "setConversationMode");
377
+ },
378
+
379
+ setSubConversationTaskId: (subConversationTaskId) =>
380
+ set({ subConversationTaskId }, false, "setSubConversationTaskId"),
381
+
382
+ setLocalPlanningConversationId: (localPlanningConversationId) =>
383
+ set(
384
+ { localPlanningConversationId },
385
+ false,
386
+ "setLocalPlanningConversationId",
387
+ ),
388
+
389
+ setPlanContent: (planContent) =>
390
+ set({ planContent }, false, "setPlanContent"),
391
+ }),
392
+ {
393
+ name: "conversation-store",
394
+ },
395
+ ),
396
+ );
src/stores/error-message-store.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import type { ErrorClassification } from "@openhands/typescript-client";
3
+
4
+ /**
5
+ * "connection" errors auto-clear once connectivity recovers; "conversation"
6
+ * errors (e.g. a wrong API key) are sticky and clear only on an explicit user
7
+ * action (dismiss, retry, new message).
8
+ */
9
+ export type ErrorMessageType = "connection" | "conversation";
10
+
11
+ interface ErrorMessageState {
12
+ errorMessage: string | null;
13
+ errorType: ErrorMessageType | null;
14
+ /**
15
+ * Structured error code from a ConversationErrorEvent/ServerErrorEvent (e.g.
16
+ * "ACPAuthRequired"), used to render a code-specific header + recovery action.
17
+ * null for errors that carry no code (connection errors, plain strings).
18
+ */
19
+ errorCode: string | null;
20
+ errorClassification: ErrorClassification | null;
21
+ }
22
+
23
+ interface ErrorMessageActions {
24
+ setErrorMessage: (
25
+ message: string,
26
+ type?: ErrorMessageType,
27
+ code?: string | null,
28
+ classification?: ErrorClassification | null,
29
+ ) => void;
30
+ removeErrorMessage: () => void;
31
+ /** Clears the error only when it is a transient connection error. */
32
+ clearConnectionError: () => void;
33
+ }
34
+
35
+ type ErrorMessageStore = ErrorMessageState & ErrorMessageActions;
36
+
37
+ const initialState: ErrorMessageState = {
38
+ errorMessage: null,
39
+ errorType: null,
40
+ errorCode: null,
41
+ errorClassification: null,
42
+ };
43
+
44
+ export const useErrorMessageStore = create<ErrorMessageStore>((set) => ({
45
+ ...initialState,
46
+
47
+ setErrorMessage: (
48
+ message: string,
49
+ type: ErrorMessageType = "conversation",
50
+ code: string | null = null,
51
+ classification: ErrorClassification | null = null,
52
+ ) =>
53
+ set(() => ({
54
+ errorMessage: message,
55
+ errorType: type,
56
+ errorCode: code,
57
+ errorClassification: classification,
58
+ })),
59
+
60
+ removeErrorMessage: () => set(() => ({ ...initialState })),
61
+
62
+ clearConnectionError: () =>
63
+ set((state) =>
64
+ state.errorType === "connection" ? { ...initialState } : state,
65
+ ),
66
+ }));
src/stores/event-message-store.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+
3
+ type SubmittedEventId = string | number;
4
+
5
+ interface EventMessageState {
6
+ submittedEventIds: SubmittedEventId[];
7
+ }
8
+
9
+ interface EventMessageStore extends EventMessageState {
10
+ addSubmittedEventId: (id: SubmittedEventId) => void;
11
+ removeSubmittedEventId: (id: SubmittedEventId) => void;
12
+ }
13
+
14
+ export const useEventMessageStore = create<EventMessageStore>((set) => ({
15
+ submittedEventIds: [],
16
+ addSubmittedEventId: (id) =>
17
+ set((state) => ({
18
+ submittedEventIds: [...state.submittedEventIds, id],
19
+ })),
20
+ removeSubmittedEventId: (id) =>
21
+ set((state) => ({
22
+ submittedEventIds: state.submittedEventIds.filter(
23
+ (eventId) => eventId !== id,
24
+ ),
25
+ })),
26
+ }));
src/stores/files-tab-store.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import { setConversationState } from "#/utils/conversation-local-storage";
3
+
4
+ interface FilesTabState {
5
+ selectedPath: string | null;
6
+ // The conversation a selection belongs to. A file picked in one
7
+ // conversation must not leak into another (it usually doesn't exist in the
8
+ // other conversation's workspace, see issue #1350), so every selection is
9
+ // tagged with its conversation and the files tab ignores a path owned by a
10
+ // different conversation.
11
+ selectedConversationId: string | null;
12
+ /**
13
+ * Ordered list of files the user or agent has opened in the current
14
+ * conversation. The quick-row tab strip renders only these paths.
15
+ */
16
+ openPaths: string[];
17
+ setSelectedPath: (
18
+ path: string | null,
19
+ conversationId?: string | null,
20
+ ) => void;
21
+ /** Remove a path from the open-tab strip; selects a neighbor when needed. */
22
+ closeOpenPath: (path: string) => void;
23
+ /**
24
+ * Replace in-memory open-tab state for a conversation (used when mounting
25
+ * / switching conversations so a refresh can restore localStorage).
26
+ * Does not write back to localStorage.
27
+ */
28
+ hydrateForConversation: (
29
+ conversationId: string,
30
+ openPaths: string[],
31
+ selectedPath: string | null,
32
+ ) => void;
33
+ }
34
+
35
+ function withOpenedPath(
36
+ openPaths: string[],
37
+ path: string,
38
+ sameConversation: boolean,
39
+ ): string[] {
40
+ if (!sameConversation) return [path];
41
+ if (openPaths.includes(path)) return openPaths;
42
+ return [...openPaths, path];
43
+ }
44
+
45
+ function selectNeighborAfterClose(
46
+ openPaths: string[],
47
+ closedPath: string,
48
+ selectedPath: string | null,
49
+ ): string | null {
50
+ if (selectedPath !== closedPath) return selectedPath;
51
+ const closedIndex = openPaths.indexOf(closedPath);
52
+ const remaining = openPaths.filter((path) => path !== closedPath);
53
+ if (remaining.length === 0) return null;
54
+ // Prefer the tab that slides into the closed slot (right neighbor), else left.
55
+ return remaining[Math.min(closedIndex, remaining.length - 1)] ?? null;
56
+ }
57
+
58
+ function resolveSelectedPath(
59
+ openPaths: string[],
60
+ selectedPath: string | null,
61
+ ): string | null {
62
+ if (selectedPath && openPaths.includes(selectedPath)) return selectedPath;
63
+ return null;
64
+ }
65
+
66
+ function persistOpenState(
67
+ conversationId: string | null | undefined,
68
+ openPaths: string[],
69
+ selectedPath: string | null,
70
+ ) {
71
+ if (!conversationId) return;
72
+ setConversationState(conversationId, {
73
+ filesTabOpenPaths: openPaths,
74
+ filesTabSelectedPath: selectedPath,
75
+ });
76
+ }
77
+
78
+ // Hoisted out of files-tab.tsx local state so non-React callers (e.g. the
79
+ // canvas_ui tool dispatcher in the WebSocket context) can drive selection.
80
+ export const useFilesTabStore = create<FilesTabState>((set) => ({
81
+ selectedPath: null,
82
+ selectedConversationId: null,
83
+ openPaths: [],
84
+ setSelectedPath: (selectedPath, conversationId = null) =>
85
+ set((state) => {
86
+ if (selectedPath === null) {
87
+ const switchedConversation =
88
+ conversationId !== state.selectedConversationId;
89
+ const next = {
90
+ selectedPath: null as string | null,
91
+ selectedConversationId: conversationId,
92
+ // Drop open tabs when the active conversation changes so paths
93
+ // from conversation A never appear as tabs in conversation B.
94
+ // Callers that switch conversations should prefer
95
+ // `hydrateForConversation` so persisted tabs can be restored.
96
+ openPaths: switchedConversation ? [] : state.openPaths,
97
+ };
98
+ persistOpenState(conversationId, next.openPaths, next.selectedPath);
99
+ return next;
100
+ }
101
+
102
+ const sameConversation = state.selectedConversationId === conversationId;
103
+ const next = {
104
+ selectedPath,
105
+ selectedConversationId: conversationId,
106
+ openPaths: withOpenedPath(
107
+ state.openPaths,
108
+ selectedPath,
109
+ sameConversation,
110
+ ),
111
+ };
112
+ persistOpenState(conversationId, next.openPaths, next.selectedPath);
113
+ return next;
114
+ }),
115
+ closeOpenPath: (path) =>
116
+ set((state) => {
117
+ if (!state.openPaths.includes(path)) return state;
118
+ const selectedPath = selectNeighborAfterClose(
119
+ state.openPaths,
120
+ path,
121
+ state.selectedPath,
122
+ );
123
+ const next = {
124
+ openPaths: state.openPaths.filter((openPath) => openPath !== path),
125
+ selectedPath,
126
+ };
127
+ persistOpenState(
128
+ state.selectedConversationId,
129
+ next.openPaths,
130
+ next.selectedPath,
131
+ );
132
+ return next;
133
+ }),
134
+ hydrateForConversation: (conversationId, openPaths, selectedPath) =>
135
+ set({
136
+ selectedConversationId: conversationId,
137
+ openPaths,
138
+ selectedPath: resolveSelectedPath(openPaths, selectedPath),
139
+ }),
140
+ }));
src/stores/free-models-store.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from "zustand";
2
+ import type { FreeModelSet } from "#/utils/format-model-name";
3
+
4
+ interface FreeModelsState {
5
+ /**
6
+ * DB-driven set of free ``provider/model`` ids (the same channel that carries
7
+ * `verified`). Empty on backends without free metadata (e.g. the local
8
+ * agent-server), so consumers uniformly treat "not in set" as paid.
9
+ */
10
+ freeModels: FreeModelSet;
11
+ /** DB-driven default OpenHands model id (``openhands/<model>``), or null. */
12
+ defaultModel: string | null;
13
+ /** True once the backend flags query has settled at least once. */
14
+ defaultModelReady: boolean;
15
+ }
16
+
17
+ interface FreeModelsActions {
18
+ /**
19
+ * Replaces the DB-driven flags. Called once by the app-level hydrator
20
+ * ({@link useHydrateFreeModels}) after the backend model list is fetched, so
21
+ * leaf components can read the flags synchronously via zustand selectors
22
+ * without needing a QueryClientProvider in scope.
23
+ */
24
+ setFlags: (flags: {
25
+ freeModels: FreeModelSet;
26
+ defaultModel: string | null;
27
+ }) => void;
28
+ markDefaultModelReady: () => void;
29
+ resetFlags: () => void;
30
+ }
31
+
32
+ type FreeModelsStore = FreeModelsState & FreeModelsActions;
33
+
34
+ const EMPTY_FREE_MODELS: FreeModelSet = new Set<string>();
35
+
36
+ export const useFreeModelsStore = create<FreeModelsStore>()((set) => ({
37
+ freeModels: EMPTY_FREE_MODELS,
38
+ defaultModel: null,
39
+ defaultModelReady: false,
40
+ setFlags: (flags) =>
41
+ set({
42
+ freeModels: flags.freeModels,
43
+ defaultModel: flags.defaultModel,
44
+ defaultModelReady: true,
45
+ }),
46
+ markDefaultModelReady: () => set({ defaultModelReady: true }),
47
+ resetFlags: () =>
48
+ set({
49
+ freeModels: EMPTY_FREE_MODELS,
50
+ defaultModel: null,
51
+ defaultModelReady: false,
52
+ }),
53
+ }));