Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- src/constants/acp-brand-marks.ts +18 -0
- src/constants/acp-providers.test.ts +114 -0
- src/constants/acp-providers.ts +542 -0
- src/constants/canvas-ui.ts +10 -0
- src/constants/child-conversation.ts +37 -0
- src/constants/llm-balance.ts +14 -0
- src/constants/llm-subscription.ts +75 -0
- src/constants/profile-scope.ts +33 -0
- src/constants/server-connection-error.ts +1 -0
- src/constants/settings-nav.tsx +77 -0
- src/constants/skills-docs.ts +7 -0
- src/constants/status-colors.ts +5 -0
- src/context/navigation-context.tsx +41 -0
- src/context/scroll-context.tsx +50 -0
- src/contexts/active-backend-context.tsx +251 -0
- src/contexts/conversation-websocket-context.tsx +1278 -0
- src/contexts/settings-section-header-context.tsx +35 -0
- src/contexts/websocket-provider-wrapper.tsx +114 -0
- src/mocks/agent-profiles-handlers.ts +245 -0
- src/mocks/analytics-handlers.ts +13 -0
- src/mocks/auth-handlers.ts +33 -0
- src/mocks/automation-handlers.ts +364 -0
- src/mocks/automation-runs.mock.ts +167 -0
- src/mocks/automations.mock.ts +177 -0
- src/mocks/browser.ts +4 -0
- src/mocks/canvas-extensions-handlers.test.ts +83 -0
- src/mocks/canvas-extensions-handlers.ts +169 -0
- src/mocks/conversation-handlers.ts +488 -0
- src/mocks/feedback-handlers.ts +15 -0
- src/mocks/file-service-handlers.ts +104 -0
- src/mocks/git-repository-handlers.ts +259 -0
- src/mocks/handlers.ts +60 -0
- src/mocks/node.ts +4 -0
- src/mocks/secrets-handlers.ts +94 -0
- src/mocks/settings-handlers.ts +1256 -0
- src/mocks/should-start-mock-worker.ts +9 -0
- src/mocks/workspaces-handlers.ts +105 -0
- src/stores/agent-store.ts +21 -0
- src/stores/archived-conversations-store.ts +84 -0
- src/stores/browser-store.ts +26 -0
- src/stores/btw-store.ts +81 -0
- src/stores/command-menu-store.ts +15 -0
- src/stores/command-store.ts +26 -0
- src/stores/conversation-panel-preferences-store.ts +241 -0
- src/stores/conversation-state-store.ts +41 -0
- src/stores/conversation-store.ts +396 -0
- src/stores/error-message-store.ts +66 -0
- src/stores/event-message-store.ts +26 -0
- src/stores/files-tab-store.ts +140 -0
- 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 |
+
}));
|