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/components/browser/index.ts +3 -0
- src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts +60 -0
- src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts +132 -0
- src/components/conversation-events/chat/event-content-helpers/get-action-content.ts +269 -0
- src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts +147 -0
- src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx +370 -0
- src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts +28 -0
- src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts +436 -0
- src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts +70 -0
- src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts +128 -0
- src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts +42 -0
- src/components/conversation-events/chat/event-content-helpers/shared.ts +6 -0
- src/components/conversation-events/chat/event-content-helpers/should-render-event.ts +148 -0
- src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx +58 -0
- src/components/conversation-events/chat/event-message-components/critic-result-display.tsx +247 -0
- src/components/conversation-events/chat/event-message-components/error-event-message.tsx +16 -0
- src/components/conversation-events/chat/event-message-components/event-group.tsx +169 -0
- src/components/conversation-events/chat/event-message-components/finish-event-message.tsx +35 -0
- src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx +127 -0
- src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx +1 -0
- src/components/conversation-events/chat/event-message-components/index.ts +8 -0
- src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx +96 -0
- src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx +73 -0
- src/components/conversation-events/chat/event-message-components/thought-event-message.tsx +40 -0
- src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx +132 -0
- src/components/conversation-events/chat/event-message.tsx +375 -0
- src/components/conversation-events/chat/event-thought-helpers.ts +112 -0
- src/components/conversation-events/chat/group-events.ts +130 -0
- src/components/conversation-events/chat/hooks/use-plan-preview-events.ts +119 -0
- src/components/conversation-events/chat/index.ts +8 -0
- src/components/conversation-events/chat/messages.tsx +142 -0
- src/components/conversation-events/chat/task-tracking/task-item.tsx +56 -0
- src/components/conversation-events/chat/task-tracking/task-list-section.test.tsx +69 -0
- src/components/conversation-events/chat/task-tracking/task-list-section.tsx +33 -0
- src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.test.tsx +66 -0
- src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx +23 -0
- src/components/conversation/index.ts +8 -0
- src/components/features/alerts/alert-banner.tsx +143 -0
- src/components/features/backends/add-backend-modal.tsx +9 -0
- src/components/features/backends/api-key-entry-screen.tsx +150 -0
- src/components/features/backends/backend-form-modal.test.tsx +80 -0
- src/components/features/backends/backend-form-modal.tsx +1629 -0
- src/components/features/backends/backend-row.tsx +162 -0
- src/components/features/backends/backend-selector.tsx +474 -0
- src/components/features/backends/backend-status-dot.tsx +55 -0
- src/components/features/backends/backend-status-label.test.ts +101 -0
- src/components/features/backends/backend-status-label.ts +77 -0
- src/components/features/backends/backend-version.tsx +39 -0
- src/components/features/backends/device-flow-auth.tsx +367 -0
- src/components/features/backends/environment-switch-overlay.tsx +172 -0
src/components/browser/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { BrowserPanel } from "../features/browser/browser";
|
| 2 |
+
export { BrowserSnapshot } from "../features/browser/browser-snapshot";
|
| 3 |
+
export { EmptyBrowserMessage } from "../features/browser/empty-browser-message";
|
src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MessageEvent } from "#/types/agent-server/core";
|
| 2 |
+
import { BaseEvent } from "#/types/agent-server/core/base/event";
|
| 3 |
+
import {
|
| 4 |
+
getSkillReadyContent,
|
| 5 |
+
getSkillReadyItems,
|
| 6 |
+
SkillReadyItem,
|
| 7 |
+
} from "./get-skill-ready-content";
|
| 8 |
+
|
| 9 |
+
export type { SkillReadyItem };
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Synthetic event type for Skill Ready events.
|
| 13 |
+
* This extends BaseEvent and includes a marker to identify it as a skill ready event.
|
| 14 |
+
*/
|
| 15 |
+
export interface SkillReadyEvent extends BaseEvent {
|
| 16 |
+
_isSkillReadyEvent: true;
|
| 17 |
+
_skillReadyContent: string;
|
| 18 |
+
_skillReadyItems: SkillReadyItem[];
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Type guard for Skill Ready events.
|
| 23 |
+
*/
|
| 24 |
+
export const isSkillReadyEvent = (event: unknown): event is SkillReadyEvent =>
|
| 25 |
+
typeof event === "object" &&
|
| 26 |
+
event !== null &&
|
| 27 |
+
"_isSkillReadyEvent" in event &&
|
| 28 |
+
event._isSkillReadyEvent === true;
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Creates a synthetic "Skill Ready" event from a user MessageEvent.
|
| 32 |
+
* This event appears as originating from the agent and contains formatted
|
| 33 |
+
* information about activated skills and extended content.
|
| 34 |
+
*/
|
| 35 |
+
export const createSkillReadyEvent = (
|
| 36 |
+
userEvent: MessageEvent,
|
| 37 |
+
): SkillReadyEvent => {
|
| 38 |
+
const activatedSkills = userEvent.activated_skills || [];
|
| 39 |
+
|
| 40 |
+
const extendedContent = userEvent.extended_content || [];
|
| 41 |
+
|
| 42 |
+
// Only create event if we have skills or extended content
|
| 43 |
+
if (activatedSkills.length === 0 && extendedContent.length === 0) {
|
| 44 |
+
throw new Error(
|
| 45 |
+
"Cannot create skill ready event without activated skills or extended content",
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
const content = getSkillReadyContent(activatedSkills, extendedContent);
|
| 50 |
+
const items = getSkillReadyItems(activatedSkills, extendedContent);
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
id: `${userEvent.id}-skill-ready`,
|
| 54 |
+
timestamp: userEvent.timestamp,
|
| 55 |
+
source: "agent",
|
| 56 |
+
_isSkillReadyEvent: true,
|
| 57 |
+
_skillReadyContent: content,
|
| 58 |
+
_skillReadyItems: items,
|
| 59 |
+
};
|
| 60 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
|
| 2 |
+
import i18n from "#/i18n";
|
| 3 |
+
import { MAX_CONTENT_LENGTH } from "./shared";
|
| 4 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Pick the translation key used for the ACP tool call title row. Mirrors
|
| 8 |
+
* ACTION_MESSAGE$RUN / $EDIT / $READ etc.
|
| 9 |
+
*/
|
| 10 |
+
export const getACPToolCallTitleKey = (event: ACPToolCallEvent): string => {
|
| 11 |
+
switch (event.tool_kind) {
|
| 12 |
+
case "execute":
|
| 13 |
+
return "ACTION_MESSAGE$ACP_RUN";
|
| 14 |
+
case "edit":
|
| 15 |
+
return "ACTION_MESSAGE$ACP_EDIT";
|
| 16 |
+
case "read":
|
| 17 |
+
return "ACTION_MESSAGE$ACP_READ";
|
| 18 |
+
case "fetch":
|
| 19 |
+
return "ACTION_MESSAGE$ACP_FETCH";
|
| 20 |
+
default:
|
| 21 |
+
return "ACTION_MESSAGE$ACP_TOOL";
|
| 22 |
+
}
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
// English verb prefixes ACP servers sometimes inline into the title.
|
| 26 |
+
// Claude Code emits ``"Read /Users/foo/bar"`` for a read tool — combined
|
| 27 |
+
// with the i18n template ``"Reading <cmd>{{title}}</cmd>"`` that lands as
|
| 28 |
+
// ``"Reading Read /Users/foo/bar"``. The redundant leading verb is the
|
| 29 |
+
// part we strip; the template's own verb (which is i18n'd) stays.
|
| 30 |
+
//
|
| 31 |
+
// Keyed by ``tool_kind`` so the strip is scoped to where double-verbing
|
| 32 |
+
// actually shows up. The english-only check is intentional — ACP servers
|
| 33 |
+
// are anglophone tools and emit english titles regardless of the user's
|
| 34 |
+
// canvas locale; matching translated verbs would mean every locale's
|
| 35 |
+
// strip list goes stale the moment a new server is added.
|
| 36 |
+
const REDUNDANT_TITLE_PREFIXES: Partial<Record<string, readonly string[]>> = {
|
| 37 |
+
read: ["Read"],
|
| 38 |
+
edit: ["Edit", "Write"],
|
| 39 |
+
execute: ["Bash", "Run"],
|
| 40 |
+
fetch: ["Fetch", "WebFetch"],
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Strip a leading verb from ``event.title`` that would duplicate the
|
| 45 |
+
* verb baked into the i18n template (see ``REDUNDANT_TITLE_PREFIXES``).
|
| 46 |
+
*
|
| 47 |
+
* The match is anchored, case-sensitive, and requires the prefix to be
|
| 48 |
+
* followed by whitespace so a token like ``"Reads"`` (an actual verb
|
| 49 |
+
* elsewhere in the title) is left alone. If no prefix matches, the title
|
| 50 |
+
* is returned verbatim.
|
| 51 |
+
*/
|
| 52 |
+
export const stripRedundantTitlePrefix = (event: ACPToolCallEvent): string => {
|
| 53 |
+
const title = event.title;
|
| 54 |
+
const tool_kind = event.tool_kind;
|
| 55 |
+
if (!title || !tool_kind) return title;
|
| 56 |
+
const prefixes = REDUNDANT_TITLE_PREFIXES[tool_kind];
|
| 57 |
+
if (!prefixes) return title;
|
| 58 |
+
for (const prefix of prefixes) {
|
| 59 |
+
if (
|
| 60 |
+
title.length > prefix.length &&
|
| 61 |
+
title.startsWith(prefix) &&
|
| 62 |
+
/\s/.test(title.charAt(prefix.length))
|
| 63 |
+
) {
|
| 64 |
+
return title.slice(prefix.length).trimStart();
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
return title;
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Stringify an arbitrary raw_input / raw_output payload for markdown
|
| 72 |
+
* rendering. Strings pass through; objects are pretty-printed JSON.
|
| 73 |
+
*/
|
| 74 |
+
const stringifyPayload = (value: unknown): string => {
|
| 75 |
+
if (value === null || value === undefined) return "";
|
| 76 |
+
if (typeof value === "string") return value;
|
| 77 |
+
try {
|
| 78 |
+
return JSON.stringify(value, null, 2);
|
| 79 |
+
} catch {
|
| 80 |
+
return String(value);
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const truncate = (content: string): string =>
|
| 85 |
+
content.length > MAX_CONTENT_LENGTH
|
| 86 |
+
? `${content.slice(0, MAX_CONTENT_LENGTH)}...`
|
| 87 |
+
: content;
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Build the markdown-flavored body for an ACP tool call card. Mirrors the
|
| 91 |
+
* shape of ``getTerminalObservationContent`` (``Command:`` + ``Output:``
|
| 92 |
+
* fenced blocks) so the rendered card lines up with regular OpenHands
|
| 93 |
+
* observations.
|
| 94 |
+
*
|
| 95 |
+
* For ``tool_kind === "execute"`` we surface ``raw_input.command`` as the
|
| 96 |
+
* command line; for others we fall back to a pretty-printed JSON dump of
|
| 97 |
+
* the input. Output is always dumped as a fenced block, with the same
|
| 98 |
+
* "(no output)" fallback copy used by the bash observation renderer.
|
| 99 |
+
*/
|
| 100 |
+
export const getACPToolCallContent = (event: ACPToolCallEvent): string => {
|
| 101 |
+
const toolKind = event.tool_kind;
|
| 102 |
+
const rawInput = event.raw_input;
|
| 103 |
+
const rawOutput = event.raw_output;
|
| 104 |
+
const isError = event.is_error;
|
| 105 |
+
|
| 106 |
+
let output = "";
|
| 107 |
+
|
| 108 |
+
// Input block — command for execute, JSON dump otherwise.
|
| 109 |
+
if (
|
| 110 |
+
toolKind === "execute" &&
|
| 111 |
+
rawInput &&
|
| 112 |
+
typeof rawInput === "object" &&
|
| 113 |
+
"command" in rawInput &&
|
| 114 |
+
typeof (rawInput as { command: unknown }).command === "string"
|
| 115 |
+
) {
|
| 116 |
+
const { command } = rawInput as { command: string };
|
| 117 |
+
output += `Command: \`${command}\`\n\n`;
|
| 118 |
+
} else if (rawInput !== null && rawInput !== undefined && rawInput !== "") {
|
| 119 |
+
const inputStr = stringifyPayload(rawInput);
|
| 120 |
+
if (inputStr.trim()) {
|
| 121 |
+
output += `Input:\n\`\`\`json\n${inputStr}\n\`\`\`\n\n`;
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Output block — matches the bash observation layout exactly.
|
| 126 |
+
const outputStr = truncate(stringifyPayload(rawOutput).trim());
|
| 127 |
+
const outputLabel = isError ? "**Error:**" : "Output:";
|
| 128 |
+
const outputBody = outputStr || i18n.t(I18nKey.OBSERVATION$COMMAND_NO_OUTPUT);
|
| 129 |
+
output += `${outputLabel}\n\`\`\`\n${outputBody}\n\`\`\``;
|
| 130 |
+
|
| 131 |
+
return output;
|
| 132 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/get-action-content.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ActionEvent } from "#/types/agent-server/core";
|
| 2 |
+
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
|
| 3 |
+
import i18n from "#/i18n";
|
| 4 |
+
import { SecurityRisk } from "#/types/agent-server/core/base/common";
|
| 5 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 6 |
+
import {
|
| 7 |
+
ExecuteBashAction,
|
| 8 |
+
TerminalAction,
|
| 9 |
+
FileEditorAction,
|
| 10 |
+
StrReplaceEditorAction,
|
| 11 |
+
MCPToolAction,
|
| 12 |
+
ThinkAction,
|
| 13 |
+
FinishAction,
|
| 14 |
+
TaskTrackerAction,
|
| 15 |
+
BrowserNavigateAction,
|
| 16 |
+
BrowserClickAction,
|
| 17 |
+
BrowserTypeAction,
|
| 18 |
+
BrowserGetStateAction,
|
| 19 |
+
BrowserGetContentAction,
|
| 20 |
+
BrowserScrollAction,
|
| 21 |
+
BrowserGoBackAction,
|
| 22 |
+
BrowserListTabsAction,
|
| 23 |
+
BrowserSwitchTabAction,
|
| 24 |
+
BrowserCloseTabAction,
|
| 25 |
+
GlobAction,
|
| 26 |
+
GrepAction,
|
| 27 |
+
InvokeSkillAction,
|
| 28 |
+
} from "#/types/agent-server/core/base/action";
|
| 29 |
+
|
| 30 |
+
const getRiskText = (risk: SecurityRisk) => {
|
| 31 |
+
switch (risk) {
|
| 32 |
+
case SecurityRisk.LOW:
|
| 33 |
+
return i18n.t(I18nKey.SECURITY$LOW_RISK);
|
| 34 |
+
case SecurityRisk.MEDIUM:
|
| 35 |
+
return i18n.t(I18nKey.SECURITY$MEDIUM_RISK);
|
| 36 |
+
case SecurityRisk.HIGH:
|
| 37 |
+
return i18n.t(I18nKey.SECURITY$HIGH_RISK);
|
| 38 |
+
case SecurityRisk.UNKNOWN:
|
| 39 |
+
default:
|
| 40 |
+
return i18n.t(I18nKey.SECURITY$UNKNOWN_RISK);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const getNoContentActionContent = (): string => "";
|
| 45 |
+
|
| 46 |
+
// Invoke-skill actions
|
| 47 |
+
const getInvokeSkillActionContent = (action: InvokeSkillAction): string =>
|
| 48 |
+
action.name ? `**Skill:** \`${action.name}\`` : getNoContentActionContent();
|
| 49 |
+
|
| 50 |
+
// Grep/Glob search actions
|
| 51 |
+
const getSearchActionContent = (
|
| 52 |
+
event: ActionEvent<GlobAction | GrepAction>,
|
| 53 |
+
): string => {
|
| 54 |
+
const { action } = event;
|
| 55 |
+
const parts: string[] = [];
|
| 56 |
+
if (action.pattern) {
|
| 57 |
+
parts.push(`**Pattern:** \`${action.pattern}\``);
|
| 58 |
+
}
|
| 59 |
+
if (action.path) {
|
| 60 |
+
parts.push(`**Path:** \`${action.path}\``);
|
| 61 |
+
}
|
| 62 |
+
if ("include" in action && action.include) {
|
| 63 |
+
parts.push(`**Include:** \`${action.include}\``);
|
| 64 |
+
}
|
| 65 |
+
return parts.length > 0 ? parts.join("\n") : getNoContentActionContent();
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
// File Editor Actions
|
| 69 |
+
const getFileEditorActionContent = (
|
| 70 |
+
action: FileEditorAction | StrReplaceEditorAction,
|
| 71 |
+
): string => {
|
| 72 |
+
// Early return if not a create command or no file text
|
| 73 |
+
if (action.command !== "create" || !action.file_text) {
|
| 74 |
+
return getNoContentActionContent();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Process file text with length truncation
|
| 78 |
+
let fileText = action.file_text;
|
| 79 |
+
if (fileText.length > MAX_CONTENT_LENGTH) {
|
| 80 |
+
fileText = `${fileText.slice(0, MAX_CONTENT_LENGTH)}...`;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
return `${action.path}\n${fileText}`;
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
// Command Actions
|
| 87 |
+
const getExecuteBashActionContent = (
|
| 88 |
+
event: ActionEvent<ExecuteBashAction | TerminalAction>,
|
| 89 |
+
): string => {
|
| 90 |
+
let content = `Command:\n\`${event.action.command}\``;
|
| 91 |
+
|
| 92 |
+
// Add security risk information if it's HIGH or MEDIUM
|
| 93 |
+
if (
|
| 94 |
+
event.security_risk === SecurityRisk.HIGH ||
|
| 95 |
+
event.security_risk === SecurityRisk.MEDIUM
|
| 96 |
+
) {
|
| 97 |
+
content += `\n\n${getRiskText(event.security_risk)}`;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return content;
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
// Tool Actions
|
| 104 |
+
const getMCPToolActionContent = (action: MCPToolAction): string => {
|
| 105 |
+
// For V1, the tool name is in the event's tool_name property, not in the action
|
| 106 |
+
let details = `**MCP Tool Call**\n\n`;
|
| 107 |
+
details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(action.data, null, 2)}\n\`\`\``;
|
| 108 |
+
return details;
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
// Simple Actions
|
| 112 |
+
const getThinkActionContent = (action: ThinkAction): string => action.thought;
|
| 113 |
+
|
| 114 |
+
const getFinishActionContent = (action: FinishAction): string =>
|
| 115 |
+
action.message.trim();
|
| 116 |
+
|
| 117 |
+
// Complex Actions
|
| 118 |
+
const getTaskTrackerActionContent = (action: TaskTrackerAction): string => {
|
| 119 |
+
let content = `**Command:** \`${action.command}\``;
|
| 120 |
+
|
| 121 |
+
// Handle plan command with task list
|
| 122 |
+
if (action.command === "plan") {
|
| 123 |
+
if (action.task_list && action.task_list.length > 0) {
|
| 124 |
+
content += `\n\n**Task List (${action.task_list.length} ${action.task_list.length === 1 ? "item" : "items"}):**\n`;
|
| 125 |
+
action.task_list.forEach((task, index: number) => {
|
| 126 |
+
const statusMap = {
|
| 127 |
+
todo: "⏳",
|
| 128 |
+
in_progress: "🔄",
|
| 129 |
+
done: "✅",
|
| 130 |
+
};
|
| 131 |
+
const statusIcon =
|
| 132 |
+
statusMap[task.status as keyof typeof statusMap] || "❓";
|
| 133 |
+
content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
|
| 134 |
+
if (task.notes) {
|
| 135 |
+
content += `\n *Notes: ${task.notes}*`;
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
} else {
|
| 139 |
+
content += "\n\n**Task List:** Empty";
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
return content;
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
// Browser Actions
|
| 147 |
+
type BrowserAction =
|
| 148 |
+
| BrowserNavigateAction
|
| 149 |
+
| BrowserClickAction
|
| 150 |
+
| BrowserTypeAction
|
| 151 |
+
| BrowserGetStateAction
|
| 152 |
+
| BrowserGetContentAction
|
| 153 |
+
| BrowserScrollAction
|
| 154 |
+
| BrowserGoBackAction
|
| 155 |
+
| BrowserListTabsAction
|
| 156 |
+
| BrowserSwitchTabAction
|
| 157 |
+
| BrowserCloseTabAction;
|
| 158 |
+
|
| 159 |
+
const getBrowserActionContent = (action: BrowserAction): string => {
|
| 160 |
+
switch (action.kind) {
|
| 161 |
+
case "BrowserNavigateAction": {
|
| 162 |
+
let content = `Browsing ${action.url}`;
|
| 163 |
+
if (action.new_tab) {
|
| 164 |
+
content += `\n**New Tab:** Yes`;
|
| 165 |
+
}
|
| 166 |
+
return content;
|
| 167 |
+
}
|
| 168 |
+
case "BrowserClickAction": {
|
| 169 |
+
let content = `**Element Index:** ${action.index}`;
|
| 170 |
+
if (action.new_tab) {
|
| 171 |
+
content += `\n**New Tab:** Yes`;
|
| 172 |
+
}
|
| 173 |
+
return content;
|
| 174 |
+
}
|
| 175 |
+
case "BrowserTypeAction": {
|
| 176 |
+
const textPreview =
|
| 177 |
+
action.text.length > 50
|
| 178 |
+
? `${action.text.slice(0, 50)}...`
|
| 179 |
+
: action.text;
|
| 180 |
+
return `**Element Index:** ${action.index}\n**Text:** ${textPreview}`;
|
| 181 |
+
}
|
| 182 |
+
case "BrowserGetStateAction": {
|
| 183 |
+
if (action.include_screenshot) {
|
| 184 |
+
return `**Include Screenshot:** Yes`;
|
| 185 |
+
}
|
| 186 |
+
return getNoContentActionContent();
|
| 187 |
+
}
|
| 188 |
+
case "BrowserGetContentAction": {
|
| 189 |
+
const parts: string[] = [];
|
| 190 |
+
if (action.extract_links) {
|
| 191 |
+
parts.push(`**Extract Links:** Yes`);
|
| 192 |
+
}
|
| 193 |
+
if (action.start_from_char > 0) {
|
| 194 |
+
parts.push(`**Start From Character:** ${action.start_from_char}`);
|
| 195 |
+
}
|
| 196 |
+
return parts.length > 0 ? parts.join("\n") : getNoContentActionContent();
|
| 197 |
+
}
|
| 198 |
+
case "BrowserScrollAction": {
|
| 199 |
+
return `**Direction:** ${action.direction}`;
|
| 200 |
+
}
|
| 201 |
+
case "BrowserGoBackAction": {
|
| 202 |
+
return getNoContentActionContent();
|
| 203 |
+
}
|
| 204 |
+
case "BrowserListTabsAction": {
|
| 205 |
+
return getNoContentActionContent();
|
| 206 |
+
}
|
| 207 |
+
case "BrowserSwitchTabAction": {
|
| 208 |
+
return `**Tab ID:** ${action.tab_id}`;
|
| 209 |
+
}
|
| 210 |
+
case "BrowserCloseTabAction": {
|
| 211 |
+
return `**Tab ID:** ${action.tab_id}`;
|
| 212 |
+
}
|
| 213 |
+
default:
|
| 214 |
+
return getNoContentActionContent();
|
| 215 |
+
}
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
export const getActionContent = (event: ActionEvent): string => {
|
| 219 |
+
const { action } = event;
|
| 220 |
+
const actionType = action.kind;
|
| 221 |
+
|
| 222 |
+
switch (actionType) {
|
| 223 |
+
case "FileEditorAction":
|
| 224 |
+
case "StrReplaceEditorAction":
|
| 225 |
+
return getFileEditorActionContent(action);
|
| 226 |
+
|
| 227 |
+
case "ExecuteBashAction":
|
| 228 |
+
case "TerminalAction":
|
| 229 |
+
return getExecuteBashActionContent(
|
| 230 |
+
event as ActionEvent<ExecuteBashAction | TerminalAction>,
|
| 231 |
+
);
|
| 232 |
+
|
| 233 |
+
case "MCPToolAction":
|
| 234 |
+
return getMCPToolActionContent(action);
|
| 235 |
+
|
| 236 |
+
case "ThinkAction":
|
| 237 |
+
return getThinkActionContent(action);
|
| 238 |
+
|
| 239 |
+
case "FinishAction":
|
| 240 |
+
return getFinishActionContent(action);
|
| 241 |
+
|
| 242 |
+
case "TaskTrackerAction":
|
| 243 |
+
return getTaskTrackerActionContent(action);
|
| 244 |
+
|
| 245 |
+
case "BrowserNavigateAction":
|
| 246 |
+
case "BrowserClickAction":
|
| 247 |
+
case "BrowserTypeAction":
|
| 248 |
+
case "BrowserGetStateAction":
|
| 249 |
+
case "BrowserGetContentAction":
|
| 250 |
+
case "BrowserScrollAction":
|
| 251 |
+
case "BrowserGoBackAction":
|
| 252 |
+
case "BrowserListTabsAction":
|
| 253 |
+
case "BrowserSwitchTabAction":
|
| 254 |
+
case "BrowserCloseTabAction":
|
| 255 |
+
return getBrowserActionContent(action);
|
| 256 |
+
|
| 257 |
+
case "GrepAction":
|
| 258 |
+
case "GlobAction":
|
| 259 |
+
return getSearchActionContent(
|
| 260 |
+
event as ActionEvent<GlobAction | GrepAction>,
|
| 261 |
+
);
|
| 262 |
+
|
| 263 |
+
case "InvokeSkillAction":
|
| 264 |
+
return getInvokeSkillActionContent(action as InvokeSkillAction);
|
| 265 |
+
|
| 266 |
+
default:
|
| 267 |
+
return getDefaultEventContent(event);
|
| 268 |
+
}
|
| 269 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui";
|
| 2 |
+
import { LAUNCH_CHILD_CONVERSATION_ACTION_KIND } from "#/constants/child-conversation";
|
| 3 |
+
import type { ActionEvent } from "#/types/agent-server/core";
|
| 4 |
+
|
| 5 |
+
export type EventTitleDescriptor =
|
| 6 |
+
| {
|
| 7 |
+
readonly kind: "translation";
|
| 8 |
+
readonly key: string;
|
| 9 |
+
readonly values: Readonly<Record<string, unknown>>;
|
| 10 |
+
}
|
| 11 |
+
| {
|
| 12 |
+
readonly kind: "text";
|
| 13 |
+
readonly text: string;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export const trimEventTitleText = (text: string, maxLength: number): string => {
|
| 17 |
+
if (!text) return "";
|
| 18 |
+
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const isServerFallbackSummary = (summary: string): boolean =>
|
| 22 |
+
/^[a-z][a-z0-9_]*\s*:\s*[[{]/i.test(summary);
|
| 23 |
+
|
| 24 |
+
export const getActionSummaryTitle = (event: ActionEvent): string | null => {
|
| 25 |
+
const summary = event.summary?.trim().replace(/\s+/g, " ") || "";
|
| 26 |
+
return !summary || isServerFallbackSummary(summary) ? null : summary;
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export const getActionEventTitleDescriptor = (
|
| 30 |
+
event: ActionEvent,
|
| 31 |
+
): EventTitleDescriptor => {
|
| 32 |
+
const summary = getActionSummaryTitle(event);
|
| 33 |
+
if (summary) {
|
| 34 |
+
return { kind: "text", text: summary };
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const actionType = event.action.kind;
|
| 38 |
+
|
| 39 |
+
switch (actionType) {
|
| 40 |
+
case "ExecuteBashAction":
|
| 41 |
+
case "TerminalAction":
|
| 42 |
+
return {
|
| 43 |
+
kind: "translation",
|
| 44 |
+
key: "ACTION_MESSAGE$RUN",
|
| 45 |
+
values: { command: trimEventTitleText(event.action.command, 80) },
|
| 46 |
+
};
|
| 47 |
+
case "FileEditorAction":
|
| 48 |
+
case "StrReplaceEditorAction": {
|
| 49 |
+
const key =
|
| 50 |
+
event.action.command === "view"
|
| 51 |
+
? "ACTION_MESSAGE$READ"
|
| 52 |
+
: event.action.command === "create"
|
| 53 |
+
? "ACTION_MESSAGE$WRITE"
|
| 54 |
+
: "ACTION_MESSAGE$EDIT";
|
| 55 |
+
return {
|
| 56 |
+
kind: "translation",
|
| 57 |
+
key,
|
| 58 |
+
values: { path: event.action.path },
|
| 59 |
+
};
|
| 60 |
+
}
|
| 61 |
+
case "MCPToolAction":
|
| 62 |
+
return {
|
| 63 |
+
kind: "translation",
|
| 64 |
+
key: "ACTION_MESSAGE$CALL_TOOL_MCP",
|
| 65 |
+
values: { mcp_tool_name: event.tool_name },
|
| 66 |
+
};
|
| 67 |
+
case "InvokeSkillAction":
|
| 68 |
+
return {
|
| 69 |
+
kind: "translation",
|
| 70 |
+
key: "ACTION_MESSAGE$INVOKE_SKILL",
|
| 71 |
+
values: { name: event.action.name },
|
| 72 |
+
};
|
| 73 |
+
case "TaskAction":
|
| 74 |
+
return {
|
| 75 |
+
kind: "translation",
|
| 76 |
+
key: "ACTION_MESSAGE$TASK",
|
| 77 |
+
values: { name: event.action.subagent_type },
|
| 78 |
+
};
|
| 79 |
+
case "ThinkAction":
|
| 80 |
+
return {
|
| 81 |
+
kind: "translation",
|
| 82 |
+
key: "ACTION_MESSAGE$THINK",
|
| 83 |
+
values: {},
|
| 84 |
+
};
|
| 85 |
+
case "FinishAction":
|
| 86 |
+
return {
|
| 87 |
+
kind: "translation",
|
| 88 |
+
key: "ACTION_MESSAGE$FINISH",
|
| 89 |
+
values: {},
|
| 90 |
+
};
|
| 91 |
+
case "TaskTrackerAction":
|
| 92 |
+
return {
|
| 93 |
+
kind: "translation",
|
| 94 |
+
key: "ACTION_MESSAGE$TASK_TRACKING",
|
| 95 |
+
values: {},
|
| 96 |
+
};
|
| 97 |
+
case "GrepAction":
|
| 98 |
+
return {
|
| 99 |
+
kind: "translation",
|
| 100 |
+
key: "ACTION_MESSAGE$GREP",
|
| 101 |
+
values: {
|
| 102 |
+
pattern: event.action.pattern
|
| 103 |
+
? trimEventTitleText(event.action.pattern, 50)
|
| 104 |
+
: "",
|
| 105 |
+
},
|
| 106 |
+
};
|
| 107 |
+
case "GlobAction":
|
| 108 |
+
return {
|
| 109 |
+
kind: "translation",
|
| 110 |
+
key: "ACTION_MESSAGE$GLOB",
|
| 111 |
+
values: {
|
| 112 |
+
pattern: event.action.pattern
|
| 113 |
+
? trimEventTitleText(event.action.pattern, 50)
|
| 114 |
+
: "",
|
| 115 |
+
},
|
| 116 |
+
};
|
| 117 |
+
case "BrowserNavigateAction":
|
| 118 |
+
case "BrowserClickAction":
|
| 119 |
+
case "BrowserTypeAction":
|
| 120 |
+
case "BrowserGetStateAction":
|
| 121 |
+
case "BrowserGetContentAction":
|
| 122 |
+
case "BrowserScrollAction":
|
| 123 |
+
case "BrowserGoBackAction":
|
| 124 |
+
case "BrowserListTabsAction":
|
| 125 |
+
case "BrowserSwitchTabAction":
|
| 126 |
+
case "BrowserCloseTabAction":
|
| 127 |
+
return {
|
| 128 |
+
kind: "translation",
|
| 129 |
+
key: "ACTION_MESSAGE$BROWSE",
|
| 130 |
+
values: {},
|
| 131 |
+
};
|
| 132 |
+
case "CanvasUIAction":
|
| 133 |
+
case CANVAS_UI_CLIENT_ACTION_KIND:
|
| 134 |
+
return { kind: "text", text: "CANVASUI" };
|
| 135 |
+
case LAUNCH_CHILD_CONVERSATION_ACTION_KIND:
|
| 136 |
+
return {
|
| 137 |
+
kind: "translation",
|
| 138 |
+
key: "ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION",
|
| 139 |
+
values: {},
|
| 140 |
+
};
|
| 141 |
+
default:
|
| 142 |
+
return {
|
| 143 |
+
kind: "text",
|
| 144 |
+
text: String(actionType).replace("Action", "").toUpperCase(),
|
| 145 |
+
};
|
| 146 |
+
}
|
| 147 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Trans } from "react-i18next";
|
| 2 |
+
import React from "react";
|
| 3 |
+
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
|
| 4 |
+
import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation";
|
| 5 |
+
import {
|
| 6 |
+
OpenHandsEvent,
|
| 7 |
+
ObservationEvent,
|
| 8 |
+
ActionEvent,
|
| 9 |
+
} from "#/types/agent-server/core";
|
| 10 |
+
import {
|
| 11 |
+
isActionEvent,
|
| 12 |
+
isObservationEvent,
|
| 13 |
+
isACPToolCallEvent,
|
| 14 |
+
isCanvasUIActionEvent,
|
| 15 |
+
} from "#/types/agent-server/type-guards";
|
| 16 |
+
import { MonoComponent } from "../../../features/chat/mono-component";
|
| 17 |
+
import { PathComponent } from "../../../features/chat/path-component";
|
| 18 |
+
import { getActionContent } from "./get-action-content";
|
| 19 |
+
import { getObservationContent } from "./get-observation-content";
|
| 20 |
+
import {
|
| 21 |
+
getACPToolCallContent,
|
| 22 |
+
getACPToolCallTitleKey,
|
| 23 |
+
stripRedundantTitlePrefix,
|
| 24 |
+
} from "./get-acp-tool-call-content";
|
| 25 |
+
import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content";
|
| 26 |
+
import { TaskTrackerObservation } from "#/types/agent-server/core/base/observation";
|
| 27 |
+
import { SkillReadyEvent, isSkillReadyEvent } from "./create-skill-ready-event";
|
| 28 |
+
import { resolveVisualizerBody } from "../../../features/chat/tool-visualizers/dispatcher";
|
| 29 |
+
import i18n from "#/i18n";
|
| 30 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 31 |
+
import {
|
| 32 |
+
getActionEventTitleDescriptor,
|
| 33 |
+
getActionSummaryTitle,
|
| 34 |
+
trimEventTitleText,
|
| 35 |
+
type EventTitleDescriptor,
|
| 36 |
+
} from "./get-action-event-title";
|
| 37 |
+
|
| 38 |
+
// Helper function to create title from translation key
|
| 39 |
+
const createTitleFromKey = (
|
| 40 |
+
key: string,
|
| 41 |
+
values: Readonly<Record<string, unknown>>,
|
| 42 |
+
): React.ReactNode => {
|
| 43 |
+
if (!i18n.exists(key)) {
|
| 44 |
+
return key;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<Trans
|
| 49 |
+
ns="openhands"
|
| 50 |
+
i18nKey={key}
|
| 51 |
+
values={values}
|
| 52 |
+
components={{
|
| 53 |
+
path: <PathComponent />,
|
| 54 |
+
cmd: <MonoComponent />,
|
| 55 |
+
}}
|
| 56 |
+
/>
|
| 57 |
+
);
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const renderTitleDescriptor = (
|
| 61 |
+
descriptor: EventTitleDescriptor,
|
| 62 |
+
): React.ReactNode =>
|
| 63 |
+
descriptor.kind === "text"
|
| 64 |
+
? descriptor.text
|
| 65 |
+
: createTitleFromKey(descriptor.key, descriptor.values);
|
| 66 |
+
|
| 67 |
+
const getNonEmptyString = (
|
| 68 |
+
value: unknown,
|
| 69 |
+
maxLength?: number,
|
| 70 |
+
): string | null => {
|
| 71 |
+
if (typeof value !== "string") {
|
| 72 |
+
return null;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const trimmed = value.trim();
|
| 76 |
+
if (!trimmed) {
|
| 77 |
+
return null;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
return maxLength ? trimEventTitleText(trimmed, maxLength) : trimmed;
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
const getVisionInspectActionTitle = (
|
| 84 |
+
event: ActionEvent,
|
| 85 |
+
): React.ReactNode | null => {
|
| 86 |
+
if (event.tool_name !== "inspect_image_with_vision") {
|
| 87 |
+
return null;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
const action = event.action as unknown as Record<string, unknown>;
|
| 91 |
+
const profileName = getNonEmptyString(action.profile_name);
|
| 92 |
+
|
| 93 |
+
return profileName
|
| 94 |
+
? `Describing image with ${profileName}`
|
| 95 |
+
: "Describing image with auxiliary vision LLM";
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const getVisionInspectObservationTitle = (
|
| 99 |
+
event: ObservationEvent,
|
| 100 |
+
correspondingAction?: ActionEvent,
|
| 101 |
+
): React.ReactNode | null => {
|
| 102 |
+
const actionToolName = correspondingAction?.tool_name;
|
| 103 |
+
const observation = event.observation as unknown as Record<string, unknown>;
|
| 104 |
+
const observationToolName = getNonEmptyString(observation.tool_name);
|
| 105 |
+
|
| 106 |
+
if (
|
| 107 |
+
actionToolName !== "inspect_image_with_vision" &&
|
| 108 |
+
observationToolName !== "inspect_image_with_vision" &&
|
| 109 |
+
String(event.observation.kind) !== "VisionInspectObservation"
|
| 110 |
+
) {
|
| 111 |
+
return null;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const action = (correspondingAction?.action ?? {}) as unknown as Record<
|
| 115 |
+
string,
|
| 116 |
+
unknown
|
| 117 |
+
>;
|
| 118 |
+
const label =
|
| 119 |
+
getNonEmptyString(action.profile_name) ??
|
| 120 |
+
getNonEmptyString(observation.profile_name) ??
|
| 121 |
+
getNonEmptyString(observation.base_url, 80) ??
|
| 122 |
+
getNonEmptyString(observation.endpoint, 80) ??
|
| 123 |
+
getNonEmptyString(observation.model, 80);
|
| 124 |
+
|
| 125 |
+
return label
|
| 126 |
+
? `Describing image with ${label}`
|
| 127 |
+
: "Describing image with auxiliary vision LLM";
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
// Action Event Processing
|
| 131 |
+
const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
| 132 |
+
// Early return if not an action event
|
| 133 |
+
if (!isActionEvent(event)) {
|
| 134 |
+
return "";
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const visionInspectTitle = getVisionInspectActionTitle(event);
|
| 138 |
+
if (visionInspectTitle) {
|
| 139 |
+
return visionInspectTitle;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
return renderTitleDescriptor(getActionEventTitleDescriptor(event));
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
// Observation Event Processing
|
| 146 |
+
const getObservationEventTitle = (
|
| 147 |
+
event: OpenHandsEvent,
|
| 148 |
+
correspondingAction?: ActionEvent,
|
| 149 |
+
): React.ReactNode => {
|
| 150 |
+
// Early return if not an observation event
|
| 151 |
+
if (!isObservationEvent(event)) {
|
| 152 |
+
return "";
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
const visionInspectObservationTitle = getVisionInspectObservationTitle(
|
| 156 |
+
event,
|
| 157 |
+
correspondingAction,
|
| 158 |
+
);
|
| 159 |
+
if (visionInspectObservationTitle) {
|
| 160 |
+
return visionInspectObservationTitle;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (correspondingAction) {
|
| 164 |
+
const visionInspectTitle = getVisionInspectActionTitle(correspondingAction);
|
| 165 |
+
if (visionInspectTitle) {
|
| 166 |
+
return visionInspectTitle;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
const summaryTitle = getActionSummaryTitle(correspondingAction);
|
| 170 |
+
if (summaryTitle) {
|
| 171 |
+
return summaryTitle;
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const observationType = event.observation.kind;
|
| 176 |
+
let observationKey = "";
|
| 177 |
+
let observationValues: Record<string, unknown> = {};
|
| 178 |
+
|
| 179 |
+
switch (observationType) {
|
| 180 |
+
case "ExecuteBashObservation":
|
| 181 |
+
case "TerminalObservation":
|
| 182 |
+
observationKey = "OBSERVATION_MESSAGE$RUN";
|
| 183 |
+
observationValues = {
|
| 184 |
+
command: event.observation.command
|
| 185 |
+
? trimEventTitleText(event.observation.command, 80)
|
| 186 |
+
: "",
|
| 187 |
+
};
|
| 188 |
+
break;
|
| 189 |
+
case "FileEditorObservation":
|
| 190 |
+
case "StrReplaceEditorObservation":
|
| 191 |
+
if (event.observation.command === "view") {
|
| 192 |
+
observationKey = "OBSERVATION_MESSAGE$READ";
|
| 193 |
+
} else if (event.observation.command === "create") {
|
| 194 |
+
observationKey = "OBSERVATION_MESSAGE$WRITE";
|
| 195 |
+
} else {
|
| 196 |
+
observationKey = "OBSERVATION_MESSAGE$EDIT";
|
| 197 |
+
}
|
| 198 |
+
observationValues = {
|
| 199 |
+
path: event.observation.path || "",
|
| 200 |
+
};
|
| 201 |
+
break;
|
| 202 |
+
case "MCPToolObservation":
|
| 203 |
+
observationKey = "OBSERVATION_MESSAGE$MCP";
|
| 204 |
+
observationValues = {
|
| 205 |
+
mcp_tool_name: event.observation.tool_name,
|
| 206 |
+
};
|
| 207 |
+
break;
|
| 208 |
+
case "InvokeSkillObservation":
|
| 209 |
+
observationKey = "OBSERVATION_MESSAGE$INVOKE_SKILL";
|
| 210 |
+
observationValues = {
|
| 211 |
+
name: event.observation.skill_name,
|
| 212 |
+
};
|
| 213 |
+
break;
|
| 214 |
+
case "TaskObservation":
|
| 215 |
+
observationKey = "OBSERVATION_MESSAGE$TASK";
|
| 216 |
+
observationValues = {
|
| 217 |
+
name: event.observation.subagent,
|
| 218 |
+
};
|
| 219 |
+
break;
|
| 220 |
+
case "CanvasUIObservation":
|
| 221 |
+
observationKey = "OBSERVATION_MESSAGE$CANVAS_UI";
|
| 222 |
+
break;
|
| 223 |
+
case "ClientToolObservation":
|
| 224 |
+
if (event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME) {
|
| 225 |
+
observationKey = "OBSERVATION_MESSAGE$CANVAS_UI";
|
| 226 |
+
break;
|
| 227 |
+
}
|
| 228 |
+
if (event.tool_name === LAUNCH_CHILD_CONVERSATION_TOOL_NAME) {
|
| 229 |
+
observationKey = "OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION";
|
| 230 |
+
break;
|
| 231 |
+
}
|
| 232 |
+
return observationType.replace("Observation", "").toUpperCase();
|
| 233 |
+
case "SwitchLLMObservation":
|
| 234 |
+
observationKey = event.observation.is_error
|
| 235 |
+
? "MODEL$SWITCH_FAILED"
|
| 236 |
+
: "MODEL$SWITCHED_TO_PROFILE";
|
| 237 |
+
observationValues = {
|
| 238 |
+
name: event.observation.profile_name,
|
| 239 |
+
};
|
| 240 |
+
break;
|
| 241 |
+
case "BrowserObservation":
|
| 242 |
+
observationKey = "OBSERVATION_MESSAGE$BROWSE";
|
| 243 |
+
break;
|
| 244 |
+
case "TaskTrackerObservation": {
|
| 245 |
+
const { command } = event.observation;
|
| 246 |
+
if (command === "plan") {
|
| 247 |
+
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN";
|
| 248 |
+
} else {
|
| 249 |
+
// command === "view"
|
| 250 |
+
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW";
|
| 251 |
+
}
|
| 252 |
+
break;
|
| 253 |
+
}
|
| 254 |
+
case "ThinkObservation":
|
| 255 |
+
observationKey = "OBSERVATION_MESSAGE$THINK";
|
| 256 |
+
break;
|
| 257 |
+
case "GlobObservation":
|
| 258 |
+
observationKey = "OBSERVATION_MESSAGE$GLOB";
|
| 259 |
+
observationValues = {
|
| 260 |
+
pattern: event.observation.pattern
|
| 261 |
+
? trimEventTitleText(event.observation.pattern, 50)
|
| 262 |
+
: "",
|
| 263 |
+
};
|
| 264 |
+
break;
|
| 265 |
+
case "GrepObservation":
|
| 266 |
+
observationKey = "OBSERVATION_MESSAGE$GREP";
|
| 267 |
+
observationValues = {
|
| 268 |
+
pattern: event.observation.pattern
|
| 269 |
+
? trimEventTitleText(event.observation.pattern, 50)
|
| 270 |
+
: "",
|
| 271 |
+
};
|
| 272 |
+
break;
|
| 273 |
+
default:
|
| 274 |
+
// For unknown observations, use the type name
|
| 275 |
+
return observationType.replace("Observation", "").toUpperCase();
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
if (observationKey) {
|
| 279 |
+
return createTitleFromKey(observationKey, observationValues);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
return observationType;
|
| 283 |
+
};
|
| 284 |
+
|
| 285 |
+
const getCanvasUIClientObservationContent = (
|
| 286 |
+
event: ObservationEvent,
|
| 287 |
+
correspondingAction?: ActionEvent,
|
| 288 |
+
): string | null => {
|
| 289 |
+
if (
|
| 290 |
+
event.observation.kind !== "ClientToolObservation" ||
|
| 291 |
+
event.tool_name !== CANVAS_UI_CLIENT_TOOL_NAME ||
|
| 292 |
+
!correspondingAction ||
|
| 293 |
+
!isCanvasUIActionEvent(correspondingAction)
|
| 294 |
+
) {
|
| 295 |
+
return null;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
return `UI command '${correspondingAction.action.command}' dispatched to the Agent Canvas frontend.`;
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
export const getEventContent = (
|
| 302 |
+
event: OpenHandsEvent | SkillReadyEvent,
|
| 303 |
+
correspondingAction?: ActionEvent,
|
| 304 |
+
) => {
|
| 305 |
+
let title: React.ReactNode = "";
|
| 306 |
+
let details: string | React.ReactNode = "";
|
| 307 |
+
|
| 308 |
+
// Handle Skill Ready events first
|
| 309 |
+
if (isSkillReadyEvent(event)) {
|
| 310 |
+
// Use translation key if available, otherwise use "SKILL READY"
|
| 311 |
+
const skillReadyKey = "OBSERVATION_MESSAGE$SKILL_READY";
|
| 312 |
+
if (i18n.exists(skillReadyKey)) {
|
| 313 |
+
title = createTitleFromKey(skillReadyKey, {});
|
| 314 |
+
} else {
|
| 315 |
+
title = "Skill Ready";
|
| 316 |
+
}
|
| 317 |
+
details = event._skillReadyContent;
|
| 318 |
+
} else if (isActionEvent(event)) {
|
| 319 |
+
title = getActionEventTitle(event);
|
| 320 |
+
// Per-tool React visualizer when one is registered; markdown otherwise.
|
| 321 |
+
details = resolveVisualizerBody(event) ?? getActionContent(event);
|
| 322 |
+
} else if (isObservationEvent(event)) {
|
| 323 |
+
title = getObservationEventTitle(event, correspondingAction);
|
| 324 |
+
|
| 325 |
+
// For TaskTrackerObservation, use React component instead of markdown
|
| 326 |
+
if (event.observation.kind === "TaskTrackerObservation") {
|
| 327 |
+
details = (
|
| 328 |
+
<TaskTrackingObservationContent
|
| 329 |
+
event={event as ObservationEvent<TaskTrackerObservation>}
|
| 330 |
+
/>
|
| 331 |
+
);
|
| 332 |
+
} else {
|
| 333 |
+
details =
|
| 334 |
+
getCanvasUIClientObservationContent(event, correspondingAction) ??
|
| 335 |
+
resolveVisualizerBody(event, correspondingAction) ??
|
| 336 |
+
getObservationContent(event);
|
| 337 |
+
}
|
| 338 |
+
} else if (isACPToolCallEvent(event)) {
|
| 339 |
+
// ACP sub-agent tool calls reuse the same card shape as observations:
|
| 340 |
+
// title is "Running/Editing/Reading …" via a translation key that
|
| 341 |
+
// mirrors ACTION_MESSAGE$RUN etc.; details are markdown built from
|
| 342 |
+
// raw_input + raw_output the same way getTerminalObservationContent
|
| 343 |
+
// builds "Command: / Output:" blocks.
|
| 344 |
+
title = createTitleFromKey(getACPToolCallTitleKey(event), {
|
| 345 |
+
// Strip a redundant verb prefix the ACP server may have inlined
|
| 346 |
+
// (Claude Code emits ``"Read /path"`` for a read tool; combined
|
| 347 |
+
// with the ``"Reading <cmd>{{title}}</cmd>"`` template that lands
|
| 348 |
+
// as ``"Reading Read /path"``). See ``stripRedundantTitlePrefix``.
|
| 349 |
+
title: stripRedundantTitlePrefix(event),
|
| 350 |
+
});
|
| 351 |
+
details = getACPToolCallContent(event);
|
| 352 |
+
} else if (
|
| 353 |
+
// Lenient fallback for action-like events that fail the strict isActionEvent() guard
|
| 354 |
+
// (e.g., missing tool_name or tool_call_id). Extract a title from the action kind
|
| 355 |
+
// so the UI shows something meaningful instead of "Unknown event".
|
| 356 |
+
event.source === "agent" &&
|
| 357 |
+
"action" in event &&
|
| 358 |
+
event.action !== null &&
|
| 359 |
+
typeof event.action === "object" &&
|
| 360 |
+
"kind" in event.action &&
|
| 361 |
+
typeof event.action.kind === "string"
|
| 362 |
+
) {
|
| 363 |
+
title = String(event.action.kind).replace("Action", "").toUpperCase();
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
return {
|
| 367 |
+
title: title || i18n.t(I18nKey.EVENT$UNKNOWN_EVENT),
|
| 368 |
+
details,
|
| 369 |
+
};
|
| 370 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ObservationEvent } from "#/types/agent-server/core";
|
| 2 |
+
import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation";
|
| 3 |
+
import { SkillReadyItem } from "./get-skill-ready-content";
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Maps an InvokeSkillObservation to the SkillReadyItem shape so invoke-skill
|
| 7 |
+
* tool calls can reuse the Skill Ready expandable list. A single skill is
|
| 8 |
+
* invoked per observation, so this yields at most one item. Returns an empty
|
| 9 |
+
* array when there is neither a skill name nor any text content to show, which
|
| 10 |
+
* tells the caller to fall back to the default markdown body.
|
| 11 |
+
*/
|
| 12 |
+
export const getInvokeSkillItems = (
|
| 13 |
+
event: ObservationEvent<InvokeSkillObservation>,
|
| 14 |
+
): SkillReadyItem[] => {
|
| 15 |
+
const { observation } = event;
|
| 16 |
+
|
| 17 |
+
const content = observation.content
|
| 18 |
+
.filter((c) => c.type === "text")
|
| 19 |
+
.map((c) => c.text)
|
| 20 |
+
.join("\n")
|
| 21 |
+
.trim();
|
| 22 |
+
|
| 23 |
+
if (!observation.skill_name && !content) {
|
| 24 |
+
return [];
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
return [{ name: observation.skill_name, content }];
|
| 28 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
|
| 2 |
+
import { ObservationEvent } from "#/types/agent-server/core";
|
| 3 |
+
import { getObservationResult } from "./get-observation-result";
|
| 4 |
+
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
|
| 5 |
+
import i18n from "#/i18n";
|
| 6 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 7 |
+
import {
|
| 8 |
+
MCPToolObservation,
|
| 9 |
+
FinishObservation,
|
| 10 |
+
ThinkObservation,
|
| 11 |
+
BrowserObservation,
|
| 12 |
+
ExecuteBashObservation,
|
| 13 |
+
TerminalObservation,
|
| 14 |
+
FileEditorObservation,
|
| 15 |
+
StrReplaceEditorObservation,
|
| 16 |
+
TaskTrackerObservation,
|
| 17 |
+
GlobObservation,
|
| 18 |
+
GrepObservation,
|
| 19 |
+
InvokeSkillObservation,
|
| 20 |
+
CanvasUIObservation,
|
| 21 |
+
SwitchLLMObservation,
|
| 22 |
+
} from "#/types/agent-server/core/base/observation";
|
| 23 |
+
|
| 24 |
+
// File Editor Observations
|
| 25 |
+
const getFileEditorObservationContent = (
|
| 26 |
+
event: ObservationEvent<FileEditorObservation | StrReplaceEditorObservation>,
|
| 27 |
+
): string => {
|
| 28 |
+
const { observation } = event;
|
| 29 |
+
|
| 30 |
+
if (observation.error) {
|
| 31 |
+
return `**Error:**\n${observation.error}`;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Extract text content from the observation if it exists
|
| 35 |
+
const textContent =
|
| 36 |
+
"content" in observation && Array.isArray(observation.content)
|
| 37 |
+
? observation.content
|
| 38 |
+
.filter((c) => c.type === "text")
|
| 39 |
+
.map((c) => c.text)
|
| 40 |
+
.join("\n")
|
| 41 |
+
: null;
|
| 42 |
+
|
| 43 |
+
const successMessage = getObservationResult(event) === "success";
|
| 44 |
+
|
| 45 |
+
// For view commands or successful edits with content changes, format as code block
|
| 46 |
+
if (
|
| 47 |
+
(successMessage &&
|
| 48 |
+
"old_content" in observation &&
|
| 49 |
+
"new_content" in observation &&
|
| 50 |
+
observation.old_content &&
|
| 51 |
+
observation.new_content) ||
|
| 52 |
+
observation.command === "view"
|
| 53 |
+
) {
|
| 54 |
+
// Prefer content over output for view commands, fallback to output if content is not available
|
| 55 |
+
const displayContent = textContent || observation.output;
|
| 56 |
+
return `\`\`\`\n${displayContent}\n\`\`\``;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// For other commands, prefer content if available, otherwise use output
|
| 60 |
+
return textContent || observation.output;
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
// Command Observations
|
| 64 |
+
const getTerminalObservationContent = (
|
| 65 |
+
event: ObservationEvent<ExecuteBashObservation | TerminalObservation>,
|
| 66 |
+
): string => {
|
| 67 |
+
const { observation } = event;
|
| 68 |
+
|
| 69 |
+
// Extract text content from the observation
|
| 70 |
+
const textContent = observation.content
|
| 71 |
+
.filter((c) => c.type === "text")
|
| 72 |
+
.map((c) => c.text)
|
| 73 |
+
.join("\n");
|
| 74 |
+
|
| 75 |
+
let content = textContent || "";
|
| 76 |
+
|
| 77 |
+
if (content.length > MAX_CONTENT_LENGTH) {
|
| 78 |
+
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// Build the output string
|
| 82 |
+
let output = "";
|
| 83 |
+
|
| 84 |
+
// Display the command if available
|
| 85 |
+
if (observation.command) {
|
| 86 |
+
output += `Command: \`${observation.command}\`\n\n`;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Display the output
|
| 90 |
+
output += `Output:\n\`\`\`sh\n${content.trim() || i18n.t(I18nKey.OBSERVATION$COMMAND_NO_OUTPUT)}\n\`\`\``;
|
| 91 |
+
|
| 92 |
+
return output;
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
// Tool Observations
|
| 96 |
+
const getBrowserObservationContent = (
|
| 97 |
+
event: ObservationEvent<BrowserObservation>,
|
| 98 |
+
): string => {
|
| 99 |
+
const { observation } = event;
|
| 100 |
+
|
| 101 |
+
// Extract text content from the observation
|
| 102 |
+
const textContent =
|
| 103 |
+
"content" in observation && Array.isArray(observation.content)
|
| 104 |
+
? observation.content
|
| 105 |
+
.filter((c) => c.type === "text")
|
| 106 |
+
.map((c) => c.text)
|
| 107 |
+
.join("\n")
|
| 108 |
+
: observation.output || "";
|
| 109 |
+
|
| 110 |
+
let contentDetails = "";
|
| 111 |
+
|
| 112 |
+
if (observation.error) {
|
| 113 |
+
contentDetails += `**Error:**\n${observation.error}`;
|
| 114 |
+
} else if (textContent) {
|
| 115 |
+
contentDetails += `**Output:**\n${textContent}`;
|
| 116 |
+
} else {
|
| 117 |
+
contentDetails += "Browser action completed successfully.";
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
if (contentDetails.length > MAX_CONTENT_LENGTH) {
|
| 121 |
+
contentDetails = `${contentDetails.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
return contentDetails;
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const getMCPToolObservationContent = (
|
| 128 |
+
event: ObservationEvent<MCPToolObservation>,
|
| 129 |
+
): string => {
|
| 130 |
+
const { observation } = event;
|
| 131 |
+
|
| 132 |
+
// Extract text content from the observation
|
| 133 |
+
const textContent = observation.content
|
| 134 |
+
.filter((c) => c.type === "text")
|
| 135 |
+
.map((c) => c.text)
|
| 136 |
+
.join("\n");
|
| 137 |
+
|
| 138 |
+
let content = `**Tool:** ${observation.tool_name}\n\n`;
|
| 139 |
+
|
| 140 |
+
if (observation.is_error) {
|
| 141 |
+
content += `**Error:**\n${textContent}`;
|
| 142 |
+
} else {
|
| 143 |
+
content += `**Result:**\n${textContent}`;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
if (content.length > MAX_CONTENT_LENGTH) {
|
| 147 |
+
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return content;
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
// Invoke-skill observations
|
| 154 |
+
const getInvokeSkillObservationContent = (
|
| 155 |
+
event: ObservationEvent<InvokeSkillObservation>,
|
| 156 |
+
): string => {
|
| 157 |
+
const { observation } = event;
|
| 158 |
+
|
| 159 |
+
const textContent = observation.content
|
| 160 |
+
.filter((c) => c.type === "text")
|
| 161 |
+
.map((c) => c.text)
|
| 162 |
+
.join("\n");
|
| 163 |
+
|
| 164 |
+
const header = observation.skill_name
|
| 165 |
+
? `**Skill:** \`${observation.skill_name}\`\n\n`
|
| 166 |
+
: "";
|
| 167 |
+
const body = observation.is_error
|
| 168 |
+
? `**Error:**\n${textContent}`
|
| 169 |
+
: textContent;
|
| 170 |
+
|
| 171 |
+
let content = `${header}${body}`;
|
| 172 |
+
if (content.length > MAX_CONTENT_LENGTH) {
|
| 173 |
+
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
| 174 |
+
}
|
| 175 |
+
return content;
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
// Canvas UI observations — just surface the acknowledgement text.
|
| 179 |
+
const getCanvasUIObservationContent = (
|
| 180 |
+
event: ObservationEvent<CanvasUIObservation>,
|
| 181 |
+
): string =>
|
| 182 |
+
event.observation.content
|
| 183 |
+
.filter((c) => c.type === "text")
|
| 184 |
+
.map((c) => c.text)
|
| 185 |
+
.join("\n");
|
| 186 |
+
|
| 187 |
+
const getSwitchLLMObservationContent = (
|
| 188 |
+
event: ObservationEvent<SwitchLLMObservation>,
|
| 189 |
+
): string => {
|
| 190 |
+
const { observation } = event;
|
| 191 |
+
|
| 192 |
+
const textContent = observation.content
|
| 193 |
+
.filter((c) => c.type === "text")
|
| 194 |
+
.map((c) => c.text)
|
| 195 |
+
.join("\n");
|
| 196 |
+
|
| 197 |
+
if (observation.is_error) {
|
| 198 |
+
return textContent
|
| 199 |
+
? `**Error:**\n${textContent}`
|
| 200 |
+
: `**Error:**\nFailed to switch LLM profile \`${observation.profile_name}\`.`;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const parts = [`**Profile:** \`${observation.profile_name}\``];
|
| 204 |
+
if (observation.active_model) {
|
| 205 |
+
parts.push(`**Active model:** \`${observation.active_model}\``);
|
| 206 |
+
}
|
| 207 |
+
if (observation.reason) {
|
| 208 |
+
parts.push(`**Reason:** ${observation.reason}`);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
return parts.join("\n");
|
| 212 |
+
};
|
| 213 |
+
|
| 214 |
+
// Complex Observations
|
| 215 |
+
const getTaskTrackerObservationContent = (
|
| 216 |
+
event: ObservationEvent<TaskTrackerObservation>,
|
| 217 |
+
): string => {
|
| 218 |
+
const { observation } = event;
|
| 219 |
+
|
| 220 |
+
const { command, task_list: taskList } = observation;
|
| 221 |
+
let content = `**Command:** \`${command}\``;
|
| 222 |
+
|
| 223 |
+
if (command === "plan" && taskList.length > 0) {
|
| 224 |
+
content += `\n\n**Task List (${taskList.length} ${taskList.length === 1 ? "item" : "items"}):**\n`;
|
| 225 |
+
|
| 226 |
+
taskList.forEach((task, index: number) => {
|
| 227 |
+
const statusMap = {
|
| 228 |
+
todo: "⏳",
|
| 229 |
+
in_progress: "🔄",
|
| 230 |
+
done: "✅",
|
| 231 |
+
};
|
| 232 |
+
const statusIcon =
|
| 233 |
+
statusMap[task.status as keyof typeof statusMap] || "❓";
|
| 234 |
+
|
| 235 |
+
content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
|
| 236 |
+
if (task.notes) {
|
| 237 |
+
content += `\n *Notes: ${task.notes}*`;
|
| 238 |
+
}
|
| 239 |
+
});
|
| 240 |
+
} else if (command === "plan") {
|
| 241 |
+
content += "\n\n**Task List:** Empty";
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
if (
|
| 245 |
+
"content" in observation &&
|
| 246 |
+
observation.content &&
|
| 247 |
+
typeof observation.content === "string" &&
|
| 248 |
+
observation.content.trim()
|
| 249 |
+
) {
|
| 250 |
+
content += `\n\n**Result:** ${observation.content.trim()}`;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
return content;
|
| 254 |
+
};
|
| 255 |
+
|
| 256 |
+
// Simple Observations
|
| 257 |
+
const getThinkObservationContent = (
|
| 258 |
+
event: ObservationEvent<ThinkObservation>,
|
| 259 |
+
): string => {
|
| 260 |
+
const { observation } = event;
|
| 261 |
+
|
| 262 |
+
const textContent = observation.content
|
| 263 |
+
.filter((c) => c.type === "text")
|
| 264 |
+
.map((c) => c.text)
|
| 265 |
+
.join("\n");
|
| 266 |
+
|
| 267 |
+
return textContent || "";
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
const getFinishObservationContent = (
|
| 271 |
+
event: ObservationEvent<FinishObservation>,
|
| 272 |
+
): string => {
|
| 273 |
+
const { observation } = event;
|
| 274 |
+
|
| 275 |
+
// Extract text content from the observation
|
| 276 |
+
const textContent = observation.content
|
| 277 |
+
.filter((c) => c.type === "text")
|
| 278 |
+
.map((c) => c.text)
|
| 279 |
+
.join("\n");
|
| 280 |
+
|
| 281 |
+
let content = "";
|
| 282 |
+
|
| 283 |
+
if (observation.is_error) {
|
| 284 |
+
content += `**Error:**\n${textContent}`;
|
| 285 |
+
} else {
|
| 286 |
+
content += textContent;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
return content;
|
| 290 |
+
};
|
| 291 |
+
|
| 292 |
+
// Glob Observations
|
| 293 |
+
const getGlobObservationContent = (
|
| 294 |
+
event: ObservationEvent<GlobObservation>,
|
| 295 |
+
): string => {
|
| 296 |
+
const { observation } = event;
|
| 297 |
+
|
| 298 |
+
// Extract text content from the observation
|
| 299 |
+
const textContent = observation.content
|
| 300 |
+
.filter((c) => c.type === "text")
|
| 301 |
+
.map((c) => c.text)
|
| 302 |
+
.join("\n");
|
| 303 |
+
|
| 304 |
+
let content = `**Pattern:** \`${observation.pattern}\`\n`;
|
| 305 |
+
content += `**Search Path:** \`${observation.search_path}\`\n\n`;
|
| 306 |
+
|
| 307 |
+
if (observation.is_error) {
|
| 308 |
+
content += `**Error:**\n${textContent}`;
|
| 309 |
+
} else if (observation.files.length === 0) {
|
| 310 |
+
content += "**Result:** No files found.";
|
| 311 |
+
} else {
|
| 312 |
+
content += `**Files Found (${observation.files.length}${observation.truncated ? "+, truncated" : ""}):**\n`;
|
| 313 |
+
content += observation.files.map((f) => `- \`${f}\``).join("\n");
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
if (content.length > MAX_CONTENT_LENGTH) {
|
| 317 |
+
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
return content;
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
// Grep Observations
|
| 324 |
+
const getGrepObservationContent = (
|
| 325 |
+
event: ObservationEvent<GrepObservation>,
|
| 326 |
+
): string => {
|
| 327 |
+
const { observation } = event;
|
| 328 |
+
|
| 329 |
+
// Extract text content from the observation
|
| 330 |
+
const textContent = observation.content
|
| 331 |
+
.filter((c) => c.type === "text")
|
| 332 |
+
.map((c) => c.text)
|
| 333 |
+
.join("\n");
|
| 334 |
+
|
| 335 |
+
let content = `**Pattern:** \`${observation.pattern}\`\n`;
|
| 336 |
+
content += `**Search Path:** \`${observation.search_path}\`\n`;
|
| 337 |
+
if (observation.include_pattern) {
|
| 338 |
+
content += `**Include:** \`${observation.include_pattern}\`\n`;
|
| 339 |
+
}
|
| 340 |
+
content += "\n";
|
| 341 |
+
|
| 342 |
+
if (observation.is_error) {
|
| 343 |
+
content += `**Error:**\n${textContent}`;
|
| 344 |
+
} else if (observation.matches.length === 0) {
|
| 345 |
+
content += "**Result:** No matches found.";
|
| 346 |
+
} else {
|
| 347 |
+
content += `**Matches (${observation.matches.length}${observation.truncated ? "+, truncated" : ""}):**\n`;
|
| 348 |
+
content += observation.matches.map((f) => `- \`${f}\``).join("\n");
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
if (content.length > MAX_CONTENT_LENGTH) {
|
| 352 |
+
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
return content;
|
| 356 |
+
};
|
| 357 |
+
|
| 358 |
+
export const getObservationContent = (event: ObservationEvent): string => {
|
| 359 |
+
const observationType = event.observation.kind;
|
| 360 |
+
|
| 361 |
+
switch (observationType) {
|
| 362 |
+
case "FileEditorObservation":
|
| 363 |
+
case "StrReplaceEditorObservation":
|
| 364 |
+
return getFileEditorObservationContent(
|
| 365 |
+
event as ObservationEvent<
|
| 366 |
+
FileEditorObservation | StrReplaceEditorObservation
|
| 367 |
+
>,
|
| 368 |
+
);
|
| 369 |
+
|
| 370 |
+
case "ExecuteBashObservation":
|
| 371 |
+
case "TerminalObservation":
|
| 372 |
+
return getTerminalObservationContent(
|
| 373 |
+
event as ObservationEvent<ExecuteBashObservation | TerminalObservation>,
|
| 374 |
+
);
|
| 375 |
+
|
| 376 |
+
case "BrowserObservation":
|
| 377 |
+
return getBrowserObservationContent(
|
| 378 |
+
event as ObservationEvent<BrowserObservation>,
|
| 379 |
+
);
|
| 380 |
+
|
| 381 |
+
case "MCPToolObservation":
|
| 382 |
+
return getMCPToolObservationContent(
|
| 383 |
+
event as ObservationEvent<MCPToolObservation>,
|
| 384 |
+
);
|
| 385 |
+
|
| 386 |
+
case "TaskTrackerObservation":
|
| 387 |
+
return getTaskTrackerObservationContent(
|
| 388 |
+
event as ObservationEvent<TaskTrackerObservation>,
|
| 389 |
+
);
|
| 390 |
+
|
| 391 |
+
case "ThinkObservation":
|
| 392 |
+
return getThinkObservationContent(
|
| 393 |
+
event as ObservationEvent<ThinkObservation>,
|
| 394 |
+
);
|
| 395 |
+
|
| 396 |
+
case "FinishObservation":
|
| 397 |
+
return getFinishObservationContent(
|
| 398 |
+
event as ObservationEvent<FinishObservation>,
|
| 399 |
+
);
|
| 400 |
+
|
| 401 |
+
case "GlobObservation":
|
| 402 |
+
return getGlobObservationContent(
|
| 403 |
+
event as ObservationEvent<GlobObservation>,
|
| 404 |
+
);
|
| 405 |
+
|
| 406 |
+
case "GrepObservation":
|
| 407 |
+
return getGrepObservationContent(
|
| 408 |
+
event as ObservationEvent<GrepObservation>,
|
| 409 |
+
);
|
| 410 |
+
|
| 411 |
+
case "InvokeSkillObservation":
|
| 412 |
+
return getInvokeSkillObservationContent(
|
| 413 |
+
event as ObservationEvent<InvokeSkillObservation>,
|
| 414 |
+
);
|
| 415 |
+
|
| 416 |
+
case "CanvasUIObservation":
|
| 417 |
+
return getCanvasUIObservationContent(
|
| 418 |
+
event as ObservationEvent<CanvasUIObservation>,
|
| 419 |
+
);
|
| 420 |
+
|
| 421 |
+
case "ClientToolObservation":
|
| 422 |
+
return event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME
|
| 423 |
+
? getCanvasUIObservationContent(
|
| 424 |
+
event as ObservationEvent<CanvasUIObservation>,
|
| 425 |
+
)
|
| 426 |
+
: getDefaultEventContent(event);
|
| 427 |
+
|
| 428 |
+
case "SwitchLLMObservation":
|
| 429 |
+
return getSwitchLLMObservationContent(
|
| 430 |
+
event as ObservationEvent<SwitchLLMObservation>,
|
| 431 |
+
);
|
| 432 |
+
|
| 433 |
+
default:
|
| 434 |
+
return getDefaultEventContent(event);
|
| 435 |
+
}
|
| 436 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ObservationEvent } from "#/types/agent-server/core";
|
| 2 |
+
import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event";
|
| 3 |
+
|
| 4 |
+
export type ObservationResultStatus = "success" | "error" | "timeout";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Map an ACPToolCallEvent's lifecycle + error flags to the same
|
| 8 |
+
* success/error status the rest of the UI uses. A non-terminal call
|
| 9 |
+
* (``pending`` / ``in_progress``) falls through to ``undefined`` so the
|
| 10 |
+
* SuccessIndicator renders nothing — the card shows as "running" via the
|
| 11 |
+
* absence of a check mark, matching how regular ActionEvents are displayed
|
| 12 |
+
* before their ObservationEvent arrives.
|
| 13 |
+
*/
|
| 14 |
+
export const getACPToolCallResult = (
|
| 15 |
+
event: ACPToolCallEvent,
|
| 16 |
+
): ObservationResultStatus | undefined => {
|
| 17 |
+
if (event.is_error || event.status === "failed") return "error";
|
| 18 |
+
if (event.status === "completed") return "success";
|
| 19 |
+
return undefined;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
export const getObservationResult = (
|
| 23 |
+
event: ObservationEvent,
|
| 24 |
+
): ObservationResultStatus => {
|
| 25 |
+
const { observation } = event;
|
| 26 |
+
const observationType = observation.kind;
|
| 27 |
+
|
| 28 |
+
switch (observationType) {
|
| 29 |
+
case "ExecuteBashObservation": {
|
| 30 |
+
const exitCode = observation.exit_code;
|
| 31 |
+
const { metadata } = observation;
|
| 32 |
+
|
| 33 |
+
if (exitCode === -1 || metadata.exit_code === -1) return "timeout"; // Command timed out
|
| 34 |
+
if (exitCode === 0 || metadata.exit_code === 0) return "success"; // Command executed successfully
|
| 35 |
+
return "error"; // Command failed
|
| 36 |
+
}
|
| 37 |
+
case "TerminalObservation": {
|
| 38 |
+
const exitCode =
|
| 39 |
+
observation.exit_code ?? observation.metadata.exit_code ?? null;
|
| 40 |
+
|
| 41 |
+
if (observation.timeout || exitCode === -1) return "timeout";
|
| 42 |
+
if (exitCode === 0) return "success";
|
| 43 |
+
if (observation.is_error) return "error";
|
| 44 |
+
return "success";
|
| 45 |
+
}
|
| 46 |
+
case "FileEditorObservation":
|
| 47 |
+
case "StrReplaceEditorObservation":
|
| 48 |
+
// Check if there's an error
|
| 49 |
+
if (observation.error) return "error";
|
| 50 |
+
return "success";
|
| 51 |
+
case "MCPToolObservation":
|
| 52 |
+
if (observation.is_error) return "error";
|
| 53 |
+
return "success";
|
| 54 |
+
case "SwitchLLMObservation":
|
| 55 |
+
if (observation.is_error) return "error";
|
| 56 |
+
return "success";
|
| 57 |
+
case "InvokeSkillObservation":
|
| 58 |
+
if (observation.is_error) return "error";
|
| 59 |
+
return "success";
|
| 60 |
+
case "TaskObservation":
|
| 61 |
+
if (observation.is_error || observation.status === "failed")
|
| 62 |
+
return "error";
|
| 63 |
+
return "success";
|
| 64 |
+
case "CanvasUIObservation":
|
| 65 |
+
if (observation.is_error) return "error";
|
| 66 |
+
return "success";
|
| 67 |
+
default:
|
| 68 |
+
return "success";
|
| 69 |
+
}
|
| 70 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { TextContent } from "#/types/agent-server/core/base/common";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* Represents a single activated skill with its name and associated content.
|
| 5 |
+
*/
|
| 6 |
+
export interface SkillReadyItem {
|
| 7 |
+
name: string;
|
| 8 |
+
content: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Extracts all text content from an array of TextContent items.
|
| 13 |
+
*/
|
| 14 |
+
function extractAllText(extendedContent: TextContent[]): string {
|
| 15 |
+
return extendedContent
|
| 16 |
+
.filter((c) => c.type === "text")
|
| 17 |
+
.map((c) => c.text)
|
| 18 |
+
.join("");
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Extracts all <EXTRA_INFO> blocks from the given text.
|
| 23 |
+
* Returns an array of content strings (without the wrapper tags).
|
| 24 |
+
*/
|
| 25 |
+
const extractExtraInfoBlocks = (text: string): string[] => {
|
| 26 |
+
const blockRegex = /<EXTRA_INFO>([\s\S]*?)<\/EXTRA_INFO>/gi;
|
| 27 |
+
return Array.from(text.matchAll(blockRegex), (match) =>
|
| 28 |
+
match[1].trim(),
|
| 29 |
+
).filter(Boolean);
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Formats a single skill with its corresponding content block.
|
| 34 |
+
*/
|
| 35 |
+
const formatSkillWithContent = (
|
| 36 |
+
skill: string,
|
| 37 |
+
contentBlock: string | undefined,
|
| 38 |
+
): string => {
|
| 39 |
+
let formatted = `\n\n- **${skill}**`;
|
| 40 |
+
|
| 41 |
+
if (contentBlock) {
|
| 42 |
+
formatted += `\n\n${contentBlock}`;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return formatted;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Formats skills paired with their corresponding extended content blocks.
|
| 50 |
+
*/
|
| 51 |
+
const formatSkillKnowledge = (
|
| 52 |
+
activatedSkills: string[],
|
| 53 |
+
extraInfoBlocks: string[],
|
| 54 |
+
): string => {
|
| 55 |
+
let content = `\n\n**Triggered Skill Knowledge:**`;
|
| 56 |
+
|
| 57 |
+
activatedSkills.forEach((skill, index) => {
|
| 58 |
+
const contentBlock = extraInfoBlocks[index];
|
| 59 |
+
content += formatSkillWithContent(skill, contentBlock);
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
return content;
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Formats extended content blocks when no skills are present.
|
| 67 |
+
*/
|
| 68 |
+
const formatExtendedContentOnly = (extraInfoBlocks: string[]): string => {
|
| 69 |
+
let content = `\n\n**Extended Content:**`;
|
| 70 |
+
|
| 71 |
+
extraInfoBlocks.forEach((block) => {
|
| 72 |
+
content += `\n\n${block}`;
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
return content;
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Extracts EXTRA_INFO blocks from extended content.
|
| 80 |
+
*/
|
| 81 |
+
const getExtraInfoBlocks = (extendedContent?: TextContent[]): string[] => {
|
| 82 |
+
if (!extendedContent) return [];
|
| 83 |
+
return extractExtraInfoBlocks(extractAllText(extendedContent));
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Formats activated skills and extended content into markdown for display.
|
| 88 |
+
* Each skill is paired with its corresponding <EXTRA_INFO> block by index.
|
| 89 |
+
*/
|
| 90 |
+
export const getSkillReadyContent = (
|
| 91 |
+
activatedSkills: string[],
|
| 92 |
+
extendedContent: TextContent[],
|
| 93 |
+
): string => {
|
| 94 |
+
const extraInfoBlocks = getExtraInfoBlocks(extendedContent);
|
| 95 |
+
|
| 96 |
+
if (activatedSkills && activatedSkills.length > 0) {
|
| 97 |
+
return formatSkillKnowledge(activatedSkills, extraInfoBlocks);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (extraInfoBlocks.length > 0) {
|
| 101 |
+
return formatExtendedContentOnly(extraInfoBlocks);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
return "";
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* Returns structured skill items with their names and associated content.
|
| 109 |
+
* Each skill is paired with its corresponding <EXTRA_INFO> block by index.
|
| 110 |
+
*/
|
| 111 |
+
export const getSkillReadyItems = (
|
| 112 |
+
activatedSkills: string[],
|
| 113 |
+
extendedContent: TextContent[],
|
| 114 |
+
): SkillReadyItem[] => {
|
| 115 |
+
const extraInfoBlocks = getExtraInfoBlocks(extendedContent);
|
| 116 |
+
|
| 117 |
+
if (activatedSkills && activatedSkills.length > 0) {
|
| 118 |
+
return activatedSkills.map((skill, index) => ({
|
| 119 |
+
name: skill,
|
| 120 |
+
content: extraInfoBlocks[index] ?? "",
|
| 121 |
+
}));
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
return extraInfoBlocks.map((block, index) => ({
|
| 125 |
+
name: `Extended Content ${index + 1}`,
|
| 126 |
+
content: block,
|
| 127 |
+
}));
|
| 128 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MessageEvent } from "#/types/agent-server/core";
|
| 2 |
+
import i18n from "#/i18n";
|
| 3 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 4 |
+
|
| 5 |
+
export const parseMessageFromEvent = (event: MessageEvent): string => {
|
| 6 |
+
const message = event.llm_message;
|
| 7 |
+
|
| 8 |
+
// Safety check: ensure llm_message exists and has content
|
| 9 |
+
if (!message?.content) {
|
| 10 |
+
return "";
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// Get the text content from the message
|
| 14 |
+
let textContent = "";
|
| 15 |
+
if (message.content) {
|
| 16 |
+
if (Array.isArray(message.content)) {
|
| 17 |
+
// Handle array of content blocks
|
| 18 |
+
textContent = message.content
|
| 19 |
+
.filter((content) => content.type === "text")
|
| 20 |
+
.map((content) => content.text)
|
| 21 |
+
.join("\n");
|
| 22 |
+
} else if (typeof message.content === "string") {
|
| 23 |
+
// Handle string content
|
| 24 |
+
textContent = message.content;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Check if there are image_urls in the message content
|
| 29 |
+
const hasImages =
|
| 30 |
+
Array.isArray(message.content) &&
|
| 31 |
+
message.content.some((content) => content.type === "image");
|
| 32 |
+
|
| 33 |
+
if (!hasImages) {
|
| 34 |
+
return textContent;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// If there are images, try to split by the augmented prompt delimiter
|
| 38 |
+
const delimiter = i18n.t(I18nKey.CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE);
|
| 39 |
+
const parts = textContent.split(delimiter);
|
| 40 |
+
|
| 41 |
+
return parts[0];
|
| 42 |
+
};
|
src/components/conversation-events/chat/event-content-helpers/shared.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { OpenHandsEvent } from "#/types/agent-server/core";
|
| 2 |
+
|
| 3 |
+
export const MAX_CONTENT_LENGTH = 1000;
|
| 4 |
+
|
| 5 |
+
export const getDefaultEventContent = (event: OpenHandsEvent): string =>
|
| 6 |
+
`\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``;
|
src/components/conversation-events/chat/event-content-helpers/should-render-event.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CHILD_CONVERSATION_RESULT_PREFIX } from "#/constants/child-conversation";
|
| 2 |
+
import { MessageEvent, OpenHandsEvent } from "#/types/agent-server/core";
|
| 3 |
+
import {
|
| 4 |
+
isActionEvent,
|
| 5 |
+
isObservationEvent,
|
| 6 |
+
isMessageEvent,
|
| 7 |
+
isAgentErrorEvent,
|
| 8 |
+
isConversationStateUpdateEvent,
|
| 9 |
+
isGoalConversationStateUpdateEvent,
|
| 10 |
+
isHookExecutionEvent,
|
| 11 |
+
isACPToolCallEvent,
|
| 12 |
+
isStreamingDeltaEvent,
|
| 13 |
+
} from "#/types/agent-server/type-guards";
|
| 14 |
+
|
| 15 |
+
// Prefixes of the SDK goal-loop re-prompts (FOLLOWUP_PROMPT / RESUME_PROMPT in
|
| 16 |
+
// openhands.sdk .../conversation/goal/prompts.py). The goal loop injects these
|
| 17 |
+
// as `user` messages each round to steer the agent, and FOLLOWUP_PROMPT embeds
|
| 18 |
+
// the judge's verdict — which the goal banner already surfaces. The persisted
|
| 19 |
+
// event carries no marker distinguishing them from real user input, so we match
|
| 20 |
+
// the prompt text to keep this machinery out of the chat. Brittle by design;
|
| 21 |
+
// keep in sync with the SDK prompts (the durable fix is a persisted goal-loop
|
| 22 |
+
// flag on the event).
|
| 23 |
+
const GOAL_REPROMPT_PREFIXES = [
|
| 24 |
+
"The goal is NOT yet complete (audit iteration",
|
| 25 |
+
"Resuming a goal that was paused or interrupted.",
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
const userMessageText = (event: MessageEvent): string | null => {
|
| 29 |
+
if (event.llm_message?.role !== "user") return null;
|
| 30 |
+
const content = event.llm_message.content;
|
| 31 |
+
return Array.isArray(content)
|
| 32 |
+
? content
|
| 33 |
+
.filter((c) => c.type === "text")
|
| 34 |
+
.map((c) => c.text)
|
| 35 |
+
.join("\n")
|
| 36 |
+
: "";
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const isGoalLoopReprompt = (event: MessageEvent): boolean => {
|
| 40 |
+
const text = userMessageText(event);
|
| 41 |
+
if (text === null) return false;
|
| 42 |
+
return GOAL_REPROMPT_PREFIXES.some((prefix) => text.startsWith(prefix));
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
// The frontend posts the outcome of a `launch_child_conversation` call back as
|
| 46 |
+
// a `user` message because client tools have no result channel (see
|
| 47 |
+
// `services/child-conversation-launch.ts`). It is a JSON payload addressed to
|
| 48 |
+
// the agent, which reports the child's id and link in its own reply, so the
|
| 49 |
+
// raw message does not belong in the chat.
|
| 50 |
+
const isChildConversationResult = (event: MessageEvent): boolean =>
|
| 51 |
+
userMessageText(event)?.startsWith(CHILD_CONVERSATION_RESULT_PREFIX) ?? false;
|
| 52 |
+
|
| 53 |
+
export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
| 54 |
+
if (isConversationStateUpdateEvent(event)) {
|
| 55 |
+
// A finished `/goal` loop renders inline so it settles into the
|
| 56 |
+
// conversation; the live (active) banner is shown separately by
|
| 57 |
+
// GoalStatusBanner, and all other state updates (and the in-progress goal
|
| 58 |
+
// events) stay hidden.
|
| 59 |
+
return isGoalConversationStateUpdateEvent(event) && !event.value.active;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Render action events (with filtering)
|
| 63 |
+
if (isActionEvent(event)) {
|
| 64 |
+
// For V1, action is an object with kind property
|
| 65 |
+
const actionType = event.action.kind;
|
| 66 |
+
|
| 67 |
+
if (!actionType) {
|
| 68 |
+
return false;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Hide user commands from the chat interface
|
| 72 |
+
if (actionType === "ExecuteBashAction" && event.source === "user") {
|
| 73 |
+
return false;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// Hide PlanningFileEditorAction - handled separately with PlanPreview component
|
| 77 |
+
if (actionType === "PlanningFileEditorAction") {
|
| 78 |
+
return false;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// The model switch tool reuses the same inline model message UI as
|
| 82 |
+
// `/model <profile>` once the observation arrives.
|
| 83 |
+
if (actionType === "SwitchLLMAction") {
|
| 84 |
+
return false;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
return true;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Render observation events
|
| 91 |
+
if (isObservationEvent(event)) {
|
| 92 |
+
// Successful model switches are rendered through ModelMessages so they
|
| 93 |
+
// look identical to `/model <profile>` confirmations. Failed switches
|
| 94 |
+
// still render as observations so the error remains visible in chat.
|
| 95 |
+
if (
|
| 96 |
+
event.observation.kind === "SwitchLLMObservation" &&
|
| 97 |
+
!event.observation.is_error
|
| 98 |
+
) {
|
| 99 |
+
return false;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return true;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Render message events (user and assistant messages), except the goal loop's
|
| 106 |
+
// injected re-prompts — the judge feedback they carry is shown in the goal
|
| 107 |
+
// banner, so otherwise they leak into the chat as fake user turns — and the
|
| 108 |
+
// child-conversation launch results, which are machine payloads the agent
|
| 109 |
+
// relays in its own reply.
|
| 110 |
+
if (isMessageEvent(event)) {
|
| 111 |
+
return !isGoalLoopReprompt(event) && !isChildConversationResult(event);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Render agent error events
|
| 115 |
+
if (isAgentErrorEvent(event)) {
|
| 116 |
+
return true;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
// Render hook execution events
|
| 120 |
+
if (isHookExecutionEvent(event)) {
|
| 121 |
+
return true;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Render ACP sub-agent tool call events at every lifecycle stage. The SDK
|
| 125 |
+
// now persists exactly two events per ``tool_call_id`` — one early
|
| 126 |
+
// ``started`` event (``pending`` / ``in_progress``) and one terminal
|
| 127 |
+
// (``completed`` / ``failed``) event — the action->observation pair for a
|
| 128 |
+
// tool call. The ``started`` event renders the card as "running" (no check
|
| 129 |
+
// mark; see ``getACPToolCallResult``) and ``handleEventForUI`` replaces it
|
| 130 |
+
// in place by ``tool_call_id`` once the terminal event arrives, mirroring
|
| 131 |
+
// how an ObservationEvent supersedes its ActionEvent. The old terminal-only
|
| 132 |
+
// gate existed because the source fanned out one cumulative-output frame per
|
| 133 |
+
// ``ToolCallProgress``, which flashed half-formed cards mid-stream; that
|
| 134 |
+
// fan-out is gone, so the running card is now a single clean event.
|
| 135 |
+
if (isACPToolCallEvent(event)) {
|
| 136 |
+
return true;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (isStreamingDeltaEvent(event)) {
|
| 140 |
+
return event.content !== null || event.reasoning_content !== null;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// Don't render any other event types (system events, etc.)
|
| 144 |
+
return false;
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
export const hasUserEvent = (events: OpenHandsEvent[]) =>
|
| 148 |
+
events.some((event) => event.source === "user");
|
src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import ArrowDown from "#/icons/angle-down-solid.svg?react";
|
| 4 |
+
import ArrowUp from "#/icons/angle-up-solid.svg?react";
|
| 5 |
+
import LightbulbIcon from "#/icons/lightbulb.svg?react";
|
| 6 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 7 |
+
import { MarkdownRenderer } from "../../../features/markdown/markdown-renderer";
|
| 8 |
+
|
| 9 |
+
interface CollapsibleThinkingProps {
|
| 10 |
+
/** The thinking / reasoning content to display when expanded. */
|
| 11 |
+
content: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Renders agent thinking or extended reasoning content inside a collapsible
|
| 16 |
+
* section. Collapsed by default so the chat stays compact — especially
|
| 17 |
+
* useful when the thinking language differs from the conversation language.
|
| 18 |
+
*/
|
| 19 |
+
export function CollapsibleThinking({ content }: CollapsibleThinkingProps) {
|
| 20 |
+
const { t } = useTranslation("openhands");
|
| 21 |
+
const [expanded, setExpanded] = React.useState(false);
|
| 22 |
+
|
| 23 |
+
if (!content.trim()) {
|
| 24 |
+
return null;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const Chevron = expanded ? ArrowUp : ArrowDown;
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div
|
| 31 |
+
className="my-1 w-full py-1 text-sm"
|
| 32 |
+
data-testid="collapsible-thinking"
|
| 33 |
+
>
|
| 34 |
+
<button
|
| 35 |
+
type="button"
|
| 36 |
+
onClick={() => setExpanded((prev) => !prev)}
|
| 37 |
+
aria-expanded={expanded}
|
| 38 |
+
aria-label={
|
| 39 |
+
expanded ? t(I18nKey.THINKING$COLLAPSE) : t(I18nKey.THINKING$EXPAND)
|
| 40 |
+
}
|
| 41 |
+
data-testid="collapsible-thinking-toggle"
|
| 42 |
+
className="w-full flex items-center gap-2 text-left cursor-pointer"
|
| 43 |
+
>
|
| 44 |
+
<Chevron className="h-4 w-4 fill-[var(--oh-muted)] flex-shrink-0" />
|
| 45 |
+
<LightbulbIcon className="h-4 w-4 fill-[var(--oh-muted)] flex-shrink-0" />
|
| 46 |
+
<span className="font-normal text-[var(--oh-muted)]">
|
| 47 |
+
{t(I18nKey.THINKING$TITLE)}
|
| 48 |
+
</span>
|
| 49 |
+
</button>
|
| 50 |
+
|
| 51 |
+
{expanded && (
|
| 52 |
+
<div className="mt-1.5 pl-6" data-testid="collapsible-thinking-content">
|
| 53 |
+
<MarkdownRenderer>{content}</MarkdownRenderer>
|
| 54 |
+
</div>
|
| 55 |
+
)}
|
| 56 |
+
</div>
|
| 57 |
+
);
|
| 58 |
+
}
|
src/components/conversation-events/chat/event-message-components/critic-result-display.tsx
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import ArrowDown from "#/icons/angle-down-solid.svg?react";
|
| 4 |
+
import ArrowUp from "#/icons/angle-up-solid.svg?react";
|
| 5 |
+
import { useSettings } from "#/hooks/query/use-settings";
|
| 6 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 7 |
+
import type {
|
| 8 |
+
CriticResult,
|
| 9 |
+
CriticFeature,
|
| 10 |
+
CriticCategorizedFeatures,
|
| 11 |
+
} from "#/types/agent-server/core/base/critic";
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Normalize potentially malformed runtime scores before rendering.
|
| 15 |
+
*/
|
| 16 |
+
function normalizeScore(score: number): number {
|
| 17 |
+
if (!Number.isFinite(score)) return 0;
|
| 18 |
+
return Math.min(1, Math.max(0, score));
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Convert a normalized score (0-1) to a 5-star rating string.
|
| 23 |
+
*/
|
| 24 |
+
function getStarRating(normalizedScore: number): {
|
| 25 |
+
filled: number;
|
| 26 |
+
empty: number;
|
| 27 |
+
} {
|
| 28 |
+
const filled = Math.round(normalizedScore * 5);
|
| 29 |
+
return { filled, empty: 5 - filled };
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Get the color class for the star rating based on score.
|
| 34 |
+
*/
|
| 35 |
+
function getScoreColorClass(score: number): string {
|
| 36 |
+
if (score >= 0.6) return "text-green-400";
|
| 37 |
+
if (score >= 0.4) return "text-yellow-400";
|
| 38 |
+
return "text-red-400";
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Get the color class for an issue probability.
|
| 43 |
+
*/
|
| 44 |
+
function getIssueColorClass(probability: number): string {
|
| 45 |
+
if (probability >= 0.7) return "text-red-400 font-semibold";
|
| 46 |
+
if (probability >= 0.5) return "text-yellow-400";
|
| 47 |
+
return "text-neutral-400";
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function isSettingsRecord(value: unknown): value is Record<string, unknown> {
|
| 51 |
+
return (
|
| 52 |
+
value !== null &&
|
| 53 |
+
value !== undefined &&
|
| 54 |
+
typeof value === "object" &&
|
| 55 |
+
!Array.isArray(value)
|
| 56 |
+
);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function getIterativeRefinementEnabled(
|
| 60 |
+
agentSettings: Record<string, unknown> | null | undefined,
|
| 61 |
+
): boolean | null {
|
| 62 |
+
const verification = agentSettings?.verification;
|
| 63 |
+
if (!isSettingsRecord(verification)) {
|
| 64 |
+
return null;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const value = verification.enable_iterative_refinement;
|
| 68 |
+
return typeof value === "boolean" ? value : null;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Renders a single issue feature with its probability.
|
| 73 |
+
*/
|
| 74 |
+
function FeatureItem({ feature }: { feature: CriticFeature }) {
|
| 75 |
+
const percentage = Math.round(feature.probability * 100);
|
| 76 |
+
const colorClass = getIssueColorClass(feature.probability);
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<span className="inline-flex items-center gap-1">
|
| 80 |
+
<span className="text-neutral-200">{feature.display_name}</span>
|
| 81 |
+
<span className={colorClass}>({percentage}%)</span>
|
| 82 |
+
</span>
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Renders a category of features (e.g., "Potential Issues", "Infrastructure").
|
| 88 |
+
*/
|
| 89 |
+
function FeatureCategory({
|
| 90 |
+
label,
|
| 91 |
+
features,
|
| 92 |
+
}: {
|
| 93 |
+
label: string;
|
| 94 |
+
features: CriticFeature[];
|
| 95 |
+
}) {
|
| 96 |
+
if (!features || features.length === 0) return null;
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div className="flex flex-wrap items-center gap-x-1 text-xs">
|
| 100 |
+
<span className="font-semibold text-neutral-300">{label}</span>
|
| 101 |
+
{features.map((feature, i) => (
|
| 102 |
+
<React.Fragment key={feature.name}>
|
| 103 |
+
{i > 0 && <span className="text-neutral-500">·</span>}
|
| 104 |
+
<FeatureItem feature={feature} />
|
| 105 |
+
</React.Fragment>
|
| 106 |
+
))}
|
| 107 |
+
</div>
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Renders the categorized features breakdown.
|
| 113 |
+
*/
|
| 114 |
+
function FeaturesBreakdown({
|
| 115 |
+
categorized,
|
| 116 |
+
}: {
|
| 117 |
+
categorized: CriticCategorizedFeatures;
|
| 118 |
+
}) {
|
| 119 |
+
const { t } = useTranslation();
|
| 120 |
+
|
| 121 |
+
const hasAgentIssues =
|
| 122 |
+
categorized.agent_behavioral_issues &&
|
| 123 |
+
categorized.agent_behavioral_issues.length > 0;
|
| 124 |
+
const hasUserPatterns =
|
| 125 |
+
categorized.user_followup_patterns &&
|
| 126 |
+
categorized.user_followup_patterns.length > 0;
|
| 127 |
+
const hasInfra =
|
| 128 |
+
categorized.infrastructure_issues &&
|
| 129 |
+
categorized.infrastructure_issues.length > 0;
|
| 130 |
+
const hasOther = categorized.other && categorized.other.length > 0;
|
| 131 |
+
|
| 132 |
+
if (!hasAgentIssues && !hasUserPatterns && !hasInfra && !hasOther) {
|
| 133 |
+
return null;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
return (
|
| 137 |
+
<div className="flex flex-col gap-1 mt-1.5">
|
| 138 |
+
{hasAgentIssues && (
|
| 139 |
+
<FeatureCategory
|
| 140 |
+
label={t(I18nKey.CRITIC$POTENTIAL_ISSUES)}
|
| 141 |
+
features={categorized.agent_behavioral_issues!}
|
| 142 |
+
/>
|
| 143 |
+
)}
|
| 144 |
+
{hasInfra && (
|
| 145 |
+
<FeatureCategory
|
| 146 |
+
label={t(I18nKey.CRITIC$INFRASTRUCTURE)}
|
| 147 |
+
features={categorized.infrastructure_issues!}
|
| 148 |
+
/>
|
| 149 |
+
)}
|
| 150 |
+
{hasUserPatterns && (
|
| 151 |
+
<FeatureCategory
|
| 152 |
+
label={t(I18nKey.CRITIC$LIKELY_FOLLOWUP)}
|
| 153 |
+
features={categorized.user_followup_patterns!}
|
| 154 |
+
/>
|
| 155 |
+
)}
|
| 156 |
+
{hasOther && (
|
| 157 |
+
<FeatureCategory
|
| 158 |
+
label={t(I18nKey.CRITIC$OTHER)}
|
| 159 |
+
features={categorized.other!}
|
| 160 |
+
/>
|
| 161 |
+
)}
|
| 162 |
+
</div>
|
| 163 |
+
);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
interface CriticResultDisplayProps {
|
| 167 |
+
criticResult: CriticResult;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/**
|
| 171 |
+
* Displays a critic evaluation result with star rating, score percentage,
|
| 172 |
+
* and expandable categorized feature breakdown.
|
| 173 |
+
*/
|
| 174 |
+
export function CriticResultDisplay({
|
| 175 |
+
criticResult,
|
| 176 |
+
}: CriticResultDisplayProps) {
|
| 177 |
+
const { t } = useTranslation();
|
| 178 |
+
const { data: settings } = useSettings();
|
| 179 |
+
const [expanded, setExpanded] = React.useState(false);
|
| 180 |
+
|
| 181 |
+
const normalizedScore = normalizeScore(criticResult.score);
|
| 182 |
+
const { filled, empty } = getStarRating(normalizedScore);
|
| 183 |
+
const colorClass = getScoreColorClass(normalizedScore);
|
| 184 |
+
const percentage = (normalizedScore * 100).toFixed(1);
|
| 185 |
+
const iterativeRefinementEnabled = getIterativeRefinementEnabled(
|
| 186 |
+
settings?.agent_settings as Record<string, unknown> | null | undefined,
|
| 187 |
+
);
|
| 188 |
+
const showIterativeRefinementHint = iterativeRefinementEnabled === false;
|
| 189 |
+
|
| 190 |
+
const categorized = criticResult.metadata?.categorized_features;
|
| 191 |
+
const hasDetails =
|
| 192 |
+
categorized != null &&
|
| 193 |
+
((categorized.agent_behavioral_issues ?? []).length > 0 ||
|
| 194 |
+
(categorized.user_followup_patterns ?? []).length > 0 ||
|
| 195 |
+
(categorized.infrastructure_issues ?? []).length > 0 ||
|
| 196 |
+
(categorized.other ?? []).length > 0);
|
| 197 |
+
|
| 198 |
+
return (
|
| 199 |
+
<div className="border-l-2 border-neutral-600 pl-2 my-2 py-1.5 text-sm">
|
| 200 |
+
<div className="flex items-center gap-1">
|
| 201 |
+
<span className="font-semibold text-neutral-300 text-xs">
|
| 202 |
+
{t(I18nKey.CRITIC$SUCCESS_LIKELIHOOD_LABEL)}
|
| 203 |
+
</span>
|
| 204 |
+
<span
|
| 205 |
+
className={`${colorClass} text-xs tracking-wide`}
|
| 206 |
+
aria-label={`Score: ${percentage}%`}
|
| 207 |
+
>
|
| 208 |
+
{"★".repeat(filled)}
|
| 209 |
+
{"☆".repeat(empty)}
|
| 210 |
+
</span>
|
| 211 |
+
<span className="text-neutral-500 text-xs">({percentage}%)</span>
|
| 212 |
+
|
| 213 |
+
{hasDetails && (
|
| 214 |
+
<button
|
| 215 |
+
type="button"
|
| 216 |
+
onClick={() => setExpanded((prev) => !prev)}
|
| 217 |
+
className="cursor-pointer ml-1"
|
| 218 |
+
aria-label={
|
| 219 |
+
expanded
|
| 220 |
+
? t(I18nKey.BUTTON$COLLAPSE_DETAILS)
|
| 221 |
+
: t(I18nKey.BUTTON$EXPAND_DETAILS)
|
| 222 |
+
}
|
| 223 |
+
>
|
| 224 |
+
{expanded ? (
|
| 225 |
+
<ArrowUp className="h-3 w-3 inline fill-neutral-400" />
|
| 226 |
+
) : (
|
| 227 |
+
<ArrowDown className="h-3 w-3 inline fill-neutral-400" />
|
| 228 |
+
)}
|
| 229 |
+
</button>
|
| 230 |
+
)}
|
| 231 |
+
</div>
|
| 232 |
+
|
| 233 |
+
{expanded && hasDetails && (
|
| 234 |
+
<FeaturesBreakdown categorized={categorized!} />
|
| 235 |
+
)}
|
| 236 |
+
|
| 237 |
+
{showIterativeRefinementHint && (
|
| 238 |
+
<p
|
| 239 |
+
className="mt-1.5 text-xs leading-5 text-neutral-500"
|
| 240 |
+
data-testid="critic-iterative-refinement-hint"
|
| 241 |
+
>
|
| 242 |
+
{t(I18nKey.CRITIC$ITERATIVE_REFINEMENT_HINT)}
|
| 243 |
+
</p>
|
| 244 |
+
)}
|
| 245 |
+
</div>
|
| 246 |
+
);
|
| 247 |
+
}
|
src/components/conversation-events/chat/event-message-components/error-event-message.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { AgentErrorEvent } from "#/types/agent-server/core";
|
| 3 |
+
import { isAgentErrorEvent } from "#/types/agent-server/type-guards";
|
| 4 |
+
import { ErrorMessage } from "../../../features/chat/error-message";
|
| 5 |
+
|
| 6 |
+
interface ErrorEventMessageProps {
|
| 7 |
+
event: AgentErrorEvent;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function ErrorEventMessage({ event }: ErrorEventMessageProps) {
|
| 11 |
+
if (!isAgentErrorEvent(event)) {
|
| 12 |
+
return null;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
return <ErrorMessage errorId={event.id} defaultMessage={event.error} />;
|
| 16 |
+
}
|
src/components/conversation-events/chat/event-message-components/event-group.tsx
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import { LoaderCircle } from "lucide-react";
|
| 4 |
+
import ArrowDown from "#/icons/angle-down-solid.svg?react";
|
| 5 |
+
import ArrowUp from "#/icons/angle-up-solid.svg?react";
|
| 6 |
+
import { OpenHandsEvent, ActionEvent } from "#/types/agent-server/core";
|
| 7 |
+
import {
|
| 8 |
+
isActionEvent,
|
| 9 |
+
isObservationEvent,
|
| 10 |
+
} from "#/types/agent-server/type-guards";
|
| 11 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 12 |
+
import { getEventContent } from "../event-content-helpers/get-event-content";
|
| 13 |
+
import { IsInEventGroupContext } from "../../../features/chat/is-in-event-group-context";
|
| 14 |
+
import { PathInteractiveContext } from "../../../features/chat/path-component";
|
| 15 |
+
|
| 16 |
+
interface EventGroupProps {
|
| 17 |
+
/** The events represented by this group. Used to compute the summary. */
|
| 18 |
+
events: OpenHandsEvent[];
|
| 19 |
+
/**
|
| 20 |
+
* Full event history. Used to resolve the action that produced the latest
|
| 21 |
+
* observation in the group so the summary title matches what the individual
|
| 22 |
+
* card would show (e.g. "Editing path/to/file"). Falls back to `events` when
|
| 23 |
+
* omitted.
|
| 24 |
+
*/
|
| 25 |
+
allEvents?: OpenHandsEvent[];
|
| 26 |
+
/**
|
| 27 |
+
* `true` once an event outside this group has been emitted after it, so the
|
| 28 |
+
* group is no longer the "live" tail of the chat. While `false` (the
|
| 29 |
+
* default), the group keeps showing the most recent action's title as its
|
| 30 |
+
* prominent summary, with the count of completed actions shown subtly on
|
| 31 |
+
* the right.
|
| 32 |
+
*/
|
| 33 |
+
isFinalized?: boolean;
|
| 34 |
+
/** The fully-rendered event messages to show when the group is expanded. */
|
| 35 |
+
children: React.ReactNode;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Collapsible container that wraps a run of consecutive agent action/observation
|
| 40 |
+
* events into a single summary card.
|
| 41 |
+
*
|
| 42 |
+
* Collapsed, while the group is still the live tail of the chat
|
| 43 |
+
* (`isFinalized=false`):
|
| 44 |
+
* - Left (prominent): the title of the most recent action/observation in
|
| 45 |
+
* the group — i.e. either the action currently in flight, or the latest
|
| 46 |
+
* completed step.
|
| 47 |
+
* - Right (subdued): "{completed}/{total} actions completed" while at
|
| 48 |
+
* least one action is still pending (with a spinner), otherwise
|
| 49 |
+
* "{count} actions completed" followed by a success check.
|
| 50 |
+
*
|
| 51 |
+
* Collapsed, after the group has been "moved past" (`isFinalized=true`):
|
| 52 |
+
* - "{count} actions completed" is promoted to the prominent foreground
|
| 53 |
+
* style and the count is the only thing shown next to the chevron.
|
| 54 |
+
*
|
| 55 |
+
* Expanded:
|
| 56 |
+
* - Renders the children verbatim, so each individual action/observation can
|
| 57 |
+
* still be expanded the way it was before grouping.
|
| 58 |
+
*/
|
| 59 |
+
export function EventGroup({
|
| 60 |
+
events,
|
| 61 |
+
allEvents,
|
| 62 |
+
isFinalized = false,
|
| 63 |
+
children,
|
| 64 |
+
}: EventGroupProps) {
|
| 65 |
+
const { t } = useTranslation("openhands");
|
| 66 |
+
const [expanded, setExpanded] = React.useState(false);
|
| 67 |
+
const contentId = React.useId();
|
| 68 |
+
const buttonId = `${contentId}-toggle`;
|
| 69 |
+
|
| 70 |
+
if (events.length === 0) {
|
| 71 |
+
return null;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Each ObservationEvent in the group is a completed action. An ActionEvent
|
| 75 |
+
// that's still here (i.e. not yet replaced by its observation in the UI
|
| 76 |
+
// events array) is an action currently in flight.
|
| 77 |
+
const pendingAction = events.find((e): e is ActionEvent => isActionEvent(e));
|
| 78 |
+
const completedCount = events.filter(isObservationEvent).length;
|
| 79 |
+
const totalCount = events.length;
|
| 80 |
+
const isRunning = !!pendingAction;
|
| 81 |
+
|
| 82 |
+
// Title of the most recent groupable event. While running this is the
|
| 83 |
+
// pending action; otherwise it's the latest observation, with its
|
| 84 |
+
// originating action looked up so the title can be the action-style summary
|
| 85 |
+
// ("Editing path/to/file") instead of the observation default.
|
| 86 |
+
const latestEvent = events[events.length - 1];
|
| 87 |
+
let latestTitle: React.ReactNode = null;
|
| 88 |
+
if (latestEvent) {
|
| 89 |
+
if (isActionEvent(latestEvent)) {
|
| 90 |
+
latestTitle = getEventContent(latestEvent).title;
|
| 91 |
+
} else if (isObservationEvent(latestEvent)) {
|
| 92 |
+
const lookupSource = allEvents ?? events;
|
| 93 |
+
const correspondingAction = lookupSource.find(
|
| 94 |
+
(e): e is ActionEvent =>
|
| 95 |
+
isActionEvent(e) && e.id === latestEvent.action_id,
|
| 96 |
+
);
|
| 97 |
+
latestTitle = getEventContent(latestEvent, correspondingAction).title;
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
const countSummary = isRunning
|
| 102 |
+
? t(I18nKey.EVENT_GROUP$ACTIONS_PROGRESS, {
|
| 103 |
+
completed: completedCount,
|
| 104 |
+
total: totalCount,
|
| 105 |
+
})
|
| 106 |
+
: t(I18nKey.EVENT_GROUP$ACTIONS_COMPLETED, { count: totalCount });
|
| 107 |
+
|
| 108 |
+
const Chevron = expanded ? ArrowUp : ArrowDown;
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<div className="my-1 w-full py-1 text-sm" data-testid="event-group">
|
| 112 |
+
<button
|
| 113 |
+
id={buttonId}
|
| 114 |
+
type="button"
|
| 115 |
+
onClick={() => setExpanded((prev) => !prev)}
|
| 116 |
+
aria-controls={contentId}
|
| 117 |
+
aria-expanded={expanded}
|
| 118 |
+
aria-label={
|
| 119 |
+
expanded
|
| 120 |
+
? t(I18nKey.EVENT_GROUP$COLLAPSE)
|
| 121 |
+
: t(I18nKey.EVENT_GROUP$EXPAND)
|
| 122 |
+
}
|
| 123 |
+
data-testid="event-group-toggle"
|
| 124 |
+
className="w-full flex items-center justify-between gap-2 text-left cursor-pointer"
|
| 125 |
+
>
|
| 126 |
+
{isFinalized ? (
|
| 127 |
+
<span className="flex items-center gap-2 min-w-0 font-normal text-[var(--oh-muted)]">
|
| 128 |
+
<Chevron className="h-4 w-4 fill-[var(--oh-muted)] flex-shrink-0" />
|
| 129 |
+
<span className="truncate">{countSummary}</span>
|
| 130 |
+
</span>
|
| 131 |
+
) : (
|
| 132 |
+
<>
|
| 133 |
+
<span className="flex items-center gap-2 min-w-0 font-normal text-[var(--oh-muted)]">
|
| 134 |
+
<Chevron className="h-4 w-4 fill-[var(--oh-muted)] flex-shrink-0" />
|
| 135 |
+
<span className="truncate">
|
| 136 |
+
<PathInteractiveContext.Provider value={false}>
|
| 137 |
+
{latestTitle ?? countSummary}
|
| 138 |
+
</PathInteractiveContext.Provider>
|
| 139 |
+
</span>
|
| 140 |
+
</span>
|
| 141 |
+
<span className="flex items-center flex-shrink-0 font-normal text-[var(--oh-muted)]">
|
| 142 |
+
<span className="truncate">{countSummary}</span>
|
| 143 |
+
{isRunning ? (
|
| 144 |
+
<LoaderCircle
|
| 145 |
+
data-testid="spinner-icon"
|
| 146 |
+
className="h-4 w-4 ml-2 inline animate-spin text-[var(--oh-muted)]"
|
| 147 |
+
/>
|
| 148 |
+
) : null}
|
| 149 |
+
</span>
|
| 150 |
+
</>
|
| 151 |
+
)}
|
| 152 |
+
</button>
|
| 153 |
+
|
| 154 |
+
{expanded && (
|
| 155 |
+
<div
|
| 156 |
+
id={contentId}
|
| 157 |
+
role="region"
|
| 158 |
+
aria-labelledby={buttonId}
|
| 159 |
+
className="mt-1.5 flex flex-col"
|
| 160 |
+
data-testid="event-group-content"
|
| 161 |
+
>
|
| 162 |
+
<IsInEventGroupContext.Provider value>
|
| 163 |
+
{children}
|
| 164 |
+
</IsInEventGroupContext.Provider>
|
| 165 |
+
</div>
|
| 166 |
+
)}
|
| 167 |
+
</div>
|
| 168 |
+
);
|
| 169 |
+
}
|
src/components/conversation-events/chat/event-message-components/finish-event-message.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ActionEvent } from "#/types/agent-server/core";
|
| 2 |
+
import { FinishAction } from "#/types/agent-server/core/base/action";
|
| 3 |
+
import { ChatMessage } from "../../../features/chat/chat-message";
|
| 4 |
+
import { getEventContent } from "../event-content-helpers/get-event-content";
|
| 5 |
+
import { CriticResultDisplay } from "./critic-result-display";
|
| 6 |
+
|
| 7 |
+
interface FinishEventMessageProps {
|
| 8 |
+
event: ActionEvent<FinishAction>;
|
| 9 |
+
isFromPlanningAgent?: boolean;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function FinishEventMessage({
|
| 13 |
+
event,
|
| 14 |
+
isFromPlanningAgent = false,
|
| 15 |
+
}: FinishEventMessageProps) {
|
| 16 |
+
const eventContent = getEventContent(event);
|
| 17 |
+
const message =
|
| 18 |
+
typeof eventContent.details === "string"
|
| 19 |
+
? eventContent.details
|
| 20 |
+
: String(eventContent.details);
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<>
|
| 24 |
+
<ChatMessage
|
| 25 |
+
type="agent"
|
| 26 |
+
message={message}
|
| 27 |
+
isFromPlanningAgent={isFromPlanningAgent}
|
| 28 |
+
timestamp={event.timestamp}
|
| 29 |
+
/>
|
| 30 |
+
{event.critic_result != null && (
|
| 31 |
+
<CriticResultDisplay criticResult={event.critic_result} />
|
| 32 |
+
)}
|
| 33 |
+
</>
|
| 34 |
+
);
|
| 35 |
+
}
|
src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
OpenHandsEvent,
|
| 3 |
+
ActionEvent,
|
| 4 |
+
ObservationEvent,
|
| 5 |
+
} from "#/types/agent-server/core";
|
| 6 |
+
import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation";
|
| 7 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 8 |
+
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
|
| 9 |
+
import { getEventContent } from "../event-content-helpers/get-event-content";
|
| 10 |
+
import {
|
| 11 |
+
getACPToolCallResult,
|
| 12 |
+
getObservationResult,
|
| 13 |
+
ObservationResultStatus,
|
| 14 |
+
} from "../event-content-helpers/get-observation-result";
|
| 15 |
+
import {
|
| 16 |
+
isACPToolCallEvent,
|
| 17 |
+
isObservationEvent,
|
| 18 |
+
} from "#/types/agent-server/type-guards";
|
| 19 |
+
import {
|
| 20 |
+
SkillReadyEvent,
|
| 21 |
+
SkillReadyItem,
|
| 22 |
+
isSkillReadyEvent,
|
| 23 |
+
} from "../event-content-helpers/create-skill-ready-event";
|
| 24 |
+
import { getInvokeSkillItems } from "../event-content-helpers/get-invoke-skill-items";
|
| 25 |
+
import { SkillReadyContentList } from "./skill-ready-content-list";
|
| 26 |
+
import SkillsIcon from "#/icons/skills.svg?react";
|
| 27 |
+
import { isMarkdownFileEditorEvent } from "#/components/features/chat/tool-visualizers/primitives/markdown-file-preview";
|
| 28 |
+
|
| 29 |
+
interface GenericEventMessageWrapperProps {
|
| 30 |
+
event: OpenHandsEvent | SkillReadyEvent;
|
| 31 |
+
isLastMessage: boolean;
|
| 32 |
+
correspondingAction?: ActionEvent;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Resolves the expandable skill-knowledge list shared by Skill Ready events and
|
| 37 |
+
* invoke-skill tool observations. Returns the list items and their header
|
| 38 |
+
* label, or null when the event carries no skill knowledge (so the caller keeps
|
| 39 |
+
* the default details body). Additional skill-knowledge sources slot in as one
|
| 40 |
+
* more branch here.
|
| 41 |
+
*/
|
| 42 |
+
function getSkillKnowledge(
|
| 43 |
+
event: OpenHandsEvent | SkillReadyEvent,
|
| 44 |
+
): { items: SkillReadyItem[]; titleKey: I18nKey } | null {
|
| 45 |
+
if (isSkillReadyEvent(event)) {
|
| 46 |
+
return event._skillReadyItems.length > 0
|
| 47 |
+
? {
|
| 48 |
+
items: event._skillReadyItems,
|
| 49 |
+
titleKey: I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE,
|
| 50 |
+
}
|
| 51 |
+
: null;
|
| 52 |
+
}
|
| 53 |
+
if (
|
| 54 |
+
isObservationEvent(event) &&
|
| 55 |
+
event.observation.kind === "InvokeSkillObservation"
|
| 56 |
+
) {
|
| 57 |
+
const items = getInvokeSkillItems(
|
| 58 |
+
event as ObservationEvent<InvokeSkillObservation>,
|
| 59 |
+
);
|
| 60 |
+
return items.length > 0
|
| 61 |
+
? { items, titleKey: I18nKey.SKILLS$INVOKED_SKILL_KNOWLEDGE }
|
| 62 |
+
: null;
|
| 63 |
+
}
|
| 64 |
+
return null;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export function GenericEventMessageWrapper({
|
| 68 |
+
event,
|
| 69 |
+
correspondingAction,
|
| 70 |
+
}: GenericEventMessageWrapperProps) {
|
| 71 |
+
const { title, details } = getEventContent(event, correspondingAction);
|
| 72 |
+
|
| 73 |
+
// TaskTrackerObservation has its own rendering
|
| 74 |
+
if (
|
| 75 |
+
!isSkillReadyEvent(event) &&
|
| 76 |
+
isObservationEvent(event) &&
|
| 77 |
+
event.observation.kind === "TaskTrackerObservation"
|
| 78 |
+
) {
|
| 79 |
+
return <div>{details}</div>;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Determine success status
|
| 83 |
+
let success: ObservationResultStatus | undefined;
|
| 84 |
+
if (isSkillReadyEvent(event)) {
|
| 85 |
+
success = "success";
|
| 86 |
+
} else if (isObservationEvent(event)) {
|
| 87 |
+
success = getObservationResult(event);
|
| 88 |
+
} else if (isACPToolCallEvent(event)) {
|
| 89 |
+
success = getACPToolCallResult(event);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Skill Ready events and invoke-skill tool observations both render the
|
| 93 |
+
// expandable skill-knowledge list (with the skills icon); they differ only in
|
| 94 |
+
// the header label.
|
| 95 |
+
const skillKnowledge = getSkillKnowledge(event);
|
| 96 |
+
const bodyDetails = skillKnowledge ? (
|
| 97 |
+
<SkillReadyContentList
|
| 98 |
+
items={skillKnowledge.items}
|
| 99 |
+
titleKey={skillKnowledge.titleKey}
|
| 100 |
+
/>
|
| 101 |
+
) : (
|
| 102 |
+
details
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
// Markdown file-editor cards carry a clipped preview; expand them by
|
| 106 |
+
// default so the artifact is visible without an extra chevron click.
|
| 107 |
+
const initiallyExpanded =
|
| 108 |
+
!isSkillReadyEvent(event) &&
|
| 109 |
+
isMarkdownFileEditorEvent(event, correspondingAction);
|
| 110 |
+
|
| 111 |
+
return (
|
| 112 |
+
<div>
|
| 113 |
+
<GenericEventMessage
|
| 114 |
+
title={title}
|
| 115 |
+
details={bodyDetails}
|
| 116 |
+
success={success}
|
| 117 |
+
initiallyExpanded={initiallyExpanded}
|
| 118 |
+
timestamp={event.timestamp}
|
| 119 |
+
titleIcon={
|
| 120 |
+
skillKnowledge ? (
|
| 121 |
+
<SkillsIcon className="h-4 w-4 stroke-[var(--oh-muted)] flex-shrink-0 mr-2" />
|
| 122 |
+
) : undefined
|
| 123 |
+
}
|
| 124 |
+
/>
|
| 125 |
+
</div>
|
| 126 |
+
);
|
| 127 |
+
}
|
src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message";
|
src/components/conversation-events/chat/event-message-components/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { UserAssistantEventMessage } from "./user-assistant-event-message";
|
| 2 |
+
export { ErrorEventMessage } from "./error-event-message";
|
| 3 |
+
export { FinishEventMessage } from "./finish-event-message";
|
| 4 |
+
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
| 5 |
+
export { ThoughtEventMessage } from "./thought-event-message";
|
| 6 |
+
export { HookExecutionEventMessage } from "./hook-execution-event-message";
|
| 7 |
+
export { EventGroup } from "./event-group";
|
| 8 |
+
export { CriticResultDisplay } from "./critic-result-display";
|
src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useTranslation } from "react-i18next";
|
| 2 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 3 |
+
import { METADATA_PREFIXES } from "#/utils/constants";
|
| 4 |
+
import { Typography } from "#/ui/typography";
|
| 5 |
+
import { MarkdownRenderer } from "../../../features/markdown/markdown-renderer";
|
| 6 |
+
|
| 7 |
+
interface ParsedSkillContent {
|
| 8 |
+
matchInfo: string | null;
|
| 9 |
+
filePath: string | null;
|
| 10 |
+
body: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Parses skill content into metadata (keyword match info, file path)
|
| 15 |
+
* and the actual skill body.
|
| 16 |
+
*/
|
| 17 |
+
export function parseSkillContent(content: string): ParsedSkillContent {
|
| 18 |
+
const lines = content.split("\n");
|
| 19 |
+
let matchInfo: string | null = null;
|
| 20 |
+
let filePath: string | null = null;
|
| 21 |
+
let bodyStartIndex = 0;
|
| 22 |
+
|
| 23 |
+
for (let i = 0; i < lines.length; i += 1) {
|
| 24 |
+
const line = lines[i];
|
| 25 |
+
|
| 26 |
+
if (line.startsWith(METADATA_PREFIXES[0])) {
|
| 27 |
+
matchInfo = line.trim();
|
| 28 |
+
bodyStartIndex = i + 1;
|
| 29 |
+
} else if (line.startsWith(METADATA_PREFIXES[2])) {
|
| 30 |
+
filePath = line.replace(METADATA_PREFIXES[2], "").trim();
|
| 31 |
+
bodyStartIndex = i + 1;
|
| 32 |
+
} else if (
|
| 33 |
+
line.startsWith(METADATA_PREFIXES[1]) ||
|
| 34 |
+
line.startsWith(METADATA_PREFIXES[3])
|
| 35 |
+
) {
|
| 36 |
+
bodyStartIndex = i + 1;
|
| 37 |
+
} else if (line.trim() === "" && i <= bodyStartIndex) {
|
| 38 |
+
bodyStartIndex = i + 1;
|
| 39 |
+
} else {
|
| 40 |
+
break;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return {
|
| 45 |
+
matchInfo,
|
| 46 |
+
filePath,
|
| 47 |
+
body: lines.slice(bodyStartIndex).join("\n").trim(),
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Wraps `<important>...</important>` tags in the content with bold markers
|
| 53 |
+
* so the markdown renderer displays them as bold text.
|
| 54 |
+
*/
|
| 55 |
+
export function styleImportantTags(text: string): string {
|
| 56 |
+
return text.replace(
|
| 57 |
+
/<important>([\s\S]*?)<\/important>/gi,
|
| 58 |
+
(_match, inner) => `**${inner.trim()}**`,
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
interface SkillItemExpandedProps {
|
| 63 |
+
content: string;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export function SkillItemExpanded({ content }: SkillItemExpandedProps) {
|
| 67 |
+
const { t } = useTranslation("openhands");
|
| 68 |
+
const { matchInfo, filePath, body } = parseSkillContent(content);
|
| 69 |
+
const hasMetadata = matchInfo || filePath;
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<div className="pl-6 pr-2 pt-2 pb-2">
|
| 73 |
+
{hasMetadata && (
|
| 74 |
+
<div className="mb-3 text-sm text-[var(--oh-muted)] space-y-1">
|
| 75 |
+
{matchInfo && <p>{matchInfo}</p>}
|
| 76 |
+
{filePath && (
|
| 77 |
+
<p>
|
| 78 |
+
<Typography.Text className="text-[var(--oh-text-subtle)]">
|
| 79 |
+
{t(I18nKey.COMMON$PATH)}{" "}
|
| 80 |
+
</Typography.Text>
|
| 81 |
+
<code className="bg-[var(--oh-surface)] px-1.5 py-0.5 rounded text-[var(--oh-text-tertiary)]">
|
| 82 |
+
{filePath}
|
| 83 |
+
</code>
|
| 84 |
+
</p>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
)}
|
| 88 |
+
|
| 89 |
+
{hasMetadata && body && (
|
| 90 |
+
<hr className="border-[var(--oh-border-subtle)] mb-3" />
|
| 91 |
+
)}
|
| 92 |
+
|
| 93 |
+
{body && <MarkdownRenderer>{styleImportantTags(body)}</MarkdownRenderer>}
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
}
|
src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
| 4 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 5 |
+
import { Typography } from "#/ui/typography";
|
| 6 |
+
import { SkillReadyItem } from "../event-content-helpers/create-skill-ready-event";
|
| 7 |
+
import { SkillItemExpanded } from "./skill-item-expanded";
|
| 8 |
+
|
| 9 |
+
interface SkillReadyContentListProps {
|
| 10 |
+
items: SkillReadyItem[];
|
| 11 |
+
/**
|
| 12 |
+
* Translation key for the list header. Defaults to the "Triggered Skill
|
| 13 |
+
* Knowledge:" label used by Skill Ready events; invoke-skill observations
|
| 14 |
+
* pass "Invoked Skill Knowledge:" instead.
|
| 15 |
+
*/
|
| 16 |
+
titleKey?: I18nKey;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function SkillReadyContentList({
|
| 20 |
+
items,
|
| 21 |
+
titleKey = I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE,
|
| 22 |
+
}: SkillReadyContentListProps) {
|
| 23 |
+
const { t } = useTranslation("openhands");
|
| 24 |
+
const [expandedSkills, setExpandedSkills] = React.useState<
|
| 25 |
+
Record<string, boolean>
|
| 26 |
+
>({});
|
| 27 |
+
|
| 28 |
+
const toggleSkill = (name: string) => {
|
| 29 |
+
setExpandedSkills((prev) => ({ ...prev, [name]: !prev[name] }));
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<div className="flex flex-col gap-1 mt-1">
|
| 34 |
+
<Typography.Text className="font-bold text-[var(--oh-foreground)] text-sm px-2 py-1">
|
| 35 |
+
{t(titleKey)}
|
| 36 |
+
</Typography.Text>
|
| 37 |
+
{items.map((item) => {
|
| 38 |
+
const isExpanded = expandedSkills[item.name] || false;
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div
|
| 42 |
+
key={item.name}
|
| 43 |
+
className="border border-[var(--oh-border-subtle)] rounded-md overflow-hidden"
|
| 44 |
+
>
|
| 45 |
+
<button
|
| 46 |
+
type="button"
|
| 47 |
+
onClick={() => toggleSkill(item.name)}
|
| 48 |
+
className="w-full py-1.5 px-2 text-left flex items-center gap-2 hover:bg-tertiary transition-colors cursor-pointer"
|
| 49 |
+
>
|
| 50 |
+
<Typography.Text className="text-[var(--oh-text-tertiary)]">
|
| 51 |
+
{isExpanded ? (
|
| 52 |
+
<ChevronDown size={14} />
|
| 53 |
+
) : (
|
| 54 |
+
<ChevronRight size={14} />
|
| 55 |
+
)}
|
| 56 |
+
</Typography.Text>
|
| 57 |
+
<Typography.Text className="font-normal text-[var(--oh-foreground)] text-sm">
|
| 58 |
+
{item.name}
|
| 59 |
+
</Typography.Text>
|
| 60 |
+
</button>
|
| 61 |
+
|
| 62 |
+
{isExpanded && item.content && (
|
| 63 |
+
<>
|
| 64 |
+
<hr className="border-[var(--oh-border-subtle)]" />
|
| 65 |
+
<SkillItemExpanded content={item.content} />
|
| 66 |
+
</>
|
| 67 |
+
)}
|
| 68 |
+
</div>
|
| 69 |
+
);
|
| 70 |
+
})}
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
src/components/conversation-events/chat/event-message-components/thought-event-message.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { ActionEvent } from "#/types/agent-server/core";
|
| 3 |
+
import { ChatMessage } from "../../../features/chat/chat-message";
|
| 4 |
+
|
| 5 |
+
interface ThoughtEventMessageProps {
|
| 6 |
+
event: ActionEvent;
|
| 7 |
+
actions?: Array<{
|
| 8 |
+
icon: React.ReactNode;
|
| 9 |
+
onClick: () => void;
|
| 10 |
+
tooltip?: string;
|
| 11 |
+
}>;
|
| 12 |
+
isFromPlanningAgent?: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function ThoughtEventMessage({
|
| 16 |
+
event,
|
| 17 |
+
actions,
|
| 18 |
+
isFromPlanningAgent = false,
|
| 19 |
+
}: ThoughtEventMessageProps) {
|
| 20 |
+
// Extract thought content from the action event
|
| 21 |
+
const thoughtContent = event.thought
|
| 22 |
+
.filter((t) => t.type === "text")
|
| 23 |
+
.map((t) => t.text)
|
| 24 |
+
.join("\n");
|
| 25 |
+
|
| 26 |
+
// If there's no thought content, don't render anything
|
| 27 |
+
if (!thoughtContent) {
|
| 28 |
+
return null;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<ChatMessage
|
| 33 |
+
type="agent"
|
| 34 |
+
message={thoughtContent}
|
| 35 |
+
actions={actions}
|
| 36 |
+
isFromPlanningAgent={isFromPlanningAgent}
|
| 37 |
+
timestamp={event.timestamp}
|
| 38 |
+
/>
|
| 39 |
+
);
|
| 40 |
+
}
|
src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import { useNavigation } from "#/context/navigation-context";
|
| 4 |
+
import { MessageEvent } from "#/types/agent-server/core";
|
| 5 |
+
import { ChatMessage } from "../../../features/chat/chat-message";
|
| 6 |
+
import { ImageCarousel } from "../../../features/images/image-carousel";
|
| 7 |
+
import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event";
|
| 8 |
+
import { CriticResultDisplay } from "./critic-result-display";
|
| 9 |
+
import { CollapsibleThinking } from "./collapsible-thinking";
|
| 10 |
+
import { splitInlineThink } from "../event-thought-helpers";
|
| 11 |
+
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
|
| 12 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 13 |
+
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
|
| 14 |
+
import { useActiveBackend } from "#/contexts/active-backend-context";
|
| 15 |
+
import { useForkConversation } from "#/hooks/mutation/use-fork-conversation";
|
| 16 |
+
import { useConversationStore } from "#/stores/conversation-store";
|
| 17 |
+
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
| 18 |
+
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
| 19 |
+
|
| 20 |
+
interface UserAssistantEventMessageProps {
|
| 21 |
+
event: MessageEvent;
|
| 22 |
+
isLastMessage: boolean;
|
| 23 |
+
isFromPlanningAgent: boolean;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function UserAssistantEventMessage({
|
| 27 |
+
event,
|
| 28 |
+
isFromPlanningAgent,
|
| 29 |
+
}: UserAssistantEventMessageProps) {
|
| 30 |
+
const { t } = useTranslation("openhands");
|
| 31 |
+
const { navigate } = useNavigation();
|
| 32 |
+
const { conversationId } = useOptionalConversationId();
|
| 33 |
+
const isCloud = useActiveBackend().backend.kind === "cloud";
|
| 34 |
+
const { mutate: forkConversation, isPending: isForking } =
|
| 35 |
+
useForkConversation();
|
| 36 |
+
const setMessageToSend = useConversationStore(
|
| 37 |
+
(state) => state.setMessageToSend,
|
| 38 |
+
);
|
| 39 |
+
// Blocks a same-tick double-click, before `isForking` flips.
|
| 40 |
+
const forkInFlightRef = React.useRef(false);
|
| 41 |
+
|
| 42 |
+
const parsed = parseMessageFromEvent(event);
|
| 43 |
+
// Route an inline <think> block (e.g. from a streamed reply) to the thinking
|
| 44 |
+
// section so reloaded conversations match the live rendering.
|
| 45 |
+
const { reasoning, message } =
|
| 46 |
+
event.source === "agent"
|
| 47 |
+
? splitInlineThink(parsed)
|
| 48 |
+
: { reasoning: "", message: parsed };
|
| 49 |
+
|
| 50 |
+
const imageUrls: string[] = [];
|
| 51 |
+
if (Array.isArray(event.llm_message.content)) {
|
| 52 |
+
event.llm_message.content.forEach((content) => {
|
| 53 |
+
if (content.type === "image") {
|
| 54 |
+
imageUrls.push(...content.image_urls);
|
| 55 |
+
}
|
| 56 |
+
});
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// "Branch from here": a user message is "edit message" (excludes the message,
|
| 60 |
+
// restores its text to the composer); an assistant message branches
|
| 61 |
+
// inclusively. Local agent-server only, inside a conversation.
|
| 62 |
+
const canBranch = !isCloud && !!conversationId;
|
| 63 |
+
const handleBranch = () => {
|
| 64 |
+
if (!conversationId || isForking || forkInFlightRef.current) return;
|
| 65 |
+
forkInFlightRef.current = true;
|
| 66 |
+
|
| 67 |
+
// Distinct title so the fork doesn't read identically to its source.
|
| 68 |
+
// getCurrentConversation() is a shared singleton — match the id in case it
|
| 69 |
+
// still holds the previously-viewed conversation.
|
| 70 |
+
const source = ConversationService.getCurrentConversation();
|
| 71 |
+
const sourceTitle =
|
| 72 |
+
source?.id === conversationId ? source.title : undefined;
|
| 73 |
+
const branchTitle = sourceTitle ? `${sourceTitle} (branch)` : undefined;
|
| 74 |
+
|
| 75 |
+
// Only edit when there's text: an image-only message parses to "", so
|
| 76 |
+
// branch it inclusively rather than dropping the image.
|
| 77 |
+
const isEdit = event.source === "user" && message.length > 0;
|
| 78 |
+
|
| 79 |
+
forkConversation(
|
| 80 |
+
{
|
| 81 |
+
sourceConversationId: conversationId,
|
| 82 |
+
eventId: event.id,
|
| 83 |
+
...(isEdit ? { editText: message } : {}),
|
| 84 |
+
...(branchTitle ? { title: branchTitle } : {}),
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
onSuccess: ({ info, excluded }) => {
|
| 88 |
+
navigate(`/conversations/${info.id}`);
|
| 89 |
+
// Prefill only when excluded (else the send duplicates it). Deferred
|
| 90 |
+
// so the new conversation's composer receives it (as useLaunchSkillInChat).
|
| 91 |
+
if (excluded) {
|
| 92 |
+
window.setTimeout(() => setMessageToSend(message), 0);
|
| 93 |
+
}
|
| 94 |
+
},
|
| 95 |
+
onError: (error) =>
|
| 96 |
+
displayErrorToast(error instanceof Error ? error.message : null),
|
| 97 |
+
onSettled: () => {
|
| 98 |
+
forkInFlightRef.current = false;
|
| 99 |
+
},
|
| 100 |
+
},
|
| 101 |
+
);
|
| 102 |
+
};
|
| 103 |
+
const actions = canBranch
|
| 104 |
+
? [
|
| 105 |
+
{
|
| 106 |
+
icon: <RepoForkedIcon width={15} height={15} aria-hidden />,
|
| 107 |
+
onClick: handleBranch,
|
| 108 |
+
tooltip: t(I18nKey.CHAT_INTERFACE$BRANCH_FROM_HERE),
|
| 109 |
+
},
|
| 110 |
+
]
|
| 111 |
+
: undefined;
|
| 112 |
+
|
| 113 |
+
return (
|
| 114 |
+
<>
|
| 115 |
+
{reasoning && <CollapsibleThinking content={reasoning} />}
|
| 116 |
+
<ChatMessage
|
| 117 |
+
type={event.source}
|
| 118 |
+
message={message}
|
| 119 |
+
isFromPlanningAgent={isFromPlanningAgent}
|
| 120 |
+
actions={actions}
|
| 121 |
+
timestamp={event.timestamp}
|
| 122 |
+
>
|
| 123 |
+
{imageUrls.length > 0 && (
|
| 124 |
+
<ImageCarousel size="small" images={imageUrls} />
|
| 125 |
+
)}
|
| 126 |
+
</ChatMessage>
|
| 127 |
+
{event.source === "agent" && event.critic_result != null && (
|
| 128 |
+
<CriticResultDisplay criticResult={event.critic_result} />
|
| 129 |
+
)}
|
| 130 |
+
</>
|
| 131 |
+
);
|
| 132 |
+
}
|
src/components/conversation-events/chat/event-message.tsx
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import {
|
| 3 |
+
OpenHandsEvent,
|
| 4 |
+
MessageEvent,
|
| 5 |
+
ActionEvent,
|
| 6 |
+
} from "#/types/agent-server/core";
|
| 7 |
+
import {
|
| 8 |
+
FinishAction,
|
| 9 |
+
ThinkAction,
|
| 10 |
+
} from "#/types/agent-server/core/base/action";
|
| 11 |
+
import {
|
| 12 |
+
isActionEvent,
|
| 13 |
+
isObservationEvent,
|
| 14 |
+
isAgentErrorEvent,
|
| 15 |
+
isUserMessageEvent,
|
| 16 |
+
isPlanningFileEditorObservationEvent,
|
| 17 |
+
isHookExecutionEvent,
|
| 18 |
+
isACPToolCallEvent,
|
| 19 |
+
isStreamingDeltaEvent,
|
| 20 |
+
isConversationStateUpdateEvent,
|
| 21 |
+
isGoalConversationStateUpdateEvent,
|
| 22 |
+
} from "#/types/agent-server/type-guards";
|
| 23 |
+
import { useConfig } from "#/hooks/query/use-config";
|
| 24 |
+
import { useConversationStore } from "#/stores/conversation-store";
|
| 25 |
+
import { useAgentState, usePlanningAgentState } from "#/hooks/use-agent-state";
|
| 26 |
+
import { AgentState } from "#/types/agent-state";
|
| 27 |
+
import { ChatMessage } from "#/components/features/chat/chat-message";
|
| 28 |
+
import { GoalStatusContent } from "#/components/features/chat/goal-status-content";
|
| 29 |
+
import { PlanPreview } from "../../features/chat/plan-preview";
|
| 30 |
+
import { ErrorEventMessage } from "./event-message-components/error-event-message";
|
| 31 |
+
import { UserAssistantEventMessage } from "./event-message-components/user-assistant-event-message";
|
| 32 |
+
import { FinishEventMessage } from "./event-message-components/finish-event-message";
|
| 33 |
+
import { GenericEventMessageWrapper } from "./event-message-components/generic-event-message-wrapper";
|
| 34 |
+
import { ThoughtEventMessage } from "./event-message-components/thought-event-message";
|
| 35 |
+
import { CollapsibleThinking } from "./event-message-components/collapsible-thinking";
|
| 36 |
+
import { HookExecutionEventMessage } from "./event-message-components/hook-execution-event-message";
|
| 37 |
+
import { createSkillReadyEvent } from "./event-content-helpers/create-skill-ready-event";
|
| 38 |
+
import { shouldShowPlanPreview } from "./hooks/use-plan-preview-events";
|
| 39 |
+
import { getReasoningContent, splitInlineThink } from "./event-thought-helpers";
|
| 40 |
+
|
| 41 |
+
interface EventMessageProps {
|
| 42 |
+
event: OpenHandsEvent & { isFromPlanningAgent?: boolean };
|
| 43 |
+
messages: OpenHandsEvent[];
|
| 44 |
+
isLastMessage: boolean;
|
| 45 |
+
isInLast10Actions: boolean;
|
| 46 |
+
/** Set of event IDs that should render PlanPreview (one per user message phase) */
|
| 47 |
+
planPreviewEventIds?: Set<string>;
|
| 48 |
+
/**
|
| 49 |
+
* When true, do not render the inline `ThoughtEventMessage` for action /
|
| 50 |
+
* observation events. The caller is expected to render the thought
|
| 51 |
+
* separately (e.g. via a hoisted "thought" rendered item) so the message
|
| 52 |
+
* pane and the in-group rendering don't double up on the same content.
|
| 53 |
+
* Has no effect on `ThinkAction`, whose thought IS its action body.
|
| 54 |
+
*/
|
| 55 |
+
suppressThought?: boolean;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const getActivatedSkills = (event: MessageEvent): string[] =>
|
| 59 |
+
event.activated_skills || [];
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Checks if extended content contains valid text content.
|
| 63 |
+
*/
|
| 64 |
+
const hasValidExtendedContent = (
|
| 65 |
+
extendedContent: MessageEvent["extended_content"],
|
| 66 |
+
): boolean => {
|
| 67 |
+
if (!extendedContent || extendedContent.length === 0) {
|
| 68 |
+
return false;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return extendedContent.some(
|
| 72 |
+
(content) => content.type === "text" && content.text.trim().length > 0,
|
| 73 |
+
);
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Determines if a Skill Ready event should be displayed for the given message event.
|
| 78 |
+
*/
|
| 79 |
+
const shouldShowSkillReadyEvent = (messageEvent: MessageEvent): boolean => {
|
| 80 |
+
const activatedSkills = getActivatedSkills(messageEvent);
|
| 81 |
+
const hasActivatedSkills = activatedSkills.length > 0;
|
| 82 |
+
const hasExtendedContent = hasValidExtendedContent(
|
| 83 |
+
messageEvent.extended_content,
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
return hasActivatedSkills && hasExtendedContent;
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
interface CommonProps {
|
| 90 |
+
isLastMessage: boolean;
|
| 91 |
+
isInLast10Actions: boolean;
|
| 92 |
+
config: unknown;
|
| 93 |
+
isFromPlanningAgent: boolean;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Renders a user message with its corresponding Skill Ready event.
|
| 98 |
+
*/
|
| 99 |
+
const renderUserMessageWithSkillReady = (
|
| 100 |
+
messageEvent: MessageEvent,
|
| 101 |
+
commonProps: CommonProps,
|
| 102 |
+
isLastMessage: boolean,
|
| 103 |
+
): React.ReactElement => {
|
| 104 |
+
try {
|
| 105 |
+
const skillReadyEvent = createSkillReadyEvent(messageEvent);
|
| 106 |
+
return (
|
| 107 |
+
<>
|
| 108 |
+
<UserAssistantEventMessage
|
| 109 |
+
event={messageEvent}
|
| 110 |
+
isLastMessage={false}
|
| 111 |
+
isFromPlanningAgent={commonProps.isFromPlanningAgent}
|
| 112 |
+
/>
|
| 113 |
+
<GenericEventMessageWrapper
|
| 114 |
+
event={skillReadyEvent}
|
| 115 |
+
isLastMessage={isLastMessage}
|
| 116 |
+
/>
|
| 117 |
+
</>
|
| 118 |
+
);
|
| 119 |
+
} catch (error) {
|
| 120 |
+
// If skill ready event creation fails, just render the user message
|
| 121 |
+
return (
|
| 122 |
+
<UserAssistantEventMessage
|
| 123 |
+
event={messageEvent}
|
| 124 |
+
isLastMessage={isLastMessage}
|
| 125 |
+
isFromPlanningAgent={commonProps.isFromPlanningAgent}
|
| 126 |
+
/>
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
/**
|
| 132 |
+
* Renders the plan preview. Its own component so `usePlanningAgentState()`
|
| 133 |
+
* only subscribes on this rare row, not every message in the conversation.
|
| 134 |
+
*/
|
| 135 |
+
function PlanningObservationPreview({
|
| 136 |
+
planContent,
|
| 137 |
+
isLastMessage,
|
| 138 |
+
isMainAgentRunning,
|
| 139 |
+
}: {
|
| 140 |
+
planContent: string | null;
|
| 141 |
+
isLastMessage: boolean;
|
| 142 |
+
isMainAgentRunning: boolean;
|
| 143 |
+
}) {
|
| 144 |
+
const {
|
| 145 |
+
localPlanningConversationId,
|
| 146 |
+
curPlanningAgentState,
|
| 147 |
+
isPlanningAgentRunning,
|
| 148 |
+
} = usePlanningAgentState();
|
| 149 |
+
|
| 150 |
+
// Guard on the id explicitly — useAgentState(undefined) falls back to the
|
| 151 |
+
// route conversation, which could be mistaken for the planner's activity.
|
| 152 |
+
const isStreaming =
|
| 153 |
+
isLastMessage &&
|
| 154 |
+
!!localPlanningConversationId &&
|
| 155 |
+
curPlanningAgentState === AgentState.RUNNING;
|
| 156 |
+
|
| 157 |
+
return (
|
| 158 |
+
<PlanPreview
|
| 159 |
+
planContent={planContent}
|
| 160 |
+
isStreaming={isStreaming}
|
| 161 |
+
isBuildDisabled={isMainAgentRunning || isPlanningAgentRunning}
|
| 162 |
+
/>
|
| 163 |
+
);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
export function EventMessage({
|
| 167 |
+
event,
|
| 168 |
+
messages,
|
| 169 |
+
isLastMessage,
|
| 170 |
+
isInLast10Actions,
|
| 171 |
+
planPreviewEventIds,
|
| 172 |
+
suppressThought = false,
|
| 173 |
+
}: EventMessageProps) {
|
| 174 |
+
const { data: config } = useConfig();
|
| 175 |
+
const { planContent } = useConversationStore();
|
| 176 |
+
const { curAgentState } = useAgentState();
|
| 177 |
+
|
| 178 |
+
// Planner-running state is folded in by PlanningObservationPreview below,
|
| 179 |
+
// not read here, to avoid a second useAgentState() subscription per row.
|
| 180 |
+
const isAgentRunning =
|
| 181 |
+
curAgentState === AgentState.RUNNING ||
|
| 182 |
+
curAgentState === AgentState.LOADING;
|
| 183 |
+
|
| 184 |
+
// Read isFromPlanningAgent directly from the event object
|
| 185 |
+
const isFromPlanningAgent = event.isFromPlanningAgent || false;
|
| 186 |
+
|
| 187 |
+
// Common props for components that need them
|
| 188 |
+
const commonProps = {
|
| 189 |
+
isLastMessage,
|
| 190 |
+
isInLast10Actions,
|
| 191 |
+
config,
|
| 192 |
+
isFromPlanningAgent,
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
// Finished `/goal` loop: render the terminal status inline so it settles into
|
| 196 |
+
// the conversation. should-render-event.ts only lets the terminal goal event
|
| 197 |
+
// through; the active loop is shown by the separate bottom GoalStatusBanner.
|
| 198 |
+
if (
|
| 199 |
+
isConversationStateUpdateEvent(event) &&
|
| 200 |
+
isGoalConversationStateUpdateEvent(event)
|
| 201 |
+
) {
|
| 202 |
+
return <GoalStatusContent status={event.value} />;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Agent error events
|
| 206 |
+
if (isAgentErrorEvent(event)) {
|
| 207 |
+
return <ErrorEventMessage event={event} {...commonProps} />;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// Hook execution events
|
| 211 |
+
if (isHookExecutionEvent(event)) {
|
| 212 |
+
return <HookExecutionEventMessage event={event} />;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// ACP sub-agent tool call events (Claude Code, Codex, Gemini CLI, …)
|
| 216 |
+
// render through the same generic wrapper used for observation events so
|
| 217 |
+
// the card shape, success indicator and markdown rendering all match.
|
| 218 |
+
if (isACPToolCallEvent(event)) {
|
| 219 |
+
return (
|
| 220 |
+
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
|
| 221 |
+
);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
if (isStreamingDeltaEvent(event)) {
|
| 225 |
+
// Route an inline <think> block to the thinking section, not the bubble.
|
| 226 |
+
const { reasoning: inlineThink, message } = splitInlineThink(
|
| 227 |
+
event.content ?? "",
|
| 228 |
+
{ streaming: true },
|
| 229 |
+
);
|
| 230 |
+
const reasoningContent = [event.reasoning_content ?? "", inlineThink]
|
| 231 |
+
.filter(Boolean)
|
| 232 |
+
.join("\n\n");
|
| 233 |
+
return (
|
| 234 |
+
<>
|
| 235 |
+
{reasoningContent && <CollapsibleThinking content={reasoningContent} />}
|
| 236 |
+
{message && (
|
| 237 |
+
<ChatMessage
|
| 238 |
+
type="agent"
|
| 239 |
+
message={message}
|
| 240 |
+
isFromPlanningAgent={isFromPlanningAgent}
|
| 241 |
+
timestamp={event.timestamp}
|
| 242 |
+
/>
|
| 243 |
+
)}
|
| 244 |
+
</>
|
| 245 |
+
);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// Finish actions
|
| 249 |
+
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
| 250 |
+
return (
|
| 251 |
+
<FinishEventMessage
|
| 252 |
+
event={event as ActionEvent<FinishAction>}
|
| 253 |
+
{...commonProps}
|
| 254 |
+
/>
|
| 255 |
+
);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// ThinkAction - render the thought inside a collapsible section so it
|
| 259 |
+
// doesn't dominate the chat, especially when the thinking language
|
| 260 |
+
// (usually English) differs from the conversation language.
|
| 261 |
+
if (isActionEvent(event) && event.action.kind === "ThinkAction") {
|
| 262 |
+
const thinkAction = event as ActionEvent<ThinkAction>;
|
| 263 |
+
return <CollapsibleThinking content={thinkAction.action.thought} />;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// Action events - render thought + action (will be replaced by thought + observation)
|
| 267 |
+
if (isActionEvent(event)) {
|
| 268 |
+
const reasoningContent = getReasoningContent(event);
|
| 269 |
+
return (
|
| 270 |
+
<>
|
| 271 |
+
{reasoningContent && <CollapsibleThinking content={reasoningContent} />}
|
| 272 |
+
{!suppressThought && (
|
| 273 |
+
<ThoughtEventMessage
|
| 274 |
+
event={event}
|
| 275 |
+
isFromPlanningAgent={isFromPlanningAgent}
|
| 276 |
+
/>
|
| 277 |
+
)}
|
| 278 |
+
<GenericEventMessageWrapper
|
| 279 |
+
event={event}
|
| 280 |
+
isLastMessage={isLastMessage}
|
| 281 |
+
/>
|
| 282 |
+
</>
|
| 283 |
+
);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// Observation events - find the corresponding action and render thought + observation
|
| 287 |
+
if (isObservationEvent(event)) {
|
| 288 |
+
// Handle PlanningFileEditorObservation specially
|
| 289 |
+
if (isPlanningFileEditorObservationEvent(event)) {
|
| 290 |
+
// Only show PlanPreview if this event is marked as the one to display
|
| 291 |
+
// (last PlanningFileEditorObservation in its phase)
|
| 292 |
+
if (
|
| 293 |
+
planPreviewEventIds &&
|
| 294 |
+
shouldShowPlanPreview(event.id, planPreviewEventIds)
|
| 295 |
+
) {
|
| 296 |
+
return (
|
| 297 |
+
<PlanningObservationPreview
|
| 298 |
+
planContent={planContent}
|
| 299 |
+
isLastMessage={isLastMessage}
|
| 300 |
+
isMainAgentRunning={isAgentRunning}
|
| 301 |
+
/>
|
| 302 |
+
);
|
| 303 |
+
}
|
| 304 |
+
// Not the designated preview event for this phase - render nothing
|
| 305 |
+
// This prevents duplicate previews within the same phase
|
| 306 |
+
return null;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
// Find the action that this observation is responding to
|
| 310 |
+
const correspondingAction = messages.find(
|
| 311 |
+
(msg) => isActionEvent(msg) && msg.id === event.action_id,
|
| 312 |
+
);
|
| 313 |
+
|
| 314 |
+
// Skip ThoughtEventMessage for ThinkAction (thought IS the action)
|
| 315 |
+
const shouldShowThought =
|
| 316 |
+
!suppressThought &&
|
| 317 |
+
correspondingAction &&
|
| 318 |
+
isActionEvent(correspondingAction) &&
|
| 319 |
+
correspondingAction.action.kind !== "ThinkAction";
|
| 320 |
+
|
| 321 |
+
const reasoningContent =
|
| 322 |
+
correspondingAction && isActionEvent(correspondingAction)
|
| 323 |
+
? getReasoningContent(correspondingAction)
|
| 324 |
+
: "";
|
| 325 |
+
|
| 326 |
+
return (
|
| 327 |
+
<>
|
| 328 |
+
{reasoningContent && <CollapsibleThinking content={reasoningContent} />}
|
| 329 |
+
{shouldShowThought && (
|
| 330 |
+
<ThoughtEventMessage
|
| 331 |
+
event={correspondingAction}
|
| 332 |
+
isFromPlanningAgent={isFromPlanningAgent}
|
| 333 |
+
/>
|
| 334 |
+
)}
|
| 335 |
+
<GenericEventMessageWrapper
|
| 336 |
+
event={event}
|
| 337 |
+
isLastMessage={isLastMessage}
|
| 338 |
+
correspondingAction={
|
| 339 |
+
correspondingAction && isActionEvent(correspondingAction)
|
| 340 |
+
? correspondingAction
|
| 341 |
+
: undefined
|
| 342 |
+
}
|
| 343 |
+
/>
|
| 344 |
+
</>
|
| 345 |
+
);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
// Message events (user and assistant messages)
|
| 349 |
+
if (!isActionEvent(event) && !isObservationEvent(event)) {
|
| 350 |
+
const messageEvent = event as MessageEvent;
|
| 351 |
+
|
| 352 |
+
// Check if this is a user message that should display a Skill Ready event
|
| 353 |
+
if (isUserMessageEvent(event) && shouldShowSkillReadyEvent(messageEvent)) {
|
| 354 |
+
return renderUserMessageWithSkillReady(
|
| 355 |
+
messageEvent,
|
| 356 |
+
commonProps,
|
| 357 |
+
isLastMessage,
|
| 358 |
+
);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// Render normal message event (user or assistant)
|
| 362 |
+
return (
|
| 363 |
+
<UserAssistantEventMessage
|
| 364 |
+
event={messageEvent}
|
| 365 |
+
{...commonProps}
|
| 366 |
+
isLastMessage={isLastMessage}
|
| 367 |
+
/>
|
| 368 |
+
);
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// Generic fallback for all other events
|
| 372 |
+
return (
|
| 373 |
+
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
|
| 374 |
+
);
|
| 375 |
+
}
|
src/components/conversation-events/chat/event-thought-helpers.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ActionEvent, OpenHandsEvent } from "#/types/agent-server/core";
|
| 2 |
+
import { ThinkingBlock } from "#/types/agent-server/core/base/event";
|
| 3 |
+
import {
|
| 4 |
+
isActionEvent,
|
| 5 |
+
isObservationEvent,
|
| 6 |
+
} from "#/types/agent-server/type-guards";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Returns the displayable thought text of an `ActionEvent`, or an empty
|
| 10 |
+
* string if the event has no usable thought content.
|
| 11 |
+
*
|
| 12 |
+
* Mirrors the logic used by `ThoughtEventMessage` so callers stay in sync
|
| 13 |
+
* with what gets rendered.
|
| 14 |
+
*/
|
| 15 |
+
export const getActionThoughtText = (action: ActionEvent): string =>
|
| 16 |
+
action.thought
|
| 17 |
+
.filter((t) => t.type === "text")
|
| 18 |
+
.map((t) => t.text)
|
| 19 |
+
.join("\n");
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Extracts extended thinking / reasoning content from an `ActionEvent`.
|
| 23 |
+
*
|
| 24 |
+
* Prefers `reasoning_content` (a plain string produced by many reasoning
|
| 25 |
+
* models). Falls back to the text from `thinking_blocks` (Anthropic
|
| 26 |
+
* extended thinking). Returns an empty string when neither is available.
|
| 27 |
+
*/
|
| 28 |
+
export const getReasoningContent = (action: ActionEvent): string => {
|
| 29 |
+
if (action.reasoning_content) {
|
| 30 |
+
return action.reasoning_content;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
if (action.thinking_blocks?.length) {
|
| 34 |
+
return action.thinking_blocks
|
| 35 |
+
.filter((b): b is ThinkingBlock => b.type === "thinking")
|
| 36 |
+
.map((b) => b.thinking)
|
| 37 |
+
.join("\n\n");
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return "";
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
export const hasNonEmptyThought = (action: ActionEvent): boolean =>
|
| 44 |
+
getActionThoughtText(action).trim().length > 0;
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* Splits a leading `<think>…</think>` reasoning block out of assistant content
|
| 48 |
+
* so it renders in the collapsible thinking section, not the message bubble.
|
| 49 |
+
* Some models stream reasoning inline instead of via `reasoning_content`.
|
| 50 |
+
*
|
| 51 |
+
* Conservative to avoid mangling normal messages: only a `<think>` at the very
|
| 52 |
+
* start is touched (later occurrences, e.g. quoted in docs, stay verbatim),
|
| 53 |
+
* only the first block is peeled, and an unclosed leading `<think>` is reasoning
|
| 54 |
+
* only while `streaming` — in a finalized message it's literal output.
|
| 55 |
+
*/
|
| 56 |
+
export const splitInlineThink = (
|
| 57 |
+
content: string,
|
| 58 |
+
options?: { streaming?: boolean },
|
| 59 |
+
): { reasoning: string; message: string } => {
|
| 60 |
+
const OPEN = "<think>";
|
| 61 |
+
const CLOSE = "</think>";
|
| 62 |
+
|
| 63 |
+
// Only a <think> at the very start is reasoning.
|
| 64 |
+
const leading = content.replace(/^\s+/, "");
|
| 65 |
+
if (!leading.startsWith(OPEN)) {
|
| 66 |
+
return { reasoning: "", message: content };
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const afterOpen = leading.slice(OPEN.length);
|
| 70 |
+
const close = afterOpen.indexOf(CLOSE);
|
| 71 |
+
|
| 72 |
+
if (close === -1) {
|
| 73 |
+
// Unclosed: reasoning-in-progress while streaming, else literal output.
|
| 74 |
+
return options?.streaming
|
| 75 |
+
? { reasoning: afterOpen.trim(), message: "" }
|
| 76 |
+
: { reasoning: "", message: content };
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
reasoning: afterOpen.slice(0, close).trim(),
|
| 81 |
+
message: afterOpen.slice(close + CLOSE.length).trim(),
|
| 82 |
+
};
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Find the `ActionEvent` whose thought should be rendered alongside the
|
| 87 |
+
* given UI event. For an `ActionEvent` the thought belongs to itself; for
|
| 88 |
+
* an `ObservationEvent` we look up the matching action in `allEvents`.
|
| 89 |
+
*
|
| 90 |
+
* `ThinkAction` is intentionally excluded because its thought IS the
|
| 91 |
+
* action body and is rendered through a separate codepath.
|
| 92 |
+
*/
|
| 93 |
+
export const getThoughtSourceAction = (
|
| 94 |
+
event: OpenHandsEvent,
|
| 95 |
+
allEvents: OpenHandsEvent[],
|
| 96 |
+
): ActionEvent | null => {
|
| 97 |
+
if (isActionEvent(event)) {
|
| 98 |
+
if (event.action.kind === "ThinkAction") return null;
|
| 99 |
+
return hasNonEmptyThought(event) ? event : null;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if (isObservationEvent(event)) {
|
| 103 |
+
const action = allEvents.find(
|
| 104 |
+
(e): e is ActionEvent => isActionEvent(e) && e.id === event.action_id,
|
| 105 |
+
);
|
| 106 |
+
if (!action) return null;
|
| 107 |
+
if (action.action.kind === "ThinkAction") return null;
|
| 108 |
+
return hasNonEmptyThought(action) ? action : null;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return null;
|
| 112 |
+
};
|
src/components/conversation-events/chat/group-events.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ActionEvent, OpenHandsEvent } from "#/types/agent-server/core";
|
| 2 |
+
import {
|
| 3 |
+
isActionEvent,
|
| 4 |
+
isObservationEvent,
|
| 5 |
+
isPlanningFileEditorObservationEvent,
|
| 6 |
+
} from "#/types/agent-server/type-guards";
|
| 7 |
+
import { isMarkdownFileEditorEvent } from "#/components/features/chat/tool-visualizers/primitives/markdown-file-preview";
|
| 8 |
+
import { getThoughtSourceAction } from "./event-thought-helpers";
|
| 9 |
+
|
| 10 |
+
/** Minimum run-length before consecutive actions get folded into a single
|
| 11 |
+
* collapsible group. Even pairs are folded so the chat scroll stays compact
|
| 12 |
+
* when the agent fires off back-to-back tool calls. */
|
| 13 |
+
export const EVENT_GROUP_MIN_SIZE = 2;
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Returns true if the given event is one of the action / observation cards
|
| 17 |
+
* that we want to fold into an `EventGroup` when several appear in a row.
|
| 18 |
+
*
|
| 19 |
+
* Events that have their own dedicated rendering (FinishAction, ThinkAction,
|
| 20 |
+
* HookExecution, AgentError, MessageEvent, PlanPreview, markdown file
|
| 21 |
+
* artifacts, TaskTracker) are treated as group breakers.
|
| 22 |
+
*/
|
| 23 |
+
export const isGroupableEvent = (
|
| 24 |
+
event: OpenHandsEvent,
|
| 25 |
+
correspondingAction?: ActionEvent,
|
| 26 |
+
): boolean => {
|
| 27 |
+
if (isActionEvent(event)) {
|
| 28 |
+
const { kind } = event.action;
|
| 29 |
+
if (kind === "FinishAction" || kind === "ThinkAction") {
|
| 30 |
+
return false;
|
| 31 |
+
}
|
| 32 |
+
// Keep markdown *create* artifact cards outside collapsed groups so their
|
| 33 |
+
// clipped preview is visible without expanding a parent summary.
|
| 34 |
+
if (isMarkdownFileEditorEvent(event, correspondingAction)) {
|
| 35 |
+
return false;
|
| 36 |
+
}
|
| 37 |
+
return true;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (isObservationEvent(event)) {
|
| 41 |
+
if (isPlanningFileEditorObservationEvent(event)) {
|
| 42 |
+
return false;
|
| 43 |
+
}
|
| 44 |
+
if (isMarkdownFileEditorEvent(event, correspondingAction)) {
|
| 45 |
+
return false;
|
| 46 |
+
}
|
| 47 |
+
if (event.observation.kind === "TaskTrackerObservation") {
|
| 48 |
+
return false;
|
| 49 |
+
}
|
| 50 |
+
return true;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
return false;
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
export type RenderedItem =
|
| 57 |
+
| { kind: "single"; event: OpenHandsEvent; index: number }
|
| 58 |
+
| { kind: "thought"; action: ActionEvent; index: number }
|
| 59 |
+
| { kind: "group"; events: OpenHandsEvent[]; startIndex: number };
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Walk a list of UI events and bucket consecutive groupable events into
|
| 63 |
+
* `group` items. Anything that breaks the run, or runs shorter than
|
| 64 |
+
* `EVENT_GROUP_MIN_SIZE`, is emitted as `single` items so they keep rendering
|
| 65 |
+
* the way they always have.
|
| 66 |
+
*
|
| 67 |
+
* Whenever a groupable event carries an agent thought (either an
|
| 68 |
+
* `ActionEvent.thought` or the corresponding action of an
|
| 69 |
+
* `ObservationEvent`), the thought is hoisted out as its own `thought`
|
| 70 |
+
* `RenderedItem`, the current run is flushed, and a new run is started with
|
| 71 |
+
* the event itself. This keeps reasoning text in the main message stream
|
| 72 |
+
* instead of buried inside a collapsed action group.
|
| 73 |
+
*
|
| 74 |
+
* `allEvents` should be the full event history so observations can find
|
| 75 |
+
* their matching action; if omitted, it defaults to `events`.
|
| 76 |
+
*/
|
| 77 |
+
export const groupEvents = (
|
| 78 |
+
events: OpenHandsEvent[],
|
| 79 |
+
minSize: number = EVENT_GROUP_MIN_SIZE,
|
| 80 |
+
allEvents: OpenHandsEvent[] = events,
|
| 81 |
+
): RenderedItem[] => {
|
| 82 |
+
if (minSize < 1) {
|
| 83 |
+
throw new Error("minSize must be at least 1");
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
const items: RenderedItem[] = [];
|
| 87 |
+
const emittedThoughtActionIds = new Set<string>();
|
| 88 |
+
let run: { events: OpenHandsEvent[]; startIndex: number } | null = null;
|
| 89 |
+
|
| 90 |
+
const flushRun = () => {
|
| 91 |
+
if (!run) return;
|
| 92 |
+
if (run.events.length >= minSize) {
|
| 93 |
+
items.push({
|
| 94 |
+
kind: "group",
|
| 95 |
+
events: run.events,
|
| 96 |
+
startIndex: run.startIndex,
|
| 97 |
+
});
|
| 98 |
+
} else {
|
| 99 |
+
run.events.forEach((event, offset) => {
|
| 100 |
+
items.push({ kind: "single", event, index: run!.startIndex + offset });
|
| 101 |
+
});
|
| 102 |
+
}
|
| 103 |
+
run = null;
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
events.forEach((event, index) => {
|
| 107 |
+
const correspondingAction = isObservationEvent(event)
|
| 108 |
+
? allEvents.find(
|
| 109 |
+
(candidate): candidate is ActionEvent =>
|
| 110 |
+
isActionEvent(candidate) && candidate.id === event.action_id,
|
| 111 |
+
)
|
| 112 |
+
: undefined;
|
| 113 |
+
if (isGroupableEvent(event, correspondingAction)) {
|
| 114 |
+
const thoughtAction = getThoughtSourceAction(event, allEvents);
|
| 115 |
+
if (thoughtAction && !emittedThoughtActionIds.has(thoughtAction.id)) {
|
| 116 |
+
flushRun();
|
| 117 |
+
emittedThoughtActionIds.add(thoughtAction.id);
|
| 118 |
+
items.push({ kind: "thought", action: thoughtAction, index });
|
| 119 |
+
}
|
| 120 |
+
if (!run) run = { events: [], startIndex: index };
|
| 121 |
+
run.events.push(event);
|
| 122 |
+
} else {
|
| 123 |
+
flushRun();
|
| 124 |
+
items.push({ kind: "single", event, index });
|
| 125 |
+
}
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
flushRun();
|
| 129 |
+
return items;
|
| 130 |
+
};
|
src/components/conversation-events/chat/hooks/use-plan-preview-events.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from "react";
|
| 2 |
+
import { OpenHandsEvent } from "#/types/agent-server/core";
|
| 3 |
+
import {
|
| 4 |
+
isUserMessageEvent,
|
| 5 |
+
isPlanningFileEditorObservationEvent,
|
| 6 |
+
} from "#/types/agent-server/type-guards";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Groups events into phases based on user messages.
|
| 10 |
+
* A phase starts with a user message and includes all subsequent events
|
| 11 |
+
* until the next user message.
|
| 12 |
+
*
|
| 13 |
+
* @param events - The full list of events
|
| 14 |
+
* @returns Array of phases, where each phase is an array of events
|
| 15 |
+
*/
|
| 16 |
+
function groupEventsByPhase(events: OpenHandsEvent[]): OpenHandsEvent[][] {
|
| 17 |
+
const phases: OpenHandsEvent[][] = [];
|
| 18 |
+
let currentPhase: OpenHandsEvent[] = [];
|
| 19 |
+
|
| 20 |
+
for (const event of events) {
|
| 21 |
+
if (isUserMessageEvent(event)) {
|
| 22 |
+
// Start a new phase with the user message
|
| 23 |
+
if (currentPhase.length > 0) {
|
| 24 |
+
phases.push(currentPhase);
|
| 25 |
+
}
|
| 26 |
+
currentPhase = [event];
|
| 27 |
+
} else {
|
| 28 |
+
// Add event to current phase
|
| 29 |
+
currentPhase.push(event);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Don't forget the last phase
|
| 34 |
+
if (currentPhase.length > 0) {
|
| 35 |
+
phases.push(currentPhase);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return phases;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const isPlanFilePath = (path: string | null): boolean =>
|
| 42 |
+
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Finds the last PlanningFileEditorObservation for Plan.md in a phase.
|
| 46 |
+
*
|
| 47 |
+
* @param phase - Array of events in a phase
|
| 48 |
+
* @returns The event ID of the last Plan.md observation, or null
|
| 49 |
+
*/
|
| 50 |
+
function findLastPlanningObservationInPhase(
|
| 51 |
+
phase: OpenHandsEvent[],
|
| 52 |
+
): string | null {
|
| 53 |
+
for (let i = phase.length - 1; i >= 0; i -= 1) {
|
| 54 |
+
const event = phase[i];
|
| 55 |
+
if (
|
| 56 |
+
isPlanningFileEditorObservationEvent(event) &&
|
| 57 |
+
isPlanFilePath(event.observation.path)
|
| 58 |
+
) {
|
| 59 |
+
return event.id;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
return null;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export interface PlanPreviewEventInfo {
|
| 66 |
+
eventId: string;
|
| 67 |
+
/** Index of this plan preview in the conversation (1st, 2nd, etc.) */
|
| 68 |
+
phaseIndex: number;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Hook to determine which PlanningFileEditorObservation events should render PlanPreview.
|
| 73 |
+
*
|
| 74 |
+
* This hook implements phase-based grouping where:
|
| 75 |
+
* - A phase starts with a user message and ends at the next user message
|
| 76 |
+
* - Only the LAST PlanningFileEditorObservation in each phase shows PlanPreview
|
| 77 |
+
* - This ensures only one preview per user request, even with multiple observations
|
| 78 |
+
*
|
| 79 |
+
* Scenario handling:
|
| 80 |
+
* - Scenario 1 (Create plan): Multiple observations in one phase → 1 preview
|
| 81 |
+
* - Scenario 2 (Create then update): Two user messages → two phases → 2 previews
|
| 82 |
+
* - Scenario 3 (Create + update while processing): Two user messages → 2 previews
|
| 83 |
+
*
|
| 84 |
+
* @param allEvents - Full list of v1 events (for phase detection)
|
| 85 |
+
* @returns Set of event IDs that should render PlanPreview
|
| 86 |
+
*/
|
| 87 |
+
export function usePlanPreviewEvents(allEvents: OpenHandsEvent[]): Set<string> {
|
| 88 |
+
return useMemo(() => {
|
| 89 |
+
const planPreviewEventIds = new Set<string>();
|
| 90 |
+
|
| 91 |
+
// Group events by phases (user message boundaries)
|
| 92 |
+
const phases = groupEventsByPhase(allEvents);
|
| 93 |
+
|
| 94 |
+
// For each phase, find the last PlanningFileEditorObservation
|
| 95 |
+
phases.forEach((phase) => {
|
| 96 |
+
const lastPlanningObservationId =
|
| 97 |
+
findLastPlanningObservationInPhase(phase);
|
| 98 |
+
if (lastPlanningObservationId) {
|
| 99 |
+
planPreviewEventIds.add(lastPlanningObservationId);
|
| 100 |
+
}
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
return planPreviewEventIds;
|
| 104 |
+
}, [allEvents]);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* Check if a specific event should render PlanPreview.
|
| 109 |
+
*
|
| 110 |
+
* @param eventId - The event ID to check
|
| 111 |
+
* @param planPreviewEventIds - Set of event IDs that should render PlanPreview
|
| 112 |
+
* @returns true if this event should render PlanPreview
|
| 113 |
+
*/
|
| 114 |
+
export function shouldShowPlanPreview(
|
| 115 |
+
eventId: string,
|
| 116 |
+
planPreviewEventIds: Set<string>,
|
| 117 |
+
): boolean {
|
| 118 |
+
return planPreviewEventIds.has(eventId);
|
| 119 |
+
}
|
src/components/conversation-events/chat/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { Messages } from "./messages";
|
| 2 |
+
export { EventMessage } from "./event-message";
|
| 3 |
+
export * from "./event-message-components";
|
| 4 |
+
export { getEventContent } from "./event-content-helpers/get-event-content";
|
| 5 |
+
export {
|
| 6 |
+
shouldRenderEvent,
|
| 7 |
+
hasUserEvent,
|
| 8 |
+
} from "./event-content-helpers/should-render-event";
|
src/components/conversation-events/chat/messages.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { OpenHandsEvent } from "#/types/agent-server/core";
|
| 3 |
+
import { EventMessage } from "./event-message";
|
| 4 |
+
import { usePlanPreviewEvents } from "./hooks/use-plan-preview-events";
|
| 5 |
+
import { groupEvents } from "./group-events";
|
| 6 |
+
import { EventGroup } from "./event-message-components/event-group";
|
| 7 |
+
import { ThoughtEventMessage } from "./event-message-components/thought-event-message";
|
| 8 |
+
import { useModelStore } from "#/stores/model-store";
|
| 9 |
+
import { ModelMessages } from "#/components/features/chat/model-messages";
|
| 10 |
+
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
|
| 11 |
+
import { ConversationConfirmationButtons } from "#/components/shared/buttons/conversation-confirmation-buttons";
|
| 12 |
+
|
| 13 |
+
interface MessagesProps {
|
| 14 |
+
messages: OpenHandsEvent[]; // UI events (actions replaced by observations)
|
| 15 |
+
allEvents: OpenHandsEvent[]; // Full event history (for action lookup)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const getLastEventId = (events: OpenHandsEvent[]) => events.at(-1)?.id;
|
| 19 |
+
const getLastEvent = (events: OpenHandsEvent[]) => events.at(-1);
|
| 20 |
+
|
| 21 |
+
export const Messages: React.FC<MessagesProps> = React.memo(
|
| 22 |
+
({ messages, allEvents }) => {
|
| 23 |
+
const { conversationId } = useOptionalConversationId();
|
| 24 |
+
// Get the set of event IDs that should render PlanPreview
|
| 25 |
+
// This ensures only one preview per user message "phase"
|
| 26 |
+
const planPreviewEventIds = usePlanPreviewEvents(allEvents);
|
| 27 |
+
|
| 28 |
+
// Set of event ids that have a /model entry anchored to them — used to
|
| 29 |
+
// avoid mounting <ModelMessages> for every event (the component would
|
| 30 |
+
// otherwise early-return null).
|
| 31 |
+
const modelEntries = useModelStore((s) =>
|
| 32 |
+
conversationId ? s.entriesByConversation[conversationId] : undefined,
|
| 33 |
+
);
|
| 34 |
+
const modelAnchorIds = React.useMemo(() => {
|
| 35 |
+
if (!modelEntries || modelEntries.length === 0) return null;
|
| 36 |
+
const ids = new Set<string>();
|
| 37 |
+
for (const entry of modelEntries) {
|
| 38 |
+
if (entry.anchorEventId !== null) ids.add(entry.anchorEventId);
|
| 39 |
+
}
|
| 40 |
+
return ids.size > 0 ? ids : null;
|
| 41 |
+
}, [modelEntries]);
|
| 42 |
+
|
| 43 |
+
const maybeRenderModelMessages = (eventId: string | number | undefined) => {
|
| 44 |
+
if (!modelAnchorIds || eventId === undefined) return null;
|
| 45 |
+
const key = String(eventId);
|
| 46 |
+
if (!modelAnchorIds.has(key)) return null;
|
| 47 |
+
return (
|
| 48 |
+
<ModelMessages conversationId={conversationId} anchorEventId={key} />
|
| 49 |
+
);
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
// Fold consecutive action/observation events into collapsible groups so a
|
| 53 |
+
// long sequence of tool calls doesn't dominate the chat scroll. Items that
|
| 54 |
+
// can't be grouped (or that fall in a short run) are still rendered one by
|
| 55 |
+
// one, identically to before. Agent thoughts attached to an action are
|
| 56 |
+
// hoisted out as their own rendered item so they always show up in the
|
| 57 |
+
// message pane and a thought between actions starts a fresh group.
|
| 58 |
+
const renderedItems = React.useMemo(
|
| 59 |
+
() => groupEvents(messages, undefined, allEvents),
|
| 60 |
+
[messages, allEvents],
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
const renderEventMessage = (
|
| 64 |
+
event: OpenHandsEvent,
|
| 65 |
+
index: number,
|
| 66 |
+
suppressThought: boolean,
|
| 67 |
+
) => (
|
| 68 |
+
<EventMessage
|
| 69 |
+
key={event.id}
|
| 70 |
+
event={event}
|
| 71 |
+
messages={allEvents}
|
| 72 |
+
isLastMessage={messages.length - 1 === index}
|
| 73 |
+
isInLast10Actions={messages.length - 1 - index < 10}
|
| 74 |
+
planPreviewEventIds={planPreviewEventIds}
|
| 75 |
+
suppressThought={suppressThought}
|
| 76 |
+
/>
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<>
|
| 81 |
+
{renderedItems.map((item, itemIndex) => {
|
| 82 |
+
if (item.kind === "single") {
|
| 83 |
+
return (
|
| 84 |
+
<React.Fragment key={`single-${item.event.id}`}>
|
| 85 |
+
{/* Thoughts for singles are also hoisted as their own
|
| 86 |
+
"thought" item, so suppress the inline render to avoid
|
| 87 |
+
duplication. */}
|
| 88 |
+
{renderEventMessage(item.event, item.index, true)}
|
| 89 |
+
{maybeRenderModelMessages(item.event.id)}
|
| 90 |
+
</React.Fragment>
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
if (item.kind === "thought") {
|
| 95 |
+
return (
|
| 96 |
+
<React.Fragment key={`thought-${item.action.id}`}>
|
| 97 |
+
<ThoughtEventMessage event={item.action} />
|
| 98 |
+
{maybeRenderModelMessages(item.action.id)}
|
| 99 |
+
</React.Fragment>
|
| 100 |
+
);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// A group is "finalized" once another rendered item appears after
|
| 104 |
+
// it, signalling the agent has moved on. While the group is still
|
| 105 |
+
// the live tail, it keeps showing the latest action title as its
|
| 106 |
+
// prominent summary.
|
| 107 |
+
const isFinalized = itemIndex < renderedItems.length - 1;
|
| 108 |
+
const groupKey = item.events[0]?.id ?? `group-${item.startIndex}`;
|
| 109 |
+
return (
|
| 110 |
+
<React.Fragment key={`group-${groupKey}`}>
|
| 111 |
+
<EventGroup
|
| 112 |
+
events={item.events}
|
| 113 |
+
allEvents={allEvents}
|
| 114 |
+
isFinalized={isFinalized}
|
| 115 |
+
>
|
| 116 |
+
{item.events.map((event, offset) =>
|
| 117 |
+
renderEventMessage(event, item.startIndex + offset, true),
|
| 118 |
+
)}
|
| 119 |
+
</EventGroup>
|
| 120 |
+
{item.events.map((event) => (
|
| 121 |
+
<React.Fragment key={`model-${event.id}`}>
|
| 122 |
+
{maybeRenderModelMessages(event.id)}
|
| 123 |
+
</React.Fragment>
|
| 124 |
+
))}
|
| 125 |
+
</React.Fragment>
|
| 126 |
+
);
|
| 127 |
+
})}
|
| 128 |
+
<ConversationConfirmationButtons />
|
| 129 |
+
</>
|
| 130 |
+
);
|
| 131 |
+
},
|
| 132 |
+
(prevProps, nextProps) =>
|
| 133 |
+
prevProps.messages.length === nextProps.messages.length &&
|
| 134 |
+
prevProps.allEvents.length === nextProps.allEvents.length &&
|
| 135 |
+
getLastEventId(prevProps.messages) === getLastEventId(nextProps.messages) &&
|
| 136 |
+
getLastEventId(prevProps.allEvents) ===
|
| 137 |
+
getLastEventId(nextProps.allEvents) &&
|
| 138 |
+
getLastEvent(prevProps.messages) === getLastEvent(nextProps.messages) &&
|
| 139 |
+
getLastEvent(prevProps.allEvents) === getLastEvent(nextProps.allEvents),
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
Messages.displayName = "Messages";
|
src/components/conversation-events/chat/task-tracking/task-item.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common";
|
| 4 |
+
import CircleIcon from "#/icons/u-circle.svg?react";
|
| 5 |
+
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
|
| 6 |
+
import CheckCircleHalfIcon from "#/icons/u-check-circle-half.svg?react";
|
| 7 |
+
import { cn } from "#/utils/utils";
|
| 8 |
+
import { Typography } from "#/ui/typography";
|
| 9 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 10 |
+
|
| 11 |
+
interface TaskItemProps {
|
| 12 |
+
task: TaskItemType;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function TaskItem({ task }: TaskItemProps) {
|
| 16 |
+
const { t } = useTranslation("openhands");
|
| 17 |
+
|
| 18 |
+
const icon = useMemo(() => {
|
| 19 |
+
switch (task.status) {
|
| 20 |
+
case "todo":
|
| 21 |
+
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
| 22 |
+
case "in_progress":
|
| 23 |
+
return <CheckCircleHalfIcon className="w-4 h-4 text-[#ffffff]" />;
|
| 24 |
+
case "done":
|
| 25 |
+
return <CheckCircleIcon className="w-4 h-4 text-[var(--oh-muted)]" />;
|
| 26 |
+
default:
|
| 27 |
+
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
| 28 |
+
}
|
| 29 |
+
}, [task.status]);
|
| 30 |
+
|
| 31 |
+
const isDoneStatus = task.status === "done";
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<div
|
| 35 |
+
className="flex gap-[14px] items-center px-4 py-2 w-full"
|
| 36 |
+
data-name="item"
|
| 37 |
+
>
|
| 38 |
+
<div className="shrink-0">{icon}</div>
|
| 39 |
+
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
|
| 40 |
+
<Typography.Text
|
| 41 |
+
className={cn(
|
| 42 |
+
"text-[12px] text-white",
|
| 43 |
+
isDoneStatus && "text-[var(--oh-muted)]",
|
| 44 |
+
)}
|
| 45 |
+
>
|
| 46 |
+
{task.title}
|
| 47 |
+
</Typography.Text>
|
| 48 |
+
{task.notes && (
|
| 49 |
+
<Typography.Text className="text-[10px] text-[var(--oh-muted)]">
|
| 50 |
+
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
|
| 51 |
+
</Typography.Text>
|
| 52 |
+
)}
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
);
|
| 56 |
+
}
|
src/components/conversation-events/chat/task-tracking/task-list-section.test.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 3 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 4 |
+
import type { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common";
|
| 5 |
+
import { TaskListSection } from "./task-list-section";
|
| 6 |
+
|
| 7 |
+
const useTranslationMock = vi.hoisted(() =>
|
| 8 |
+
vi.fn((namespace: string) => ({
|
| 9 |
+
t: (key: string) =>
|
| 10 |
+
namespace === "openhands" ? `translated:${key}` : `missing:${key}`,
|
| 11 |
+
})),
|
| 12 |
+
);
|
| 13 |
+
|
| 14 |
+
vi.mock("react-i18next", async (importOriginal) => ({
|
| 15 |
+
...(await importOriginal<typeof import("react-i18next")>()),
|
| 16 |
+
useTranslation: useTranslationMock,
|
| 17 |
+
}));
|
| 18 |
+
|
| 19 |
+
const createTask = (overrides: Partial<TaskItemType> = {}): TaskItemType => ({
|
| 20 |
+
title: "Inspect the behavior",
|
| 21 |
+
notes: "",
|
| 22 |
+
status: "todo",
|
| 23 |
+
...overrides,
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
describe("task tracking list", () => {
|
| 27 |
+
afterEach(() => {
|
| 28 |
+
vi.restoreAllMocks();
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
it("shows every planned task in its supplied order", () => {
|
| 32 |
+
const consoleErrorSpy = vi
|
| 33 |
+
.spyOn(console, "error")
|
| 34 |
+
.mockImplementation(() => undefined);
|
| 35 |
+
const taskList = [
|
| 36 |
+
createTask({ title: "Inspect the behavior" }),
|
| 37 |
+
createTask({ title: "Add focused coverage", status: "in_progress" }),
|
| 38 |
+
createTask({ title: "Verify the result", status: "done" }),
|
| 39 |
+
];
|
| 40 |
+
|
| 41 |
+
const { container } = render(<TaskListSection taskList={taskList} />);
|
| 42 |
+
|
| 43 |
+
expect(
|
| 44 |
+
screen.getByText(`translated:${I18nKey.COMMON$TASKS}`),
|
| 45 |
+
).toBeInTheDocument();
|
| 46 |
+
const renderedTasks = container.querySelectorAll('[data-name="item"]');
|
| 47 |
+
expect(renderedTasks).toHaveLength(3);
|
| 48 |
+
expect(Array.from(renderedTasks, (task) => task.textContent)).toEqual([
|
| 49 |
+
"Inspect the behavior",
|
| 50 |
+
"Add focused coverage",
|
| 51 |
+
"Verify the result",
|
| 52 |
+
]);
|
| 53 |
+
expect(
|
| 54 |
+
consoleErrorSpy.mock.calls.some(
|
| 55 |
+
([message]) =>
|
| 56 |
+
typeof message === "string" && message.includes("same key"),
|
| 57 |
+
),
|
| 58 |
+
).toBe(false);
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it("keeps the task section visible when the plan is empty", () => {
|
| 62 |
+
const { container } = render(<TaskListSection taskList={[]} />);
|
| 63 |
+
|
| 64 |
+
expect(
|
| 65 |
+
screen.getByText(`translated:${I18nKey.COMMON$TASKS}`),
|
| 66 |
+
).toBeInTheDocument();
|
| 67 |
+
expect(container.querySelectorAll('[data-name="item"]')).toHaveLength(0);
|
| 68 |
+
});
|
| 69 |
+
});
|
src/components/conversation-events/chat/task-tracking/task-list-section.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useTranslation } from "react-i18next";
|
| 2 |
+
import { TaskItem } from "./task-item";
|
| 3 |
+
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
| 4 |
+
import { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common";
|
| 5 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 6 |
+
import { Typography } from "#/ui/typography";
|
| 7 |
+
|
| 8 |
+
interface TaskListSectionProps {
|
| 9 |
+
taskList: TaskItemType[];
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function TaskListSection({ taskList }: TaskListSectionProps) {
|
| 13 |
+
const { t } = useTranslation("openhands");
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="flex flex-col overflow-clip bg-[var(--oh-surface)] border border-[var(--oh-border)] rounded-[12px] w-full">
|
| 17 |
+
{/* Header Tabs */}
|
| 18 |
+
<div className="flex gap-1 items-center border-b border-[var(--oh-border)] h-[41px] px-2 shrink-0">
|
| 19 |
+
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[var(--oh-muted)]" />
|
| 20 |
+
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
|
| 21 |
+
{t(I18nKey.COMMON$TASKS)}
|
| 22 |
+
</Typography.Text>
|
| 23 |
+
</div>
|
| 24 |
+
|
| 25 |
+
{/* Task Items */}
|
| 26 |
+
<div>
|
| 27 |
+
{taskList.map((task, index) => (
|
| 28 |
+
<TaskItem key={`task-${index}`} task={task} />
|
| 29 |
+
))}
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
}
|
src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.test.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 4 |
+
import type { ObservationEvent } from "#/types/agent-server/core";
|
| 5 |
+
import type { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common";
|
| 6 |
+
import type { TaskTrackerObservation } from "#/types/agent-server/core/base/observation";
|
| 7 |
+
import { TaskTrackingObservationContent } from "./task-tracking-observation-content";
|
| 8 |
+
|
| 9 |
+
const createTask = (overrides: Partial<TaskItemType> = {}): TaskItemType => ({
|
| 10 |
+
title: "Inspect the behavior",
|
| 11 |
+
notes: "",
|
| 12 |
+
status: "todo",
|
| 13 |
+
...overrides,
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
const createTaskTrackingEvent = (
|
| 17 |
+
observationOverrides: Partial<TaskTrackerObservation> = {},
|
| 18 |
+
): ObservationEvent<TaskTrackerObservation> => ({
|
| 19 |
+
id: "observation-1",
|
| 20 |
+
timestamp: "2026-07-13T12:00:00.000Z",
|
| 21 |
+
source: "environment",
|
| 22 |
+
tool_name: "task_tracker",
|
| 23 |
+
tool_call_id: "tool-call-1",
|
| 24 |
+
action_id: "action-1",
|
| 25 |
+
observation: {
|
| 26 |
+
kind: "TaskTrackerObservation",
|
| 27 |
+
content: "Current plan",
|
| 28 |
+
command: "plan",
|
| 29 |
+
task_list: [createTask()],
|
| 30 |
+
...observationOverrides,
|
| 31 |
+
},
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
describe("task tracking observations", () => {
|
| 35 |
+
it("shows a non-empty plan", () => {
|
| 36 |
+
const event = createTaskTrackingEvent({
|
| 37 |
+
task_list: [
|
| 38 |
+
createTask({ title: "Inspect the behavior" }),
|
| 39 |
+
createTask({ title: "Verify the result", status: "done" }),
|
| 40 |
+
],
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
render(<TaskTrackingObservationContent event={event} />);
|
| 44 |
+
|
| 45 |
+
expect(screen.getByText(I18nKey.COMMON$TASKS)).toBeInTheDocument();
|
| 46 |
+
expect(screen.getByText("Inspect the behavior")).toBeInTheDocument();
|
| 47 |
+
expect(screen.getByText("Verify the result")).toBeInTheDocument();
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
it("hides task data emitted by commands other than plan", () => {
|
| 51 |
+
const event = createTaskTrackingEvent({ command: "update" });
|
| 52 |
+
|
| 53 |
+
render(<TaskTrackingObservationContent event={event} />);
|
| 54 |
+
|
| 55 |
+
expect(screen.queryByText(I18nKey.COMMON$TASKS)).not.toBeInTheDocument();
|
| 56 |
+
expect(screen.queryByText("Inspect the behavior")).not.toBeInTheDocument();
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it("hides an empty plan", () => {
|
| 60 |
+
const event = createTaskTrackingEvent({ task_list: [] });
|
| 61 |
+
|
| 62 |
+
render(<TaskTrackingObservationContent event={event} />);
|
| 63 |
+
|
| 64 |
+
expect(screen.queryByText(I18nKey.COMMON$TASKS)).not.toBeInTheDocument();
|
| 65 |
+
});
|
| 66 |
+
});
|
src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { ObservationEvent } from "#/types/agent-server/core";
|
| 3 |
+
import { TaskTrackerObservation } from "#/types/agent-server/core/base/observation";
|
| 4 |
+
import { TaskListSection } from "./task-list-section";
|
| 5 |
+
|
| 6 |
+
interface TaskTrackingObservationContentProps {
|
| 7 |
+
event: ObservationEvent<TaskTrackerObservation>;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export function TaskTrackingObservationContent({
|
| 11 |
+
event,
|
| 12 |
+
}: TaskTrackingObservationContentProps): React.ReactNode {
|
| 13 |
+
const { observation } = event;
|
| 14 |
+
const { command, task_list: taskList } = observation;
|
| 15 |
+
const shouldShowTaskList = command === "plan" && taskList.length > 0;
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<div className="flex flex-col gap-4">
|
| 19 |
+
{/* Task List section - only show for 'plan' command */}
|
| 20 |
+
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
|
| 21 |
+
</div>
|
| 22 |
+
);
|
| 23 |
+
}
|
src/components/conversation/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export { ConversationView } from "../../routes/conversation";
|
| 2 |
+
export { ChatInterface as ChatPanel } from "../features/chat/chat-interface";
|
| 3 |
+
export { ConversationLoading } from "../features/conversation/conversation-loading";
|
| 4 |
+
export { ConversationMain } from "../features/conversation/conversation-main/conversation-main";
|
| 5 |
+
export { ChatInterfaceWrapper } from "../features/conversation/conversation-main/chat-interface-wrapper";
|
| 6 |
+
export { ConversationName } from "../features/conversation/conversation-name";
|
| 7 |
+
export { ConversationNameWithStatus } from "../features/conversation/conversation-name-with-status";
|
| 8 |
+
export { ConversationTabs } from "../features/conversation/conversation-tabs/conversation-tabs";
|
src/components/features/alerts/alert-banner.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useTranslation } from "react-i18next";
|
| 2 |
+
import { useMemo } from "react";
|
| 3 |
+
import { useLocalStorage } from "@uidotdev/usehooks";
|
| 4 |
+
import { FaTriangleExclamation } from "react-icons/fa6";
|
| 5 |
+
import CloseIcon from "#/icons/close.svg?react";
|
| 6 |
+
import { useNavigation } from "#/context/navigation-context";
|
| 7 |
+
import { cn } from "#/utils/utils";
|
| 8 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 9 |
+
import { Typography } from "#/ui/typography";
|
| 10 |
+
|
| 11 |
+
interface AlertBannerProps {
|
| 12 |
+
maintenanceStartTime?: string | null;
|
| 13 |
+
faultyModels?: string[];
|
| 14 |
+
errorMessage?: string | null;
|
| 15 |
+
updatedAt: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function AlertBanner({
|
| 19 |
+
maintenanceStartTime,
|
| 20 |
+
faultyModels,
|
| 21 |
+
errorMessage,
|
| 22 |
+
updatedAt,
|
| 23 |
+
}: AlertBannerProps) {
|
| 24 |
+
const { t } = useTranslation("openhands");
|
| 25 |
+
const [dismissedAt, setDismissedAt] = useLocalStorage<string | null>(
|
| 26 |
+
"alert_banner_dismissed_at",
|
| 27 |
+
null,
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
const { currentPath } = useNavigation();
|
| 31 |
+
|
| 32 |
+
// Format ISO timestamp to user's local timezone
|
| 33 |
+
const formatMaintenanceTime = (isoTimeString: string): string => {
|
| 34 |
+
const date = new Date(isoTimeString);
|
| 35 |
+
return date.toLocaleString(undefined, {
|
| 36 |
+
year: "numeric",
|
| 37 |
+
month: "short",
|
| 38 |
+
day: "numeric",
|
| 39 |
+
hour: "2-digit",
|
| 40 |
+
minute: "2-digit",
|
| 41 |
+
timeZoneName: "short",
|
| 42 |
+
});
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const localTime = maintenanceStartTime
|
| 46 |
+
? formatMaintenanceTime(maintenanceStartTime)
|
| 47 |
+
: null;
|
| 48 |
+
|
| 49 |
+
const hasMaintenanceAlert = !!maintenanceStartTime;
|
| 50 |
+
const hasFaultyModels = faultyModels && faultyModels.length > 0;
|
| 51 |
+
const hasErrorMessage = errorMessage && errorMessage.trim().length > 0;
|
| 52 |
+
|
| 53 |
+
const hasAnyAlert = hasMaintenanceAlert || hasFaultyModels || hasErrorMessage;
|
| 54 |
+
|
| 55 |
+
const isBannerVisible = useMemo(() => {
|
| 56 |
+
if (!hasAnyAlert) {
|
| 57 |
+
return false;
|
| 58 |
+
}
|
| 59 |
+
return dismissedAt !== updatedAt;
|
| 60 |
+
}, [dismissedAt, updatedAt, hasAnyAlert]);
|
| 61 |
+
|
| 62 |
+
// Try to translate error message, fallback to raw message
|
| 63 |
+
const translatedErrorMessage = useMemo(() => {
|
| 64 |
+
if (!errorMessage) return null;
|
| 65 |
+
|
| 66 |
+
// Check if the error message is a translation key (e.g., "ERROR$SOME_KEY")
|
| 67 |
+
const translated = t(errorMessage as I18nKey);
|
| 68 |
+
// If translation returns the same key, it means no translation exists
|
| 69 |
+
if (translated === errorMessage) {
|
| 70 |
+
return errorMessage;
|
| 71 |
+
}
|
| 72 |
+
return translated;
|
| 73 |
+
}, [errorMessage, t]);
|
| 74 |
+
|
| 75 |
+
if (!isBannerVisible) {
|
| 76 |
+
return null;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const renderMessages = () => {
|
| 80 |
+
const messages: React.ReactNode[] = [];
|
| 81 |
+
|
| 82 |
+
if (hasMaintenanceAlert && localTime) {
|
| 83 |
+
messages.push(
|
| 84 |
+
<Typography.Paragraph key="maintenance" className="text-sm font-medium">
|
| 85 |
+
{t(I18nKey.MAINTENANCE$SCHEDULED_MESSAGE, { time: localTime })}
|
| 86 |
+
</Typography.Paragraph>,
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
if (hasFaultyModels) {
|
| 91 |
+
messages.push(
|
| 92 |
+
<Typography.Paragraph
|
| 93 |
+
key="faulty-models"
|
| 94 |
+
className="text-sm font-medium"
|
| 95 |
+
>
|
| 96 |
+
{t(I18nKey.ALERT$FAULTY_MODELS_MESSAGE)} {faultyModels!.join(", ")}
|
| 97 |
+
</Typography.Paragraph>,
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
if (hasErrorMessage && translatedErrorMessage) {
|
| 102 |
+
messages.push(
|
| 103 |
+
<Typography.Paragraph
|
| 104 |
+
key="error-message"
|
| 105 |
+
className="text-sm font-medium"
|
| 106 |
+
>
|
| 107 |
+
{translatedErrorMessage}
|
| 108 |
+
</Typography.Paragraph>,
|
| 109 |
+
);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return messages;
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<div
|
| 117 |
+
data-testid="alert-banner"
|
| 118 |
+
className={cn(
|
| 119 |
+
"bg-base border border-primary text-white p-4 rounded",
|
| 120 |
+
"flex flex-row items-center justify-between m-1",
|
| 121 |
+
currentPath === "/" && "mt-3 mr-3",
|
| 122 |
+
)}
|
| 123 |
+
>
|
| 124 |
+
<div className="flex items-center">
|
| 125 |
+
<div className="flex-shrink-0">
|
| 126 |
+
<FaTriangleExclamation className="text-primary align-middle" />
|
| 127 |
+
</div>
|
| 128 |
+
<div className="ml-3 flex flex-col gap-1">{renderMessages()}</div>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<button
|
| 132 |
+
type="button"
|
| 133 |
+
data-testid="dismiss-button"
|
| 134 |
+
onClick={() => setDismissedAt(updatedAt)}
|
| 135 |
+
className={cn(
|
| 136 |
+
"bg-base rounded-full w-5 h-5 flex items-center justify-center cursor-pointer",
|
| 137 |
+
)}
|
| 138 |
+
>
|
| 139 |
+
<CloseIcon />
|
| 140 |
+
</button>
|
| 141 |
+
</div>
|
| 142 |
+
);
|
| 143 |
+
}
|
src/components/features/backends/add-backend-modal.tsx
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BackendFormModal } from "./backend-form-modal";
|
| 2 |
+
|
| 3 |
+
interface AddBackendModalProps {
|
| 4 |
+
onClose: () => void;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export function AddBackendModal({ onClose }: AddBackendModalProps) {
|
| 8 |
+
return <BackendFormModal mode="add" onClose={onClose} />;
|
| 9 |
+
}
|
src/components/features/backends/api-key-entry-screen.tsx
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import { SettingsClient } from "@openhands/typescript-client/clients";
|
| 4 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 5 |
+
import { isSdkHttpStatusError } from "#/api/agent-server-compatibility";
|
| 6 |
+
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
| 7 |
+
import { isNoBackend } from "#/api/backend-registry/active-store";
|
| 8 |
+
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
| 9 |
+
import { BrandButton } from "#/components/features/settings/brand-button";
|
| 10 |
+
import {
|
| 11 |
+
MODAL_MAX_WIDTH_VIEWPORT,
|
| 12 |
+
modalWidthClassName,
|
| 13 |
+
} from "#/components/shared/modals/modal-body";
|
| 14 |
+
import { cn } from "#/utils/utils";
|
| 15 |
+
import { BackendStatusDot } from "./backend-status-dot";
|
| 16 |
+
import {
|
| 17 |
+
BackendForm,
|
| 18 |
+
type BackendFormSubmitPayload,
|
| 19 |
+
} from "./backend-form-modal";
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Full-screen prompt shown when the server is in public mode
|
| 23 |
+
* (`VITE_AUTH_REQUIRED=true`) and no valid API key has been configured.
|
| 24 |
+
*
|
| 25 |
+
* Reuses {@link BackendForm} with `hostReadOnly` + `onSubmitOverride`
|
| 26 |
+
* to render the standard name / host / API-key inputs while adding
|
| 27 |
+
* server-side validation (calls `GET /api/settings` before persisting)
|
| 28 |
+
* and a connection status indicator.
|
| 29 |
+
*/
|
| 30 |
+
export default function ApiKeyEntryScreen() {
|
| 31 |
+
const { t } = useTranslation("openhands");
|
| 32 |
+
const { active, addBackend, updateBackend } = useActiveBackendContext();
|
| 33 |
+
|
| 34 |
+
const host = window.location.origin;
|
| 35 |
+
|
| 36 |
+
const [isValidating, setIsValidating] = React.useState(false);
|
| 37 |
+
const [connectionStatus, setConnectionStatus] = React.useState<
|
| 38 |
+
"idle" | "success" | "error"
|
| 39 |
+
>("idle");
|
| 40 |
+
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
| 41 |
+
|
| 42 |
+
const handleSubmitOverride = React.useCallback(
|
| 43 |
+
async (payload: BackendFormSubmitPayload) => {
|
| 44 |
+
setIsValidating(true);
|
| 45 |
+
setConnectionStatus("idle");
|
| 46 |
+
setErrorMessage(null);
|
| 47 |
+
|
| 48 |
+
try {
|
| 49 |
+
await new SettingsClient(
|
| 50 |
+
getAgentServerClientOptions({
|
| 51 |
+
host: payload.host,
|
| 52 |
+
sessionApiKey: payload.apiKey,
|
| 53 |
+
timeout: 5000,
|
| 54 |
+
}),
|
| 55 |
+
).getSettings();
|
| 56 |
+
|
| 57 |
+
setConnectionStatus("success");
|
| 58 |
+
|
| 59 |
+
if (isNoBackend(active.backend)) {
|
| 60 |
+
addBackend(payload);
|
| 61 |
+
} else {
|
| 62 |
+
updateBackend(active.backend.id, payload);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
window.location.reload();
|
| 66 |
+
} catch (err: unknown) {
|
| 67 |
+
setConnectionStatus("error");
|
| 68 |
+
|
| 69 |
+
if (isSdkHttpStatusError(err, 401)) {
|
| 70 |
+
setErrorMessage(t(I18nKey.AUTH$INVALID_KEY));
|
| 71 |
+
} else {
|
| 72 |
+
const detail = err instanceof Error ? err.message : String(err);
|
| 73 |
+
setErrorMessage(
|
| 74 |
+
`${t(I18nKey.AUTH$CONNECTION_FAILED)}${detail ? `: ${detail}` : ""}`,
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
setIsValidating(false);
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
[active.backend, addBackend, updateBackend, t],
|
| 81 |
+
);
|
| 82 |
+
|
| 83 |
+
return (
|
| 84 |
+
<div
|
| 85 |
+
data-testid="api-key-entry-screen"
|
| 86 |
+
className="flex min-h-screen items-center justify-center bg-base px-6"
|
| 87 |
+
>
|
| 88 |
+
<div
|
| 89 |
+
className={cn(
|
| 90 |
+
"relative rounded-xl border border-[var(--oh-border)] bg-base-secondary",
|
| 91 |
+
modalWidthClassName("md"),
|
| 92 |
+
MODAL_MAX_WIDTH_VIEWPORT,
|
| 93 |
+
)}
|
| 94 |
+
>
|
| 95 |
+
<div className="px-6 pt-6 pb-2 pr-12">
|
| 96 |
+
<h2 className="text-lg font-semibold">
|
| 97 |
+
{t(I18nKey.BACKEND$ADD_TITLE)}
|
| 98 |
+
</h2>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div className="px-6 pb-6 pt-2">
|
| 102 |
+
<BackendForm
|
| 103 |
+
mode="add"
|
| 104 |
+
backend={{ ...active.backend, host, apiKey: "", name: "" }}
|
| 105 |
+
onSubmitted={() => {}}
|
| 106 |
+
testIdRoot="api-key-entry"
|
| 107 |
+
hostReadOnly
|
| 108 |
+
requireApiKey
|
| 109 |
+
onSubmitOverride={handleSubmitOverride}
|
| 110 |
+
renderActions={({ canSubmit, testIdRoot }) => (
|
| 111 |
+
<>
|
| 112 |
+
{connectionStatus !== "idle" && (
|
| 113 |
+
<div className="flex items-center gap-3 text-sm">
|
| 114 |
+
<BackendStatusDot
|
| 115 |
+
isConnected={connectionStatus === "success"}
|
| 116 |
+
/>
|
| 117 |
+
<span
|
| 118 |
+
data-testid={`${testIdRoot}-status`}
|
| 119 |
+
className={
|
| 120 |
+
connectionStatus === "error"
|
| 121 |
+
? "text-red-400"
|
| 122 |
+
: "text-green-400"
|
| 123 |
+
}
|
| 124 |
+
>
|
| 125 |
+
{connectionStatus === "error"
|
| 126 |
+
? errorMessage
|
| 127 |
+
: t(I18nKey.ONBOARDING$BACKEND_STATUS_CONNECTED)}
|
| 128 |
+
</span>
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
|
| 132 |
+
<BrandButton
|
| 133 |
+
type="submit"
|
| 134 |
+
variant="secondary"
|
| 135 |
+
isDisabled={!canSubmit || isValidating}
|
| 136 |
+
testId={`${testIdRoot}-submit`}
|
| 137 |
+
className="w-full text-center"
|
| 138 |
+
>
|
| 139 |
+
{isValidating
|
| 140 |
+
? t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING)
|
| 141 |
+
: t(I18nKey.BACKEND$CONNECT)}
|
| 142 |
+
</BrandButton>
|
| 143 |
+
</>
|
| 144 |
+
)}
|
| 145 |
+
/>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
);
|
| 150 |
+
}
|
src/components/features/backends/backend-form-modal.test.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
| 2 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 3 |
+
import { BackendConnectionOptions } from "./backend-form-modal";
|
| 4 |
+
|
| 5 |
+
// Isolate the manual "add backend" column: the sibling Cloud-login column
|
| 6 |
+
// drives an OAuth device flow that is irrelevant to backend-kind selection.
|
| 7 |
+
vi.mock("./device-flow-auth", () => ({
|
| 8 |
+
DeviceFlowAuth: () => null,
|
| 9 |
+
}));
|
| 10 |
+
|
| 11 |
+
afterEach(() => {
|
| 12 |
+
vi.clearAllMocks();
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
describe("BackendConnectionOptions — manual backend type", () => {
|
| 16 |
+
it("defaults the Type selector to Cloud for an OpenHands Cloud host", () => {
|
| 17 |
+
render(
|
| 18 |
+
<BackendConnectionOptions
|
| 19 |
+
onConnected={vi.fn()}
|
| 20 |
+
initialManualBackend={{ host: "https://app.all-hands.dev" }}
|
| 21 |
+
/>,
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
expect(screen.getByTestId("add-backend-kind-option-cloud")).toHaveAttribute(
|
| 25 |
+
"aria-checked",
|
| 26 |
+
"true",
|
| 27 |
+
);
|
| 28 |
+
expect(screen.getByTestId("add-backend-kind-option-local")).toHaveAttribute(
|
| 29 |
+
"aria-checked",
|
| 30 |
+
"false",
|
| 31 |
+
);
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
it("defaults the Type selector to Local for a self-hosted host on a custom domain", () => {
|
| 35 |
+
// A self-hosted OHE on a custom domain is indistinguishable from a local
|
| 36 |
+
// agent-server by host alone, so it must not be auto-classified as Cloud.
|
| 37 |
+
render(
|
| 38 |
+
<BackendConnectionOptions
|
| 39 |
+
onConnected={vi.fn()}
|
| 40 |
+
initialManualBackend={{ host: "https://app.adorable.build.one" }}
|
| 41 |
+
/>,
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
expect(screen.getByTestId("add-backend-kind-option-local")).toHaveAttribute(
|
| 45 |
+
"aria-checked",
|
| 46 |
+
"true",
|
| 47 |
+
);
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
it("persists the user-selected Cloud kind when adding a self-hosted OHE on a custom domain", async () => {
|
| 51 |
+
const onConnected = vi.fn();
|
| 52 |
+
render(
|
| 53 |
+
<BackendConnectionOptions
|
| 54 |
+
onConnected={onConnected}
|
| 55 |
+
initialManualBackend={{
|
| 56 |
+
name: "Adorable Enterprise",
|
| 57 |
+
host: "app.adorable.build.one",
|
| 58 |
+
apiKey: "oh-cloud-key",
|
| 59 |
+
}}
|
| 60 |
+
/>,
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
// The user overrides the (Local) default to declare this custom domain a
|
| 64 |
+
// Cloud app-server, then connects.
|
| 65 |
+
fireEvent.click(screen.getByTestId("add-backend-kind-option-cloud"));
|
| 66 |
+
fireEvent.click(screen.getByTestId("add-backend-submit"));
|
| 67 |
+
|
| 68 |
+
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
|
| 69 |
+
expect(onConnected).toHaveBeenCalledWith(
|
| 70 |
+
expect.objectContaining({
|
| 71 |
+
kind: "cloud",
|
| 72 |
+
name: "Adorable Enterprise",
|
| 73 |
+
host: "https://app.adorable.build.one",
|
| 74 |
+
apiKey: "oh-cloud-key",
|
| 75 |
+
}),
|
| 76 |
+
"manual",
|
| 77 |
+
{ agentServerVersion: null },
|
| 78 |
+
);
|
| 79 |
+
});
|
| 80 |
+
});
|
src/components/features/backends/backend-form-modal.tsx
ADDED
|
@@ -0,0 +1,1629 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
+
import { useTranslation } from "react-i18next";
|
| 4 |
+
import { ChevronDown, Globe, Info, Monitor } from "lucide-react";
|
| 5 |
+
import { ServerClient } from "@openhands/typescript-client/clients";
|
| 6 |
+
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
|
| 7 |
+
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
| 8 |
+
import {
|
| 9 |
+
MODAL_MAX_WIDTH_VIEWPORT,
|
| 10 |
+
modalWidthClassName,
|
| 11 |
+
} from "#/components/shared/modals/modal-body";
|
| 12 |
+
import { ModalCloseButton } from "#/components/shared/modals/modal-close-button";
|
| 13 |
+
import { BrandButton } from "#/components/features/settings/brand-button";
|
| 14 |
+
import { SettingsInput } from "#/components/features/settings/settings-input";
|
| 15 |
+
import { SegmentedToggle } from "#/components/features/files-tab/segmented-toggle";
|
| 16 |
+
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
| 17 |
+
import { useNavigation } from "#/context/navigation-context";
|
| 18 |
+
import { useBackendsHealth } from "#/hooks/query/use-backends-health";
|
| 19 |
+
import { useTracking } from "#/hooks/use-tracking";
|
| 20 |
+
import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics";
|
| 21 |
+
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
| 22 |
+
import { getLockedCloudHost } from "#/api/agent-server-config";
|
| 23 |
+
import { isOpenHandsCloudHost } from "#/api/device-flow-client";
|
| 24 |
+
import {
|
| 25 |
+
getDisplayAgentServerVersion,
|
| 26 |
+
validateLocalBackend,
|
| 27 |
+
} from "#/api/agent-server-compatibility";
|
| 28 |
+
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
|
| 29 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 30 |
+
import type { Backend, BackendKind } from "#/api/backend-registry/types";
|
| 31 |
+
import { getUserFacingConnectionErrorMessage } from "#/utils/user-facing-error";
|
| 32 |
+
import { cn } from "#/utils/utils";
|
| 33 |
+
import {
|
| 34 |
+
modalTitleLgClassName,
|
| 35 |
+
modalTitleLgMediumClassName,
|
| 36 |
+
} from "#/utils/modal-classes";
|
| 37 |
+
import ExternalLinkIcon from "#/icons/external-link.svg?react";
|
| 38 |
+
import ServerIcon from "#/icons/server.svg?react";
|
| 39 |
+
import { getBackendStatusLabel } from "./backend-status-label";
|
| 40 |
+
import { BackendStatusDot } from "./backend-status-dot";
|
| 41 |
+
import { DeviceFlowAuth } from "./device-flow-auth";
|
| 42 |
+
|
| 43 |
+
export type BackendFormMode = "add" | "edit";
|
| 44 |
+
|
| 45 |
+
interface BackendConnectionTestMetadata {
|
| 46 |
+
agentServerVersion: string | null;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
interface BackendFormModalProps {
|
| 50 |
+
mode: BackendFormMode;
|
| 51 |
+
/** Required when `mode === "edit"`. */
|
| 52 |
+
backend?: Backend;
|
| 53 |
+
onClose: () => void;
|
| 54 |
+
/** Analytics surface for the `backend_added` event (add mode only). */
|
| 55 |
+
source?: BackendAddedSource;
|
| 56 |
+
/** Hide the close button and disable backdrop/escape dismissal. Used for locked Cloud first-run. */
|
| 57 |
+
hideCloseButton?: boolean;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* Seed the default backend kind from the host. Uses proper hostname-suffix
|
| 62 |
+
* matching (via {@link isOpenHandsCloudHost}) rather than a substring test, so
|
| 63 |
+
* a look-alike host such as `all-hands-testing.dev` isn't misread as cloud.
|
| 64 |
+
*
|
| 65 |
+
* This is only a *default*: a self-hosted OpenHands Cloud/Enterprise instance
|
| 66 |
+
* on a truly custom domain is indistinguishable from a local agent-server by
|
| 67 |
+
* host alone, so the manual add form lets the user override the kind
|
| 68 |
+
* explicitly (see the Type selector in ManualConnectionColumn).
|
| 69 |
+
*/
|
| 70 |
+
function inferKindFromHost(host: string): BackendKind {
|
| 71 |
+
return isOpenHandsCloudHost(host) ? "cloud" : "local";
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Returns true for hostnames that represent a local / private-network address.
|
| 76 |
+
* Used by normalizeHost to choose http:// instead of https://.
|
| 77 |
+
*/
|
| 78 |
+
function isLocalAddress(hostname: string): boolean {
|
| 79 |
+
// Strip IPv6 bracket notation: [::1] → ::1
|
| 80 |
+
const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
| 81 |
+
// IPv6 loopback, any-address, and named loopback
|
| 82 |
+
if (h === "localhost" || h === "::1" || h === "::" || h === "0.0.0.0")
|
| 83 |
+
return true;
|
| 84 |
+
// 127.x.x.x loopback range + IPv4-mapped loopback (::ffff:127.x.x.x)
|
| 85 |
+
if (/^127\./.test(h) || /^::ffff:127\./i.test(h)) return true;
|
| 86 |
+
// RFC 1918 private ranges
|
| 87 |
+
if (/^10\./.test(h)) return true;
|
| 88 |
+
if (/^192\.168\./.test(h)) return true;
|
| 89 |
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
|
| 90 |
+
// IPv6 link-local (fe80::/10) and unique local (fc00::/7)
|
| 91 |
+
if (/^fe[89ab][0-9a-f]:/i.test(h)) return true;
|
| 92 |
+
if (/^f[cd][0-9a-f]{2}:/i.test(h)) return true;
|
| 93 |
+
// mDNS / Bonjour (.local)
|
| 94 |
+
if (h.endsWith(".local")) return true;
|
| 95 |
+
// Single-label hostnames (no dots, no colons) are local network names.
|
| 96 |
+
// Colons are excluded so bare IPv6 addresses don't accidentally match.
|
| 97 |
+
if (!h.includes(".") && !h.includes(":")) return true;
|
| 98 |
+
return false;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function normalizeHost(host: string): string {
|
| 102 |
+
const trimmed = host.trim().replace(/\/+$/, "");
|
| 103 |
+
if (!trimmed) return "";
|
| 104 |
+
// Already has an explicit scheme — respect it.
|
| 105 |
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
| 106 |
+
// Extract the pure hostname for scheme selection, handling three cases:
|
| 107 |
+
// [::1]:8080 → bracket IPv6 notation → extract ::1
|
| 108 |
+
// ::1 → bare IPv6 (multiple colons, no bracket) → whole string
|
| 109 |
+
// host:port → regular host:port → part before the colon
|
| 110 |
+
const bracketMatch = trimmed.match(/^\[([^\]]+)\]/);
|
| 111 |
+
const hostname = bracketMatch
|
| 112 |
+
? bracketMatch[1]
|
| 113 |
+
: (trimmed.match(/:/g) ?? []).length > 1
|
| 114 |
+
? trimmed
|
| 115 |
+
: trimmed.split(":")[0];
|
| 116 |
+
const scheme = isLocalAddress(hostname) ? "http" : "https";
|
| 117 |
+
return `${scheme}://${trimmed}`;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* Returns true when `host` represents a reachable backend URL.
|
| 122 |
+
*
|
| 123 |
+
* Rules (applied in order):
|
| 124 |
+
* 1. Must be non-empty after trimming.
|
| 125 |
+
* 2. Must contain no whitespace — spaces can never appear in a host/port.
|
| 126 |
+
* 3. After normalisation (bare hosts get `https://` prepended), must parse
|
| 127 |
+
* as a valid http or https URL with a non-empty hostname.
|
| 128 |
+
*/
|
| 129 |
+
function isValidHostUrl(host: string): boolean {
|
| 130 |
+
const trimmed = host.trim();
|
| 131 |
+
if (!trimmed) return false;
|
| 132 |
+
// Spaces anywhere in the input are an immediate rejection.
|
| 133 |
+
if (/\s/.test(trimmed)) return false;
|
| 134 |
+
const normalized = normalizeHost(trimmed);
|
| 135 |
+
if (!normalized) return false;
|
| 136 |
+
try {
|
| 137 |
+
const url = new URL(normalized);
|
| 138 |
+
return (
|
| 139 |
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
| 140 |
+
url.hostname.length > 0
|
| 141 |
+
);
|
| 142 |
+
} catch {
|
| 143 |
+
return false;
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
const DEFAULT_OPENHANDS_CLOUD_HOST = "https://app.all-hands.dev";
|
| 148 |
+
const LOCAL_BACKEND_COMMAND = "agent-canvas --backend-only --port 8001";
|
| 149 |
+
const LOCAL_AGENT_SERVER_DOCS_URL =
|
| 150 |
+
"https://github.com/OpenHands/OpenHands/blob/main/docs/DEVELOPMENT.md#alternative-development-workflows";
|
| 151 |
+
const REMOTE_AGENT_SERVER_DOCS_URL =
|
| 152 |
+
"https://github.com/OpenHands/OpenHands/blob/main/docs/SELF_HOSTING.md";
|
| 153 |
+
const DEPLOYMENT_OPTIONS_URL =
|
| 154 |
+
"https://docs.openhands.dev/overview/introduction";
|
| 155 |
+
export type BackendConnectionMethod = "manual" | "cloud_login";
|
| 156 |
+
|
| 157 |
+
export type BackendAddedSource = CloudConnectionSource;
|
| 158 |
+
type AddBackendOption = "cloud" | "agent-server";
|
| 159 |
+
type AgentServerLocation = "local" | "remote";
|
| 160 |
+
|
| 161 |
+
function getConnectionTestFailedTitle(
|
| 162 |
+
t: ReturnType<typeof useTranslation>["t"],
|
| 163 |
+
host: string,
|
| 164 |
+
): string {
|
| 165 |
+
return t(I18nKey.BACKEND$CONNECTION_TEST_FAILED, {
|
| 166 |
+
host,
|
| 167 |
+
interpolation: { escapeValue: false },
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function getConnectionErrorDetail(error: unknown): string | null {
|
| 172 |
+
return getUserFacingConnectionErrorMessage(error);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function getConnectionTestFailedMessage(title: string, error: unknown): string {
|
| 176 |
+
const detail = getConnectionErrorDetail(error);
|
| 177 |
+
return detail ? `${title}\n${detail}` : title;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
async function testBackendConnection(
|
| 181 |
+
backend: Pick<Backend, "host" | "apiKey" | "kind">,
|
| 182 |
+
): Promise<BackendConnectionTestMetadata> {
|
| 183 |
+
// Cloud backends authenticate via OAuth; preflight GET is not applicable.
|
| 184 |
+
if (backend.kind !== "local") return { agentServerVersion: null };
|
| 185 |
+
const agentServerVersion = await validateLocalBackend(backend, 5000);
|
| 186 |
+
return { agentServerVersion };
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
/**
|
| 190 |
+
* Live status row for the edit form: shows a connection dot, a
|
| 191 |
+
* "Local"/"Cloud" label, and the agent server's reported version when
|
| 192 |
+
* available. Replaces the legacy local/cloud radio fieldset (kind is
|
| 193 |
+
* now inferred from the host).
|
| 194 |
+
*/
|
| 195 |
+
function BackendStatusBadge({
|
| 196 |
+
backend,
|
| 197 |
+
testIdRoot,
|
| 198 |
+
}: {
|
| 199 |
+
backend: Backend;
|
| 200 |
+
testIdRoot: string;
|
| 201 |
+
}) {
|
| 202 |
+
const { t } = useTranslation("openhands");
|
| 203 |
+
const healthByBackendId = useBackendsHealth([backend]);
|
| 204 |
+
const health = healthByBackendId[backend.id];
|
| 205 |
+
const isConnected = health?.isConnected ?? null;
|
| 206 |
+
const disabled = health?.disabled === true;
|
| 207 |
+
const consecutiveFailures = health?.consecutiveFailures ?? 0;
|
| 208 |
+
const lastError = health?.lastError ?? null;
|
| 209 |
+
|
| 210 |
+
const { data: version } = useQuery({
|
| 211 |
+
queryKey: ["backend-version", backend.host, backend.apiKey],
|
| 212 |
+
queryFn: async () => {
|
| 213 |
+
const info = await new ServerClient(
|
| 214 |
+
getAgentServerClientOptions({
|
| 215 |
+
host: backend.host,
|
| 216 |
+
sessionApiKey: backend.apiKey || null,
|
| 217 |
+
timeout: 5000,
|
| 218 |
+
}),
|
| 219 |
+
).getServerInfo();
|
| 220 |
+
return getDisplayAgentServerVersion(info);
|
| 221 |
+
},
|
| 222 |
+
retry: false,
|
| 223 |
+
staleTime: 60_000,
|
| 224 |
+
enabled: backend.kind === "local" && !disabled,
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
const statusLabel = getBackendStatusLabel(t, backend, health);
|
| 228 |
+
|
| 229 |
+
const kindLabel =
|
| 230 |
+
backend.kind === "cloud"
|
| 231 |
+
? t(I18nKey.BACKEND$KIND_CLOUD)
|
| 232 |
+
: t(I18nKey.BACKEND$KIND_LOCAL);
|
| 233 |
+
|
| 234 |
+
return (
|
| 235 |
+
<div className="flex flex-col gap-2">
|
| 236 |
+
<div
|
| 237 |
+
data-testid={`${testIdRoot}-status`}
|
| 238 |
+
className="flex items-center gap-3 text-sm"
|
| 239 |
+
>
|
| 240 |
+
<BackendStatusDot isConnected={isConnected} />
|
| 241 |
+
<span className="text-white" data-testid={`${testIdRoot}-status-label`}>
|
| 242 |
+
{statusLabel}
|
| 243 |
+
</span>
|
| 244 |
+
<span className="text-tertiary-alt">·</span>
|
| 245 |
+
<span className="text-[var(--oh-text-tertiary)]">{kindLabel}</span>
|
| 246 |
+
{version ? (
|
| 247 |
+
<span
|
| 248 |
+
className="text-xs text-[var(--oh-muted)]"
|
| 249 |
+
data-testid={`${testIdRoot}-version`}
|
| 250 |
+
>
|
| 251 |
+
{t(I18nKey.BACKEND$VERSION_LABEL, { version })}
|
| 252 |
+
</span>
|
| 253 |
+
) : null}
|
| 254 |
+
</div>
|
| 255 |
+
|
| 256 |
+
{disabled ? (
|
| 257 |
+
<div
|
| 258 |
+
data-testid={`${testIdRoot}-status-error`}
|
| 259 |
+
className="flex flex-col gap-1 rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm"
|
| 260 |
+
>
|
| 261 |
+
<span className="font-semibold text-red-300">
|
| 262 |
+
{t(I18nKey.BACKEND$HEALTH_FAILED_TITLE)}
|
| 263 |
+
</span>
|
| 264 |
+
<span className="text-xs text-[var(--oh-text-tertiary)]">
|
| 265 |
+
{t(I18nKey.BACKEND$HEALTH_FAILED_DETAIL, {
|
| 266 |
+
count: consecutiveFailures,
|
| 267 |
+
})}
|
| 268 |
+
</span>
|
| 269 |
+
{lastError ? (
|
| 270 |
+
<span
|
| 271 |
+
data-testid={`${testIdRoot}-status-error-message`}
|
| 272 |
+
className="text-xs text-red-300 whitespace-pre-wrap break-words"
|
| 273 |
+
>
|
| 274 |
+
{lastError}
|
| 275 |
+
</span>
|
| 276 |
+
) : null}
|
| 277 |
+
</div>
|
| 278 |
+
) : null}
|
| 279 |
+
</div>
|
| 280 |
+
);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
export interface BackendFormSubmitPayload {
|
| 284 |
+
name: string;
|
| 285 |
+
host: string;
|
| 286 |
+
apiKey: string;
|
| 287 |
+
kind: BackendKind;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
interface UseBackendFormOptions {
|
| 291 |
+
initialName?: string;
|
| 292 |
+
initialHost?: string;
|
| 293 |
+
initialApiKey?: string;
|
| 294 |
+
/**
|
| 295 |
+
* Called to test the connection. The hook does NOT call
|
| 296 |
+
* `testBackendConnection` directly so callers can inject a
|
| 297 |
+
* wrapped version (e.g. with extra logging or different timeout).
|
| 298 |
+
* Should throw on failure.
|
| 299 |
+
*/
|
| 300 |
+
onTestConnection: (
|
| 301 |
+
payload: BackendFormSubmitPayload,
|
| 302 |
+
) => Promise<BackendConnectionTestMetadata>;
|
| 303 |
+
/** Called after a successful connection test and persistence. */
|
| 304 |
+
onSuccess: (metadata: BackendConnectionTestMetadata) => void;
|
| 305 |
+
/** Require a non-empty API key even when the host looks local. */
|
| 306 |
+
requireApiKey?: boolean;
|
| 307 |
+
/**
|
| 308 |
+
* When provided, completely replaces the default submit flow
|
| 309 |
+
* (onTestConnection + onSuccess). The hook still manages form state
|
| 310 |
+
* and canSubmit validation, but the caller owns error handling and
|
| 311 |
+
* success side effects. Should throw on failure.
|
| 312 |
+
*/
|
| 313 |
+
onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise<void>;
|
| 314 |
+
/** Fix the persisted backend kind instead of inferring it from the host. */
|
| 315 |
+
fixedKind?: BackendKind;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
/**
|
| 319 |
+
* Shared hook for the backend-form state used by both `BackendForm`
|
| 320 |
+
* (edit/add mode) and `ManualConnectionColumn` (add-mode-only column).
|
| 321 |
+
* Encapsulates name / host / apiKey fields, `connectionError`,
|
| 322 |
+
* `isSubmitting`, and the shared `handleSubmit` flow.
|
| 323 |
+
*/
|
| 324 |
+
function useBackendForm({
|
| 325 |
+
initialName = "",
|
| 326 |
+
initialHost = "",
|
| 327 |
+
initialApiKey = "",
|
| 328 |
+
onTestConnection,
|
| 329 |
+
onSuccess,
|
| 330 |
+
requireApiKey = false,
|
| 331 |
+
onSubmitOverride,
|
| 332 |
+
fixedKind,
|
| 333 |
+
}: UseBackendFormOptions) {
|
| 334 |
+
const { t } = useTranslation("openhands");
|
| 335 |
+
|
| 336 |
+
const [name, setName] = React.useState(initialName);
|
| 337 |
+
const [host, setHost] = React.useState(initialHost);
|
| 338 |
+
const [apiKey, setApiKey] = React.useState(initialApiKey);
|
| 339 |
+
const [connectionError, setConnectionError] = React.useState<string | null>(
|
| 340 |
+
null,
|
| 341 |
+
);
|
| 342 |
+
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
| 343 |
+
const [kindOverride, setKindOverride] = React.useState<BackendKind | null>(
|
| 344 |
+
null,
|
| 345 |
+
);
|
| 346 |
+
|
| 347 |
+
// Kind follows host inference until the user explicitly picks a type, then
|
| 348 |
+
// respects that choice. A custom-domain OHE can't be distinguished from a
|
| 349 |
+
// custom-domain local agent-server by host alone, so ManualConnectionColumn
|
| 350 |
+
// exposes `setKind` (a Type selector) to let the user declare it.
|
| 351 |
+
const kind = fixedKind ?? kindOverride ?? inferKindFromHost(host);
|
| 352 |
+
const needsApiKey = requireApiKey || kind !== "local";
|
| 353 |
+
const canSubmit =
|
| 354 |
+
name.trim().length > 0 &&
|
| 355 |
+
isValidHostUrl(host) &&
|
| 356 |
+
(!needsApiKey || apiKey.trim().length > 0);
|
| 357 |
+
|
| 358 |
+
const handleSubmit = React.useCallback(
|
| 359 |
+
async (e: React.FormEvent<HTMLFormElement>) => {
|
| 360 |
+
e.preventDefault();
|
| 361 |
+
if (!canSubmit || isSubmitting) return;
|
| 362 |
+
|
| 363 |
+
const payload: BackendFormSubmitPayload = {
|
| 364 |
+
name: name.trim(),
|
| 365 |
+
host: normalizeHost(host),
|
| 366 |
+
apiKey: apiKey.trim(),
|
| 367 |
+
kind,
|
| 368 |
+
};
|
| 369 |
+
|
| 370 |
+
setConnectionError(null);
|
| 371 |
+
setIsSubmitting(true);
|
| 372 |
+
|
| 373 |
+
try {
|
| 374 |
+
// When onSubmitOverride is provided, it completely replaces the
|
| 375 |
+
// default flow (onTestConnection + onSuccess).
|
| 376 |
+
if (onSubmitOverride) {
|
| 377 |
+
await onSubmitOverride(payload);
|
| 378 |
+
} else {
|
| 379 |
+
const metadata = await onTestConnection(payload);
|
| 380 |
+
onSuccess(metadata);
|
| 381 |
+
}
|
| 382 |
+
} catch (error) {
|
| 383 |
+
setConnectionError(
|
| 384 |
+
getConnectionTestFailedMessage(
|
| 385 |
+
getConnectionTestFailedTitle(t, payload.host),
|
| 386 |
+
error,
|
| 387 |
+
),
|
| 388 |
+
);
|
| 389 |
+
} finally {
|
| 390 |
+
setIsSubmitting(false);
|
| 391 |
+
}
|
| 392 |
+
},
|
| 393 |
+
[
|
| 394 |
+
canSubmit,
|
| 395 |
+
isSubmitting,
|
| 396 |
+
name,
|
| 397 |
+
host,
|
| 398 |
+
apiKey,
|
| 399 |
+
kind,
|
| 400 |
+
onTestConnection,
|
| 401 |
+
onSuccess,
|
| 402 |
+
requireApiKey,
|
| 403 |
+
onSubmitOverride,
|
| 404 |
+
fixedKind,
|
| 405 |
+
t,
|
| 406 |
+
],
|
| 407 |
+
);
|
| 408 |
+
|
| 409 |
+
return {
|
| 410 |
+
name,
|
| 411 |
+
setName,
|
| 412 |
+
host,
|
| 413 |
+
setHost,
|
| 414 |
+
apiKey,
|
| 415 |
+
setApiKey,
|
| 416 |
+
connectionError,
|
| 417 |
+
setConnectionError,
|
| 418 |
+
isSubmitting,
|
| 419 |
+
kind,
|
| 420 |
+
setKind: setKindOverride,
|
| 421 |
+
canSubmit,
|
| 422 |
+
handleSubmit,
|
| 423 |
+
};
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
export interface BackendFormProps {
|
| 427 |
+
mode: BackendFormMode;
|
| 428 |
+
/** Required when `mode === "edit"`. */
|
| 429 |
+
backend?: Backend;
|
| 430 |
+
/**
|
| 431 |
+
* Called after the form is submitted and the backend has been
|
| 432 |
+
* persisted. Use this to dismiss a containing modal, advance an
|
| 433 |
+
* onboarding step, etc.
|
| 434 |
+
*/
|
| 435 |
+
onSubmitted: () => void;
|
| 436 |
+
/**
|
| 437 |
+
* Optional render slot rendered in place of the default
|
| 438 |
+
* Save / Cancel button row, so callers (e.g. the onboarding flow)
|
| 439 |
+
* can re-skin the action area while still owning submission via the
|
| 440 |
+
* standard `<form onSubmit>` flow. Receives the form's submit-ready
|
| 441 |
+
* state.
|
| 442 |
+
*/
|
| 443 |
+
renderActions?: (state: {
|
| 444 |
+
canSubmit: boolean;
|
| 445 |
+
isSubmitting: boolean;
|
| 446 |
+
testIdRoot: string;
|
| 447 |
+
}) => React.ReactNode;
|
| 448 |
+
/** Used to disambiguate test ids across the same screen. */
|
| 449 |
+
testIdRoot?: string;
|
| 450 |
+
/** When true, the host field is pre-filled and disabled. */
|
| 451 |
+
hostReadOnly?: boolean;
|
| 452 |
+
/**
|
| 453 |
+
* When true, a non-empty API key is required for submission regardless
|
| 454 |
+
* of the inferred backend kind. The standard add form allows empty
|
| 455 |
+
* keys for local backends; the auth-gate screen needs to enforce one.
|
| 456 |
+
*/
|
| 457 |
+
requireApiKey?: boolean;
|
| 458 |
+
/**
|
| 459 |
+
* When true, hides the name/host/API-key inputs (and related inline
|
| 460 |
+
* errors) while keeping the action row visible — used by onboarding
|
| 461 |
+
* after a successful connection probe.
|
| 462 |
+
*/
|
| 463 |
+
hideConfigurationFields?: boolean;
|
| 464 |
+
/**
|
| 465 |
+
* Replace the default synchronous add/update-and-close submit with a
|
| 466 |
+
* custom async handler. The form builds the payload, validates
|
| 467 |
+
* client-side, then hands it to this callback. If the callback throws,
|
| 468 |
+
* the form remains open so the caller can surface errors.
|
| 469 |
+
*/
|
| 470 |
+
onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise<void>;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/**
|
| 474 |
+
* Reusable form body for adding / editing a backend. Renders the
|
| 475 |
+
* common name / host / API-key inputs plus the kind selector
|
| 476 |
+
* (radio buttons in `add` mode, status badge in `edit` mode).
|
| 477 |
+
*
|
| 478 |
+
* Rendered as a `<form>`, so consumers should put any extra controls
|
| 479 |
+
* either inside `renderActions` or as siblings inside a wrapping
|
| 480 |
+
* element — but submission flows through the standard form submit so
|
| 481 |
+
* Enter-to-submit still works.
|
| 482 |
+
*/
|
| 483 |
+
export function BackendForm({
|
| 484 |
+
mode,
|
| 485 |
+
backend,
|
| 486 |
+
onSubmitted,
|
| 487 |
+
renderActions,
|
| 488 |
+
testIdRoot: explicitTestIdRoot,
|
| 489 |
+
hostReadOnly,
|
| 490 |
+
requireApiKey,
|
| 491 |
+
hideConfigurationFields = false,
|
| 492 |
+
onSubmitOverride,
|
| 493 |
+
}: BackendFormProps) {
|
| 494 |
+
const { t } = useTranslation("openhands");
|
| 495 |
+
const { addBackend, updateBackend } = useActiveBackendContext();
|
| 496 |
+
|
| 497 |
+
// In edit mode preserve the existing backend's kind so that renaming or
|
| 498 |
+
// rotating the API key on a cloud backend (e.g. an OHE/enterprise instance
|
| 499 |
+
// on a custom domain) does not silently downgrade it to "local" and switch
|
| 500 |
+
// the auth header from `Authorization: Bearer` to `X-Session-API-Key`.
|
| 501 |
+
// Only infer from the host when adding a new backend.
|
| 502 |
+
const fixedKind: BackendKind | null =
|
| 503 |
+
mode === "edit" && backend ? backend.kind : null;
|
| 504 |
+
|
| 505 |
+
const {
|
| 506 |
+
name,
|
| 507 |
+
setName,
|
| 508 |
+
host,
|
| 509 |
+
setHost,
|
| 510 |
+
apiKey,
|
| 511 |
+
setApiKey,
|
| 512 |
+
connectionError,
|
| 513 |
+
setConnectionError,
|
| 514 |
+
isSubmitting,
|
| 515 |
+
kind: inferredKind,
|
| 516 |
+
handleSubmit: runSubmit,
|
| 517 |
+
} = useBackendForm({
|
| 518 |
+
initialName: backend?.name ?? "",
|
| 519 |
+
initialHost: backend?.host ?? "",
|
| 520 |
+
initialApiKey: backend?.apiKey ?? "",
|
| 521 |
+
onTestConnection: testBackendConnection,
|
| 522 |
+
onSuccess: async () => {
|
| 523 |
+
const payload: BackendFormSubmitPayload = {
|
| 524 |
+
name: name.trim(),
|
| 525 |
+
host: normalizeHost(host),
|
| 526 |
+
apiKey: apiKey.trim(),
|
| 527 |
+
kind: fixedKind ?? inferredKind,
|
| 528 |
+
};
|
| 529 |
+
if (mode === "edit" && backend) {
|
| 530 |
+
updateBackend(backend.id, payload);
|
| 531 |
+
} else {
|
| 532 |
+
addBackend(payload);
|
| 533 |
+
}
|
| 534 |
+
onSubmitted();
|
| 535 |
+
},
|
| 536 |
+
requireApiKey,
|
| 537 |
+
onSubmitOverride,
|
| 538 |
+
});
|
| 539 |
+
|
| 540 |
+
// Inline validation: only show errors after the user has left a field.
|
| 541 |
+
const [nameTouched, setNameTouched] = React.useState(false);
|
| 542 |
+
const [hostTouched, setHostTouched] = React.useState(false);
|
| 543 |
+
|
| 544 |
+
const kind = fixedKind ?? inferredKind;
|
| 545 |
+
const testIdRoot =
|
| 546 |
+
explicitTestIdRoot ?? (mode === "edit" ? "edit-backend" : "add-backend");
|
| 547 |
+
|
| 548 |
+
const needsApiKey = requireApiKey || kind !== "local";
|
| 549 |
+
const canSubmit =
|
| 550 |
+
name.trim().length > 0 &&
|
| 551 |
+
isValidHostUrl(host) &&
|
| 552 |
+
(!needsApiKey || apiKey.trim().length > 0);
|
| 553 |
+
|
| 554 |
+
// Error messages — only surfaced after the user has blurred the field.
|
| 555 |
+
const nameError =
|
| 556 |
+
nameTouched && !name.trim() ? t(I18nKey.BACKEND$NAME_REQUIRED) : undefined;
|
| 557 |
+
const hostError = hostTouched
|
| 558 |
+
? !host.trim()
|
| 559 |
+
? t(I18nKey.BACKEND$HOST_REQUIRED)
|
| 560 |
+
: !isValidHostUrl(host)
|
| 561 |
+
? t(I18nKey.BACKEND$HOST_INVALID)
|
| 562 |
+
: undefined
|
| 563 |
+
: undefined;
|
| 564 |
+
|
| 565 |
+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
| 566 |
+
if (!canSubmit) {
|
| 567 |
+
// Mark all validated fields as touched so inline errors become visible
|
| 568 |
+
// (e.g. user pressed Enter before filling required fields).
|
| 569 |
+
setNameTouched(true);
|
| 570 |
+
setHostTouched(true);
|
| 571 |
+
return;
|
| 572 |
+
}
|
| 573 |
+
await runSubmit(event);
|
| 574 |
+
};
|
| 575 |
+
|
| 576 |
+
return (
|
| 577 |
+
<form
|
| 578 |
+
data-testid={`${testIdRoot}-form`}
|
| 579 |
+
onSubmit={handleSubmit}
|
| 580 |
+
className="flex flex-col gap-4"
|
| 581 |
+
>
|
| 582 |
+
<div
|
| 583 |
+
data-testid={`${testIdRoot}-configuration-fields`}
|
| 584 |
+
className={cn(
|
| 585 |
+
"flex flex-col gap-4",
|
| 586 |
+
hideConfigurationFields && "hidden",
|
| 587 |
+
)}
|
| 588 |
+
>
|
| 589 |
+
<SettingsInput
|
| 590 |
+
testId={`${testIdRoot}-name`}
|
| 591 |
+
name={`${testIdRoot}-name`}
|
| 592 |
+
type="text"
|
| 593 |
+
label={t(I18nKey.BACKEND$NAME_LABEL)}
|
| 594 |
+
value={name}
|
| 595 |
+
onChange={(value) => {
|
| 596 |
+
setName(value);
|
| 597 |
+
setConnectionError(null);
|
| 598 |
+
}}
|
| 599 |
+
onBlur={() => setNameTouched(true)}
|
| 600 |
+
// eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy
|
| 601 |
+
placeholder="Production"
|
| 602 |
+
className="w-full"
|
| 603 |
+
showRequiredTag
|
| 604 |
+
error={nameError}
|
| 605 |
+
/>
|
| 606 |
+
|
| 607 |
+
<SettingsInput
|
| 608 |
+
testId={`${testIdRoot}-host`}
|
| 609 |
+
name={`${testIdRoot}-host`}
|
| 610 |
+
type="text"
|
| 611 |
+
label={t(I18nKey.BACKEND$HOST_LABEL)}
|
| 612 |
+
value={host}
|
| 613 |
+
onChange={
|
| 614 |
+
hostReadOnly
|
| 615 |
+
? undefined
|
| 616 |
+
: (value) => {
|
| 617 |
+
setHost(value);
|
| 618 |
+
setConnectionError(null);
|
| 619 |
+
}
|
| 620 |
+
}
|
| 621 |
+
onBlur={() => setHostTouched(true)}
|
| 622 |
+
placeholder={DEFAULT_OPENHANDS_CLOUD_HOST}
|
| 623 |
+
className="w-full"
|
| 624 |
+
showRequiredTag
|
| 625 |
+
error={hostError}
|
| 626 |
+
isDisabled={hostReadOnly}
|
| 627 |
+
/>
|
| 628 |
+
|
| 629 |
+
<SettingsInput
|
| 630 |
+
testId={`${testIdRoot}-api-key`}
|
| 631 |
+
name={`${testIdRoot}-api-key`}
|
| 632 |
+
type="password"
|
| 633 |
+
label={t(I18nKey.BACKEND$KEY_LABEL)}
|
| 634 |
+
value={apiKey}
|
| 635 |
+
onChange={(value) => {
|
| 636 |
+
setApiKey(value);
|
| 637 |
+
setConnectionError(null);
|
| 638 |
+
}}
|
| 639 |
+
placeholder=""
|
| 640 |
+
className="w-full"
|
| 641 |
+
/>
|
| 642 |
+
|
| 643 |
+
{connectionError ? (
|
| 644 |
+
<div
|
| 645 |
+
role="alert"
|
| 646 |
+
data-testid={`${testIdRoot}-error`}
|
| 647 |
+
className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300 whitespace-pre-wrap break-words"
|
| 648 |
+
>
|
| 649 |
+
{connectionError}
|
| 650 |
+
</div>
|
| 651 |
+
) : null}
|
| 652 |
+
|
| 653 |
+
{mode === "edit" && backend && (
|
| 654 |
+
<BackendStatusBadge backend={backend} testIdRoot={testIdRoot} />
|
| 655 |
+
)}
|
| 656 |
+
</div>
|
| 657 |
+
|
| 658 |
+
{renderActions ? (
|
| 659 |
+
renderActions({
|
| 660 |
+
canSubmit: canSubmit && !isSubmitting,
|
| 661 |
+
isSubmitting,
|
| 662 |
+
testIdRoot,
|
| 663 |
+
})
|
| 664 |
+
) : (
|
| 665 |
+
<div className="flex justify-end gap-2 mt-2 w-full">
|
| 666 |
+
<BrandButton
|
| 667 |
+
type="button"
|
| 668 |
+
variant="secondary"
|
| 669 |
+
onClick={onSubmitted}
|
| 670 |
+
testId={`${testIdRoot}-cancel`}
|
| 671 |
+
>
|
| 672 |
+
{t(I18nKey.BUTTON$CANCEL)}
|
| 673 |
+
</BrandButton>
|
| 674 |
+
<BrandButton
|
| 675 |
+
type="submit"
|
| 676 |
+
variant="primary"
|
| 677 |
+
isDisabled={!canSubmit || isSubmitting}
|
| 678 |
+
testId={`${testIdRoot}-submit`}
|
| 679 |
+
>
|
| 680 |
+
{t(I18nKey.BACKEND$SAVE)}
|
| 681 |
+
</BrandButton>
|
| 682 |
+
</div>
|
| 683 |
+
)}
|
| 684 |
+
</form>
|
| 685 |
+
);
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
// ── Add-mode two-column layout ──────────────────────────────────────
|
| 689 |
+
|
| 690 |
+
/**
|
| 691 |
+
* @spec BM-002 — Adding a backend auto-switches the active selection to it
|
| 692 |
+
* (BM-001), so a backend-scoped detail page the user is viewing now belongs
|
| 693 |
+
* to the previous backend. Redirect to that section's list so they never see
|
| 694 |
+
* stale data, mirroring the switch-backend redirect in BackendSelector.
|
| 695 |
+
*/
|
| 696 |
+
function useRedirectAfterAddBackend() {
|
| 697 |
+
const { currentPath, navigate } = useNavigation();
|
| 698 |
+
return React.useCallback(() => {
|
| 699 |
+
if (/^\/automations\/[^/]+/.test(currentPath)) navigate("/automations");
|
| 700 |
+
else if (/^\/conversations\/[^/]+/.test(currentPath))
|
| 701 |
+
navigate("/conversations");
|
| 702 |
+
}, [currentPath, navigate]);
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
interface BackendConnectionOptionsProps {
|
| 706 |
+
onConnected: (
|
| 707 |
+
payload: BackendFormSubmitPayload,
|
| 708 |
+
connectionMethod: BackendConnectionMethod,
|
| 709 |
+
metadata?: BackendConnectionTestMetadata,
|
| 710 |
+
) => void;
|
| 711 |
+
testIdRoot?: string;
|
| 712 |
+
initialManualBackend?: Partial<
|
| 713 |
+
Pick<BackendFormSubmitPayload, "name" | "host" | "apiKey">
|
| 714 |
+
>;
|
| 715 |
+
requireManualApiKey?: boolean;
|
| 716 |
+
manualSubmitLabel?: React.ReactNode;
|
| 717 |
+
manualSubmittingLabel?: React.ReactNode;
|
| 718 |
+
manualSubmitTestId?: string;
|
| 719 |
+
analyticsSource?: CloudConnectionSource;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
/**
|
| 723 |
+
* Manual agent-server connection plus OpenHands Cloud OAuth login.
|
| 724 |
+
* Used by both the Add Backend modal and the onboarding backend step so
|
| 725 |
+
* supported backend choices stay consistent across first-run and settings UI.
|
| 726 |
+
*/
|
| 727 |
+
export function BackendConnectionOptions({
|
| 728 |
+
onConnected,
|
| 729 |
+
testIdRoot = "add-backend",
|
| 730 |
+
initialManualBackend,
|
| 731 |
+
requireManualApiKey = false,
|
| 732 |
+
manualSubmitLabel,
|
| 733 |
+
manualSubmittingLabel,
|
| 734 |
+
manualSubmitTestId,
|
| 735 |
+
analyticsSource,
|
| 736 |
+
}: BackendConnectionOptionsProps) {
|
| 737 |
+
const { t } = useTranslation("openhands");
|
| 738 |
+
const lockedCloudHost = getLockedCloudHost();
|
| 739 |
+
|
| 740 |
+
if (lockedCloudHost) {
|
| 741 |
+
return (
|
| 742 |
+
<div
|
| 743 |
+
data-testid={`${testIdRoot}-connection-options`}
|
| 744 |
+
className="flex justify-center"
|
| 745 |
+
>
|
| 746 |
+
<div className="w-full">
|
| 747 |
+
<CloudLoginColumn
|
| 748 |
+
onConnected={onConnected}
|
| 749 |
+
testIdRoot={testIdRoot}
|
| 750 |
+
lockedHost={lockedCloudHost}
|
| 751 |
+
analyticsSource={analyticsSource}
|
| 752 |
+
/>
|
| 753 |
+
</div>
|
| 754 |
+
</div>
|
| 755 |
+
);
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
return (
|
| 759 |
+
<div
|
| 760 |
+
data-testid={`${testIdRoot}-connection-options`}
|
| 761 |
+
className="flex flex-col gap-6 md:flex-row"
|
| 762 |
+
>
|
| 763 |
+
<div className="flex-1 min-w-0">
|
| 764 |
+
<ManualConnectionColumn
|
| 765 |
+
onConnected={onConnected}
|
| 766 |
+
testIdRoot={testIdRoot}
|
| 767 |
+
initialBackend={initialManualBackend}
|
| 768 |
+
requireApiKey={requireManualApiKey}
|
| 769 |
+
submitLabel={manualSubmitLabel ?? t(I18nKey.BACKEND$CONNECT)}
|
| 770 |
+
submittingLabel={
|
| 771 |
+
manualSubmittingLabel ??
|
| 772 |
+
t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING)
|
| 773 |
+
}
|
| 774 |
+
submitTestId={manualSubmitTestId}
|
| 775 |
+
/>
|
| 776 |
+
</div>
|
| 777 |
+
|
| 778 |
+
<div className="flex-1 min-w-0">
|
| 779 |
+
<CloudLoginColumn
|
| 780 |
+
onConnected={onConnected}
|
| 781 |
+
testIdRoot={testIdRoot}
|
| 782 |
+
analyticsSource={analyticsSource}
|
| 783 |
+
/>
|
| 784 |
+
</div>
|
| 785 |
+
</div>
|
| 786 |
+
);
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
interface ManualConnectionColumnProps {
|
| 790 |
+
onConnected: (
|
| 791 |
+
payload: BackendFormSubmitPayload,
|
| 792 |
+
connectionMethod: BackendConnectionMethod,
|
| 793 |
+
metadata?: BackendConnectionTestMetadata,
|
| 794 |
+
) => void;
|
| 795 |
+
testIdRoot: string;
|
| 796 |
+
initialBackend?: Partial<
|
| 797 |
+
Pick<BackendFormSubmitPayload, "name" | "host" | "apiKey">
|
| 798 |
+
>;
|
| 799 |
+
requireApiKey: boolean;
|
| 800 |
+
submitLabel: React.ReactNode;
|
| 801 |
+
submittingLabel: React.ReactNode;
|
| 802 |
+
submitTestId?: string;
|
| 803 |
+
fixedKind?: BackendKind;
|
| 804 |
+
showKindSelector?: boolean;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
/**
|
| 808 |
+
* Manual connection via Host + API Key. Designed for self-hosted agent servers
|
| 809 |
+
* and self-hosted OpenHands Cloud with API key auth.
|
| 810 |
+
*/
|
| 811 |
+
function ManualConnectionColumn({
|
| 812 |
+
onConnected,
|
| 813 |
+
testIdRoot,
|
| 814 |
+
initialBackend,
|
| 815 |
+
requireApiKey,
|
| 816 |
+
submitLabel,
|
| 817 |
+
submittingLabel,
|
| 818 |
+
submitTestId,
|
| 819 |
+
fixedKind,
|
| 820 |
+
showKindSelector = true,
|
| 821 |
+
}: ManualConnectionColumnProps) {
|
| 822 |
+
const { t } = useTranslation("openhands");
|
| 823 |
+
|
| 824 |
+
const {
|
| 825 |
+
name,
|
| 826 |
+
setName,
|
| 827 |
+
host,
|
| 828 |
+
setHost,
|
| 829 |
+
apiKey,
|
| 830 |
+
setApiKey,
|
| 831 |
+
connectionError,
|
| 832 |
+
setConnectionError,
|
| 833 |
+
isSubmitting,
|
| 834 |
+
kind,
|
| 835 |
+
setKind,
|
| 836 |
+
canSubmit,
|
| 837 |
+
handleSubmit,
|
| 838 |
+
} = useBackendForm({
|
| 839 |
+
initialName: initialBackend?.name ?? "",
|
| 840 |
+
initialHost: initialBackend?.host ?? "",
|
| 841 |
+
initialApiKey: initialBackend?.apiKey ?? "",
|
| 842 |
+
onTestConnection: testBackendConnection,
|
| 843 |
+
onSuccess: (metadata) => {
|
| 844 |
+
onConnected(
|
| 845 |
+
{
|
| 846 |
+
name: name.trim(),
|
| 847 |
+
host: normalizeHost(host),
|
| 848 |
+
apiKey: apiKey.trim(),
|
| 849 |
+
kind,
|
| 850 |
+
},
|
| 851 |
+
"manual",
|
| 852 |
+
metadata,
|
| 853 |
+
);
|
| 854 |
+
},
|
| 855 |
+
requireApiKey,
|
| 856 |
+
fixedKind,
|
| 857 |
+
});
|
| 858 |
+
|
| 859 |
+
return (
|
| 860 |
+
<form
|
| 861 |
+
data-testid={`${testIdRoot}-form`}
|
| 862 |
+
onSubmit={handleSubmit}
|
| 863 |
+
className="flex flex-col gap-4 flex-1 min-w-0"
|
| 864 |
+
>
|
| 865 |
+
<SettingsInput
|
| 866 |
+
testId={`${testIdRoot}-name`}
|
| 867 |
+
name={`${testIdRoot}-name`}
|
| 868 |
+
type="text"
|
| 869 |
+
label={t(I18nKey.BACKEND$NAME_LABEL)}
|
| 870 |
+
hint={t(I18nKey.BACKEND$NAME_HELPER)}
|
| 871 |
+
value={name}
|
| 872 |
+
onChange={(value) => {
|
| 873 |
+
setName(value);
|
| 874 |
+
setConnectionError(null);
|
| 875 |
+
}}
|
| 876 |
+
// eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy
|
| 877 |
+
placeholder="e.g. My Server"
|
| 878 |
+
className="w-full"
|
| 879 |
+
/>
|
| 880 |
+
|
| 881 |
+
<SettingsInput
|
| 882 |
+
testId={`${testIdRoot}-host`}
|
| 883 |
+
name={`${testIdRoot}-host`}
|
| 884 |
+
type="text"
|
| 885 |
+
label={t(I18nKey.BACKEND$HOST_LABEL)}
|
| 886 |
+
hint={t(I18nKey.BACKEND$HOST_HELPER)}
|
| 887 |
+
value={host}
|
| 888 |
+
onChange={(value) => {
|
| 889 |
+
setHost(value);
|
| 890 |
+
setConnectionError(null);
|
| 891 |
+
}}
|
| 892 |
+
// eslint-disable-next-line i18next/no-literal-string -- example value, not translatable
|
| 893 |
+
placeholder="http://localhost:8000"
|
| 894 |
+
className="w-full"
|
| 895 |
+
/>
|
| 896 |
+
|
| 897 |
+
{showKindSelector ? (
|
| 898 |
+
<div className="flex flex-col items-start gap-2.5">
|
| 899 |
+
<span className="text-sm">{t(I18nKey.BACKEND$KIND_LABEL)}</span>
|
| 900 |
+
<SegmentedToggle<BackendKind>
|
| 901 |
+
value={kind}
|
| 902 |
+
options={[
|
| 903 |
+
{ value: "local", label: t(I18nKey.BACKEND$KIND_LOCAL) },
|
| 904 |
+
{ value: "cloud", label: t(I18nKey.BACKEND$KIND_CLOUD) },
|
| 905 |
+
]}
|
| 906 |
+
onChange={(value) => setKind(value)}
|
| 907 |
+
ariaLabel={t(I18nKey.BACKEND$KIND_LABEL)}
|
| 908 |
+
testId={`${testIdRoot}-kind`}
|
| 909 |
+
/>
|
| 910 |
+
</div>
|
| 911 |
+
) : null}
|
| 912 |
+
|
| 913 |
+
<SettingsInput
|
| 914 |
+
testId={`${testIdRoot}-api-key`}
|
| 915 |
+
name={`${testIdRoot}-api-key`}
|
| 916 |
+
type="password"
|
| 917 |
+
label={t(I18nKey.BACKEND$KEY_LABEL)}
|
| 918 |
+
value={apiKey}
|
| 919 |
+
onChange={(value) => {
|
| 920 |
+
setApiKey(value);
|
| 921 |
+
setConnectionError(null);
|
| 922 |
+
}}
|
| 923 |
+
// eslint-disable-next-line i18next/no-literal-string -- example value, not translatable
|
| 924 |
+
placeholder="sk-••••••••••"
|
| 925 |
+
className="w-full"
|
| 926 |
+
/>
|
| 927 |
+
|
| 928 |
+
{connectionError ? (
|
| 929 |
+
<div
|
| 930 |
+
role="alert"
|
| 931 |
+
data-testid={`${testIdRoot}-error`}
|
| 932 |
+
className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300 whitespace-pre-wrap break-words"
|
| 933 |
+
>
|
| 934 |
+
{connectionError}
|
| 935 |
+
</div>
|
| 936 |
+
) : null}
|
| 937 |
+
|
| 938 |
+
<BrandButton
|
| 939 |
+
type="submit"
|
| 940 |
+
variant="secondary"
|
| 941 |
+
isDisabled={!canSubmit || isSubmitting}
|
| 942 |
+
testId={submitTestId ?? `${testIdRoot}-submit`}
|
| 943 |
+
className="w-full text-center"
|
| 944 |
+
>
|
| 945 |
+
{isSubmitting ? submittingLabel : submitLabel}
|
| 946 |
+
</BrandButton>
|
| 947 |
+
</form>
|
| 948 |
+
);
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
interface CloudLoginColumnProps {
|
| 952 |
+
onConnected: (
|
| 953 |
+
payload: BackendFormSubmitPayload,
|
| 954 |
+
connectionMethod: BackendConnectionMethod,
|
| 955 |
+
metadata?: BackendConnectionTestMetadata,
|
| 956 |
+
) => void;
|
| 957 |
+
testIdRoot: string;
|
| 958 |
+
lockedHost?: string;
|
| 959 |
+
analyticsSource?: CloudConnectionSource;
|
| 960 |
+
/** Omit repeated branding when a surrounding chooser already names Cloud. */
|
| 961 |
+
showBranding?: boolean;
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
/**
|
| 965 |
+
* One-click OAuth login with OpenHands Cloud. Includes an "Advanced"
|
| 966 |
+
* disclosure for users who self-host OpenHands Cloud and need to override the
|
| 967 |
+
* host.
|
| 968 |
+
*/
|
| 969 |
+
function CloudLoginColumn({
|
| 970 |
+
onConnected,
|
| 971 |
+
testIdRoot,
|
| 972 |
+
lockedHost,
|
| 973 |
+
analyticsSource,
|
| 974 |
+
showBranding = true,
|
| 975 |
+
}: CloudLoginColumnProps) {
|
| 976 |
+
const { t } = useTranslation("openhands");
|
| 977 |
+
|
| 978 |
+
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
| 979 |
+
const [customHost, setCustomHost] = React.useState("");
|
| 980 |
+
const advancedPanelId = `${testIdRoot}-advanced-panel`;
|
| 981 |
+
|
| 982 |
+
const effectiveHost =
|
| 983 |
+
lockedHost ?? (customHost.trim() || DEFAULT_OPENHANDS_CLOUD_HOST);
|
| 984 |
+
|
| 985 |
+
const handleLoginSuccess = (apiKey: string) => {
|
| 986 |
+
onConnected(
|
| 987 |
+
{
|
| 988 |
+
name: "OpenHands Cloud",
|
| 989 |
+
host: normalizeHost(effectiveHost),
|
| 990 |
+
apiKey,
|
| 991 |
+
kind: "cloud",
|
| 992 |
+
},
|
| 993 |
+
"cloud_login",
|
| 994 |
+
);
|
| 995 |
+
};
|
| 996 |
+
|
| 997 |
+
return (
|
| 998 |
+
<div className="flex w-full min-w-0 flex-col items-center gap-3">
|
| 999 |
+
{showBranding ? (
|
| 1000 |
+
<div className="flex flex-col items-center gap-1">
|
| 1001 |
+
<OpenHandsLogoWhite width={56} height={56} aria-hidden />
|
| 1002 |
+
|
| 1003 |
+
<h4
|
| 1004 |
+
className={modalTitleLgMediumClassName}
|
| 1005 |
+
data-testid={`${testIdRoot}-cloud-title`}
|
| 1006 |
+
>
|
| 1007 |
+
{t(I18nKey.BACKEND$CLOUD_TITLE)}
|
| 1008 |
+
</h4>
|
| 1009 |
+
</div>
|
| 1010 |
+
) : null}
|
| 1011 |
+
|
| 1012 |
+
<DeviceFlowAuth
|
| 1013 |
+
host={effectiveHost}
|
| 1014 |
+
onSuccess={handleLoginSuccess}
|
| 1015 |
+
testIdRoot={testIdRoot}
|
| 1016 |
+
analyticsSource={analyticsSource}
|
| 1017 |
+
className="w-full items-center"
|
| 1018 |
+
idleDescription={
|
| 1019 |
+
<p
|
| 1020 |
+
className="text-center text-sm leading-relaxed text-[var(--oh-muted)]"
|
| 1021 |
+
data-testid={`${testIdRoot}-cloud-description`}
|
| 1022 |
+
>
|
| 1023 |
+
{t(I18nKey.BACKEND$CLOUD_DESCRIPTION)}
|
| 1024 |
+
</p>
|
| 1025 |
+
}
|
| 1026 |
+
// Host overriding only matters before the flow starts, so it rides the
|
| 1027 |
+
// same idle-only slot as the description instead of sitting under the
|
| 1028 |
+
// authorization status.
|
| 1029 |
+
idleFooter={
|
| 1030 |
+
lockedHost ? null : (
|
| 1031 |
+
// Keep the Advanced disclosure at the original compact width even
|
| 1032 |
+
// though the surrounding cloud panel spans the full chooser.
|
| 1033 |
+
<div className="mx-auto w-full max-w-md">
|
| 1034 |
+
<button
|
| 1035 |
+
type="button"
|
| 1036 |
+
onClick={() => setAdvancedOpen((open) => !open)}
|
| 1037 |
+
aria-expanded={advancedOpen}
|
| 1038 |
+
aria-controls={advancedPanelId}
|
| 1039 |
+
data-testid={`${testIdRoot}-advanced-toggle`}
|
| 1040 |
+
className="flex w-full cursor-pointer items-center justify-center gap-1 text-center text-xs text-[var(--oh-muted)] transition-colors hover:text-content-2"
|
| 1041 |
+
>
|
| 1042 |
+
<span>{t(I18nKey.BACKEND$ADVANCED)}</span>
|
| 1043 |
+
<ChevronDownSmallIcon
|
| 1044 |
+
className={cn(
|
| 1045 |
+
"h-4 w-4 shrink-0 text-muted transition-transform duration-200 ease-out",
|
| 1046 |
+
advancedOpen && "rotate-180",
|
| 1047 |
+
)}
|
| 1048 |
+
aria-hidden
|
| 1049 |
+
/>
|
| 1050 |
+
</button>
|
| 1051 |
+
{/* Height animates through `grid-template-rows` (0fr ↔ 1fr) so no
|
| 1052 |
+
max-height guess is needed; the inner clip is what makes `0fr`
|
| 1053 |
+
collapse. Content stays mounted so a typed host survives, and
|
| 1054 |
+
`inert` keeps the collapsed field out of the tab order. */}
|
| 1055 |
+
<div
|
| 1056 |
+
className={cn(
|
| 1057 |
+
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
|
| 1058 |
+
advancedOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
| 1059 |
+
)}
|
| 1060 |
+
>
|
| 1061 |
+
<div className="overflow-hidden">
|
| 1062 |
+
<div
|
| 1063 |
+
id={advancedPanelId}
|
| 1064 |
+
data-testid={`${testIdRoot}-advanced-panel`}
|
| 1065 |
+
aria-hidden={!advancedOpen}
|
| 1066 |
+
inert={!advancedOpen ? true : undefined}
|
| 1067 |
+
className={cn(
|
| 1068 |
+
"pt-3 transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
| 1069 |
+
advancedOpen ? "opacity-100" : "opacity-0",
|
| 1070 |
+
)}
|
| 1071 |
+
>
|
| 1072 |
+
<SettingsInput
|
| 1073 |
+
testId={`${testIdRoot}-cloud-host`}
|
| 1074 |
+
name={`${testIdRoot}-cloud-host`}
|
| 1075 |
+
type="text"
|
| 1076 |
+
label={t(I18nKey.BACKEND$HOST_LABEL)}
|
| 1077 |
+
value={customHost}
|
| 1078 |
+
onChange={setCustomHost}
|
| 1079 |
+
placeholder={DEFAULT_OPENHANDS_CLOUD_HOST}
|
| 1080 |
+
className="w-full"
|
| 1081 |
+
/>
|
| 1082 |
+
<p className="mt-1 text-xs text-[var(--oh-muted)]">
|
| 1083 |
+
{t(I18nKey.BACKEND$LOGIN_CLOUD_HINT)}
|
| 1084 |
+
</p>
|
| 1085 |
+
</div>
|
| 1086 |
+
</div>
|
| 1087 |
+
</div>
|
| 1088 |
+
</div>
|
| 1089 |
+
)
|
| 1090 |
+
}
|
| 1091 |
+
/>
|
| 1092 |
+
</div>
|
| 1093 |
+
);
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
interface BackendOptionTabProps {
|
| 1097 |
+
value: AddBackendOption;
|
| 1098 |
+
selectedValue: AddBackendOption;
|
| 1099 |
+
title: string;
|
| 1100 |
+
description: string;
|
| 1101 |
+
icon: React.ReactNode;
|
| 1102 |
+
onSelect: (value: AddBackendOption) => void;
|
| 1103 |
+
panelId: string;
|
| 1104 |
+
testId: string;
|
| 1105 |
+
}
|
| 1106 |
+
|
| 1107 |
+
/**
|
| 1108 |
+
* Presents a connection-method tab: icon, title, and a one-line subtitle.
|
| 1109 |
+
*
|
| 1110 |
+
* The shared tablist owns the outer border, so these buttons meet cleanly at
|
| 1111 |
+
* the center. Selection uses a bottom bar instead of recoloring the full
|
| 1112 |
+
* outline, preserving the group as one visual control.
|
| 1113 |
+
*/
|
| 1114 |
+
function BackendOptionTab({
|
| 1115 |
+
value,
|
| 1116 |
+
selectedValue,
|
| 1117 |
+
title,
|
| 1118 |
+
description,
|
| 1119 |
+
icon,
|
| 1120 |
+
onSelect,
|
| 1121 |
+
panelId,
|
| 1122 |
+
testId,
|
| 1123 |
+
}: BackendOptionTabProps) {
|
| 1124 |
+
const isSelected = value === selectedValue;
|
| 1125 |
+
const tabId = `${testId}-tab`;
|
| 1126 |
+
|
| 1127 |
+
return (
|
| 1128 |
+
<button
|
| 1129 |
+
id={tabId}
|
| 1130 |
+
type="button"
|
| 1131 |
+
role="tab"
|
| 1132 |
+
aria-selected={isSelected}
|
| 1133 |
+
aria-controls={panelId}
|
| 1134 |
+
data-testid={testId}
|
| 1135 |
+
onClick={() => onSelect(value)}
|
| 1136 |
+
className={cn(
|
| 1137 |
+
"relative flex min-h-16 w-full cursor-pointer items-center gap-3 px-3 py-3 text-left transition-colors",
|
| 1138 |
+
"first:border-r first:border-r-[var(--oh-border)]",
|
| 1139 |
+
"focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-300",
|
| 1140 |
+
isSelected
|
| 1141 |
+
? "bg-[var(--oh-surface-raised)] text-white after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-primary"
|
| 1142 |
+
: "text-[var(--oh-muted)] hover:bg-[var(--oh-surface-raised)] hover:text-white",
|
| 1143 |
+
)}
|
| 1144 |
+
>
|
| 1145 |
+
<span
|
| 1146 |
+
className="flex size-8 shrink-0 items-center justify-center"
|
| 1147 |
+
aria-hidden
|
| 1148 |
+
>
|
| 1149 |
+
{icon}
|
| 1150 |
+
</span>
|
| 1151 |
+
<span className="min-w-0 flex-1">
|
| 1152 |
+
<span className="block truncate text-sm font-medium">{title}</span>
|
| 1153 |
+
<span className="mt-0.5 block text-xs leading-tight text-[var(--oh-muted)]">
|
| 1154 |
+
{description}
|
| 1155 |
+
</span>
|
| 1156 |
+
</span>
|
| 1157 |
+
</button>
|
| 1158 |
+
);
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
interface AnimatedPanelHeightProps {
|
| 1162 |
+
children: React.ReactNode;
|
| 1163 |
+
}
|
| 1164 |
+
|
| 1165 |
+
/**
|
| 1166 |
+
* Animates a panel between content-driven heights.
|
| 1167 |
+
*
|
| 1168 |
+
* A ResizeObserver keeps the wrapper synchronized as tabs or nested
|
| 1169 |
+
* disclosures change. The transition class remains mounted before the
|
| 1170 |
+
* observer publishes a new height; adding it in the same render as the height
|
| 1171 |
+
* would give the browser no previous painted value to interpolate from.
|
| 1172 |
+
*/
|
| 1173 |
+
function AnimatedPanelHeight({ children }: AnimatedPanelHeightProps) {
|
| 1174 |
+
const contentRef = React.useRef<HTMLDivElement>(null);
|
| 1175 |
+
const [height, setHeight] = React.useState<number>();
|
| 1176 |
+
|
| 1177 |
+
React.useLayoutEffect(() => {
|
| 1178 |
+
const content = contentRef.current;
|
| 1179 |
+
if (!content) return undefined;
|
| 1180 |
+
|
| 1181 |
+
const measure = () => {
|
| 1182 |
+
const nextHeight = content.getBoundingClientRect().height;
|
| 1183 |
+
// jsdom reports zero-sized layout boxes; leaving height automatic there
|
| 1184 |
+
// keeps component tests representative without changing browser behavior.
|
| 1185 |
+
setHeight(nextHeight > 0 ? nextHeight : undefined);
|
| 1186 |
+
};
|
| 1187 |
+
|
| 1188 |
+
measure();
|
| 1189 |
+
if (typeof ResizeObserver === "undefined") return undefined;
|
| 1190 |
+
|
| 1191 |
+
const observer = new ResizeObserver(measure);
|
| 1192 |
+
observer.observe(content);
|
| 1193 |
+
return () => observer.disconnect();
|
| 1194 |
+
}, []);
|
| 1195 |
+
|
| 1196 |
+
return (
|
| 1197 |
+
<div
|
| 1198 |
+
data-testid="add-backend-panel-height"
|
| 1199 |
+
style={height === undefined ? undefined : { height }}
|
| 1200 |
+
className="overflow-hidden transition-[height] duration-300 ease-in-out motion-reduce:transition-none"
|
| 1201 |
+
>
|
| 1202 |
+
<div ref={contentRef}>{children}</div>
|
| 1203 |
+
</div>
|
| 1204 |
+
);
|
| 1205 |
+
}
|
| 1206 |
+
|
| 1207 |
+
/**
|
| 1208 |
+
* Collects every setup instruction for the selected location into one
|
| 1209 |
+
* collapsible note.
|
| 1210 |
+
*
|
| 1211 |
+
* All of the guidance lives here — the intro, the example command (local) or
|
| 1212 |
+
* the host formats to use with a tunnel (remote), and the docs link — so users
|
| 1213 |
+
* never have to leave the modal to find out what belongs in the host field.
|
| 1214 |
+
* It starts collapsed so the connection form remains compact; users can
|
| 1215 |
+
* reveal the complete instructions without leaving the modal.
|
| 1216 |
+
*
|
| 1217 |
+
* Open/close uses a `grid-template-rows` transition (`0fr` ↔ `1fr`) so the
|
| 1218 |
+
* height animates without a fixed `max-height` guess. Content stays mounted
|
| 1219 |
+
* so the toggle does not remount the docs link on every expand.
|
| 1220 |
+
*/
|
| 1221 |
+
function AgentServerGuidance({ location }: { location: AgentServerLocation }) {
|
| 1222 |
+
const { t } = useTranslation("openhands");
|
| 1223 |
+
const [isExpanded, setIsExpanded] = React.useState(false);
|
| 1224 |
+
const isRemote = location === "remote";
|
| 1225 |
+
const title = isRemote
|
| 1226 |
+
? t(I18nKey.BACKEND$REMOTE_SETUP_TITLE)
|
| 1227 |
+
: t(I18nKey.BACKEND$BEFORE_CONNECT_TITLE);
|
| 1228 |
+
const description = isRemote
|
| 1229 |
+
? t(I18nKey.BACKEND$REMOTE_SETUP_DESCRIPTION)
|
| 1230 |
+
: t(I18nKey.BACKEND$LOCAL_SETUP_DESCRIPTION);
|
| 1231 |
+
const docsHref = isRemote
|
| 1232 |
+
? REMOTE_AGENT_SERVER_DOCS_URL
|
| 1233 |
+
: LOCAL_AGENT_SERVER_DOCS_URL;
|
| 1234 |
+
const docsLabel = isRemote
|
| 1235 |
+
? t(I18nKey.BACKEND$REMOTE_SETUP_DOCS)
|
| 1236 |
+
: t(I18nKey.BACKEND$LOCAL_SETUP_DOCS);
|
| 1237 |
+
const testIdRoot = isRemote ? "add-backend-remote" : "add-backend-local";
|
| 1238 |
+
const toggleId = `${testIdRoot}-guidance-toggle`;
|
| 1239 |
+
const bodyId = `${testIdRoot}-guidance-body`;
|
| 1240 |
+
|
| 1241 |
+
return (
|
| 1242 |
+
<aside
|
| 1243 |
+
data-testid={`${testIdRoot}-guidance`}
|
| 1244 |
+
className="rounded-lg bg-[var(--oh-surface-raised)] text-sm text-[var(--oh-muted)]"
|
| 1245 |
+
>
|
| 1246 |
+
{/* Heading wraps the button so the accordion keeps a real heading in the
|
| 1247 |
+
document outline while the whole row stays clickable. */}
|
| 1248 |
+
<h4 className="text-white">
|
| 1249 |
+
<button
|
| 1250 |
+
id={toggleId}
|
| 1251 |
+
type="button"
|
| 1252 |
+
onClick={() => setIsExpanded((expanded) => !expanded)}
|
| 1253 |
+
aria-expanded={isExpanded}
|
| 1254 |
+
aria-controls={bodyId}
|
| 1255 |
+
data-testid={toggleId}
|
| 1256 |
+
className={cn(
|
| 1257 |
+
// Padding lives on the button rather than the card so the hover
|
| 1258 |
+
// fill spans the full row.
|
| 1259 |
+
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-2 text-left font-medium",
|
| 1260 |
+
"transition-colors hover:bg-[var(--oh-interactive-hover)]",
|
| 1261 |
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-300",
|
| 1262 |
+
)}
|
| 1263 |
+
>
|
| 1264 |
+
<span className="flex min-w-0 items-center gap-2">
|
| 1265 |
+
<Info
|
| 1266 |
+
className="size-4 shrink-0 text-[var(--oh-muted)]"
|
| 1267 |
+
aria-hidden
|
| 1268 |
+
/>
|
| 1269 |
+
<span className="truncate">{title}</span>
|
| 1270 |
+
</span>
|
| 1271 |
+
<ChevronDown
|
| 1272 |
+
className={cn(
|
| 1273 |
+
"size-5 shrink-0 text-[var(--oh-muted)] transition-transform duration-200 ease-out",
|
| 1274 |
+
isExpanded && "rotate-180",
|
| 1275 |
+
)}
|
| 1276 |
+
aria-hidden
|
| 1277 |
+
/>
|
| 1278 |
+
</button>
|
| 1279 |
+
</h4>
|
| 1280 |
+
|
| 1281 |
+
{/* Outer grid owns the height animation; the inner overflow clip is
|
| 1282 |
+
required so `1fr` measures the content while `0fr` fully collapses. */}
|
| 1283 |
+
<div
|
| 1284 |
+
className={cn(
|
| 1285 |
+
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
|
| 1286 |
+
isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
| 1287 |
+
)}
|
| 1288 |
+
>
|
| 1289 |
+
<div className="overflow-hidden">
|
| 1290 |
+
<div
|
| 1291 |
+
id={bodyId}
|
| 1292 |
+
data-testid={bodyId}
|
| 1293 |
+
role="region"
|
| 1294 |
+
aria-labelledby={toggleId}
|
| 1295 |
+
aria-hidden={!isExpanded}
|
| 1296 |
+
inert={!isExpanded ? true : undefined}
|
| 1297 |
+
className={cn(
|
| 1298 |
+
"flex flex-col gap-2 px-3 pb-3 transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
| 1299 |
+
isExpanded ? "opacity-100" : "opacity-0",
|
| 1300 |
+
)}
|
| 1301 |
+
>
|
| 1302 |
+
<p className="leading-5">{description}</p>
|
| 1303 |
+
|
| 1304 |
+
{isRemote ? (
|
| 1305 |
+
<div>
|
| 1306 |
+
<h5 className="font-medium text-white">
|
| 1307 |
+
{t(I18nKey.BACKEND$REMOTE_CONNECTION_TITLE)}
|
| 1308 |
+
</h5>
|
| 1309 |
+
<p className="mt-1 leading-5">
|
| 1310 |
+
{t(I18nKey.BACKEND$REMOTE_CONNECTION_DESCRIPTION)}
|
| 1311 |
+
</p>
|
| 1312 |
+
</div>
|
| 1313 |
+
) : (
|
| 1314 |
+
<code className="block break-words font-mono text-xs text-white">
|
| 1315 |
+
{LOCAL_BACKEND_COMMAND}
|
| 1316 |
+
</code>
|
| 1317 |
+
)}
|
| 1318 |
+
|
| 1319 |
+
<a
|
| 1320 |
+
href={docsHref}
|
| 1321 |
+
target="_blank"
|
| 1322 |
+
rel="noopener noreferrer"
|
| 1323 |
+
data-testid={`${testIdRoot}-docs-link`}
|
| 1324 |
+
tabIndex={isExpanded ? undefined : -1}
|
| 1325 |
+
className="inline-flex w-fit items-center gap-1.5 text-primary hover:underline"
|
| 1326 |
+
>
|
| 1327 |
+
<span>{docsLabel}</span>
|
| 1328 |
+
<ExternalLinkIcon className="size-4 shrink-0" aria-hidden />
|
| 1329 |
+
</a>
|
| 1330 |
+
</div>
|
| 1331 |
+
</div>
|
| 1332 |
+
</div>
|
| 1333 |
+
</aside>
|
| 1334 |
+
);
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
function AddBackendChooser({
|
| 1338 |
+
onConnected,
|
| 1339 |
+
source,
|
| 1340 |
+
}: {
|
| 1341 |
+
onConnected: (
|
| 1342 |
+
payload: BackendFormSubmitPayload,
|
| 1343 |
+
connectionMethod: BackendConnectionMethod,
|
| 1344 |
+
metadata?: BackendConnectionTestMetadata,
|
| 1345 |
+
) => void;
|
| 1346 |
+
source: BackendAddedSource;
|
| 1347 |
+
}) {
|
| 1348 |
+
const { t } = useTranslation("openhands");
|
| 1349 |
+
const [selectedOption, setSelectedOption] =
|
| 1350 |
+
React.useState<AddBackendOption>("cloud");
|
| 1351 |
+
const [agentServerLocation, setAgentServerLocation] =
|
| 1352 |
+
React.useState<AgentServerLocation>("local");
|
| 1353 |
+
const panelId = "add-backend-selected-panel";
|
| 1354 |
+
const selectedTabId = `add-backend-option-${selectedOption}-tab`;
|
| 1355 |
+
const isCloudSelected = selectedOption === "cloud";
|
| 1356 |
+
|
| 1357 |
+
return (
|
| 1358 |
+
<div data-testid="add-backend-chooser" className="flex flex-col">
|
| 1359 |
+
<div
|
| 1360 |
+
role="tablist"
|
| 1361 |
+
aria-label={t(I18nKey.BACKEND$CHOOSER_TITLE)}
|
| 1362 |
+
className="grid grid-cols-2 overflow-hidden rounded-lg border border-[var(--oh-border)]"
|
| 1363 |
+
>
|
| 1364 |
+
<BackendOptionTab
|
| 1365 |
+
value="cloud"
|
| 1366 |
+
selectedValue={selectedOption}
|
| 1367 |
+
title={t(I18nKey.BACKEND$CLOUD_TITLE)}
|
| 1368 |
+
description={t(I18nKey.BACKEND$CLOUD_OPTION_DESCRIPTION)}
|
| 1369 |
+
icon={
|
| 1370 |
+
<OpenHandsLogoWhite
|
| 1371 |
+
width={32}
|
| 1372 |
+
height={32}
|
| 1373 |
+
data-testid="add-backend-option-cloud-logo"
|
| 1374 |
+
/>
|
| 1375 |
+
}
|
| 1376 |
+
onSelect={setSelectedOption}
|
| 1377 |
+
panelId={panelId}
|
| 1378 |
+
testId="add-backend-option-cloud"
|
| 1379 |
+
/>
|
| 1380 |
+
<BackendOptionTab
|
| 1381 |
+
value="agent-server"
|
| 1382 |
+
selectedValue={selectedOption}
|
| 1383 |
+
title={t(I18nKey.BACKEND$AGENT_SERVER_TITLE)}
|
| 1384 |
+
description={t(I18nKey.BACKEND$AGENT_SERVER_OPTION_DESCRIPTION)}
|
| 1385 |
+
icon={<ServerIcon className="size-6" />}
|
| 1386 |
+
onSelect={setSelectedOption}
|
| 1387 |
+
panelId={panelId}
|
| 1388 |
+
testId="add-backend-option-agent-server"
|
| 1389 |
+
/>
|
| 1390 |
+
</div>
|
| 1391 |
+
|
| 1392 |
+
<div className="mt-6">
|
| 1393 |
+
<AnimatedPanelHeight>
|
| 1394 |
+
<section
|
| 1395 |
+
id={panelId}
|
| 1396 |
+
role="tabpanel"
|
| 1397 |
+
aria-labelledby={selectedTabId}
|
| 1398 |
+
className="min-w-0"
|
| 1399 |
+
>
|
| 1400 |
+
{isCloudSelected ? (
|
| 1401 |
+
/* Padding keeps the CTA and Advanced host field from hugging the
|
| 1402 |
+
border; when Advanced expands the panel grows with the content
|
| 1403 |
+
instead of squeezing it into the reserved min-height. */
|
| 1404 |
+
<div
|
| 1405 |
+
data-testid="add-backend-cloud-panel"
|
| 1406 |
+
className={cn(
|
| 1407 |
+
"relative isolate flex min-h-[13.5rem] w-full items-center justify-center rounded-xl border border-[var(--oh-border)] px-5 py-6",
|
| 1408 |
+
// A soft greyscale halo sits behind the CTA; -z-10 keeps it under the
|
| 1409 |
+
// content rather than washing over it.
|
| 1410 |
+
"before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-xl",
|
| 1411 |
+
"before:bg-[radial-gradient(75%_75%_at_50%_50%,rgba(255,255,255,0.1)_0%,rgba(255,255,255,0)_70%)]",
|
| 1412 |
+
)}
|
| 1413 |
+
>
|
| 1414 |
+
<CloudLoginColumn
|
| 1415 |
+
onConnected={onConnected}
|
| 1416 |
+
testIdRoot="add-backend"
|
| 1417 |
+
analyticsSource={source}
|
| 1418 |
+
showBranding={false}
|
| 1419 |
+
/>
|
| 1420 |
+
</div>
|
| 1421 |
+
) : (
|
| 1422 |
+
<div
|
| 1423 |
+
data-testid="add-backend-agent-server-panel"
|
| 1424 |
+
className="mx-auto w-full max-w-xl"
|
| 1425 |
+
>
|
| 1426 |
+
{/* Rules on either side center the toggle and read as a
|
| 1427 |
+
divider between the chooser and the connection form. */}
|
| 1428 |
+
<div className="flex items-center gap-3">
|
| 1429 |
+
<span
|
| 1430 |
+
className="h-px flex-1 bg-[var(--oh-border)]"
|
| 1431 |
+
aria-hidden
|
| 1432 |
+
/>
|
| 1433 |
+
<SegmentedToggle<AgentServerLocation>
|
| 1434 |
+
value={agentServerLocation}
|
| 1435 |
+
options={[
|
| 1436 |
+
{
|
| 1437 |
+
value: "local",
|
| 1438 |
+
label: t(I18nKey.BACKEND$KIND_LOCAL),
|
| 1439 |
+
icon: <Monitor aria-hidden />,
|
| 1440 |
+
},
|
| 1441 |
+
{
|
| 1442 |
+
value: "remote",
|
| 1443 |
+
label: t(I18nKey.BACKEND$KIND_REMOTE),
|
| 1444 |
+
icon: <Globe aria-hidden />,
|
| 1445 |
+
},
|
| 1446 |
+
]}
|
| 1447 |
+
onChange={setAgentServerLocation}
|
| 1448 |
+
ariaLabel={t(I18nKey.BACKEND$AGENT_SERVER_LOCATION)}
|
| 1449 |
+
testId="add-backend-location"
|
| 1450 |
+
/>
|
| 1451 |
+
<span
|
| 1452 |
+
className="h-px flex-1 bg-[var(--oh-border)]"
|
| 1453 |
+
aria-hidden
|
| 1454 |
+
/>
|
| 1455 |
+
</div>
|
| 1456 |
+
|
| 1457 |
+
<div className="mt-4 flex flex-col gap-4">
|
| 1458 |
+
<AgentServerGuidance
|
| 1459 |
+
key={agentServerLocation}
|
| 1460 |
+
location={agentServerLocation}
|
| 1461 |
+
/>
|
| 1462 |
+
<ManualConnectionColumn
|
| 1463 |
+
onConnected={onConnected}
|
| 1464 |
+
testIdRoot="add-backend"
|
| 1465 |
+
requireApiKey={agentServerLocation === "remote"}
|
| 1466 |
+
submitLabel={t(I18nKey.BACKEND$CONNECT)}
|
| 1467 |
+
submittingLabel={t(
|
| 1468 |
+
I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING,
|
| 1469 |
+
)}
|
| 1470 |
+
fixedKind="local"
|
| 1471 |
+
showKindSelector={false}
|
| 1472 |
+
/>
|
| 1473 |
+
</div>
|
| 1474 |
+
</div>
|
| 1475 |
+
)}
|
| 1476 |
+
</section>
|
| 1477 |
+
</AnimatedPanelHeight>
|
| 1478 |
+
</div>
|
| 1479 |
+
</div>
|
| 1480 |
+
);
|
| 1481 |
+
}
|
| 1482 |
+
|
| 1483 |
+
function AddBackendConnectionOptions({
|
| 1484 |
+
onClose,
|
| 1485 |
+
source,
|
| 1486 |
+
}: {
|
| 1487 |
+
onClose: () => void;
|
| 1488 |
+
source: BackendAddedSource;
|
| 1489 |
+
}) {
|
| 1490 |
+
const { addBackend } = useActiveBackendContext();
|
| 1491 |
+
const redirectAfterAdd = useRedirectAfterAddBackend();
|
| 1492 |
+
const { trackBackendAdded } = useTracking();
|
| 1493 |
+
const lockedCloudHost = getLockedCloudHost();
|
| 1494 |
+
|
| 1495 |
+
const handleConnected = React.useCallback(
|
| 1496 |
+
(
|
| 1497 |
+
payload: BackendFormSubmitPayload,
|
| 1498 |
+
connectionMethod: BackendConnectionMethod,
|
| 1499 |
+
metadata?: BackendConnectionTestMetadata,
|
| 1500 |
+
) => {
|
| 1501 |
+
addBackend(payload);
|
| 1502 |
+
trackBackendAdded({
|
| 1503 |
+
backendKind: payload.kind,
|
| 1504 |
+
connectionMethod,
|
| 1505 |
+
hasApiKey: Boolean(payload.apiKey),
|
| 1506 |
+
source,
|
| 1507 |
+
agentServerVersion: metadata?.agentServerVersion,
|
| 1508 |
+
});
|
| 1509 |
+
redirectAfterAdd();
|
| 1510 |
+
onClose();
|
| 1511 |
+
},
|
| 1512 |
+
[addBackend, redirectAfterAdd, onClose, trackBackendAdded, source],
|
| 1513 |
+
);
|
| 1514 |
+
|
| 1515 |
+
if (lockedCloudHost) {
|
| 1516 |
+
return (
|
| 1517 |
+
<CloudLoginColumn
|
| 1518 |
+
onConnected={handleConnected}
|
| 1519 |
+
testIdRoot="add-backend"
|
| 1520 |
+
lockedHost={lockedCloudHost}
|
| 1521 |
+
analyticsSource={source}
|
| 1522 |
+
/>
|
| 1523 |
+
);
|
| 1524 |
+
}
|
| 1525 |
+
|
| 1526 |
+
return <AddBackendChooser onConnected={handleConnected} source={source} />;
|
| 1527 |
+
}
|
| 1528 |
+
|
| 1529 |
+
// ── Modal wrappers ──────────────────────────────────────────────────
|
| 1530 |
+
|
| 1531 |
+
/**
|
| 1532 |
+
* Modal wrapper. In **add** mode it renders a two-column layout
|
| 1533 |
+
* (manual connection | OR | Cloud login). In **edit** mode it wraps
|
| 1534 |
+
* the standard `BackendForm`.
|
| 1535 |
+
*/
|
| 1536 |
+
export function BackendFormModal({
|
| 1537 |
+
mode,
|
| 1538 |
+
backend,
|
| 1539 |
+
onClose,
|
| 1540 |
+
source = "add_backend_modal",
|
| 1541 |
+
hideCloseButton = false,
|
| 1542 |
+
}: BackendFormModalProps) {
|
| 1543 |
+
const { t } = useTranslation("openhands");
|
| 1544 |
+
|
| 1545 |
+
if (mode === "add") {
|
| 1546 |
+
return (
|
| 1547 |
+
<ModalBackdrop
|
| 1548 |
+
onClose={hideCloseButton ? undefined : onClose}
|
| 1549 |
+
closeOnEscape={false}
|
| 1550 |
+
closeOnBackdropClick={!hideCloseButton}
|
| 1551 |
+
aria-label={t(I18nKey.BACKEND$ADD_TITLE)}
|
| 1552 |
+
>
|
| 1553 |
+
<div
|
| 1554 |
+
data-testid={
|
| 1555 |
+
hideCloseButton ? "onboarding-modal" : "add-backend-modal"
|
| 1556 |
+
}
|
| 1557 |
+
className={cn(
|
| 1558 |
+
"relative max-h-[92vh] w-[720px] overflow-y-auto rounded-xl border border-[var(--oh-border)] bg-base-secondary p-6",
|
| 1559 |
+
MODAL_MAX_WIDTH_VIEWPORT,
|
| 1560 |
+
)}
|
| 1561 |
+
>
|
| 1562 |
+
{hideCloseButton ? null : (
|
| 1563 |
+
<ModalCloseButton onClose={onClose} testId="add-backend-close" />
|
| 1564 |
+
)}
|
| 1565 |
+
{hideCloseButton ? null : (
|
| 1566 |
+
<div className="pr-8">
|
| 1567 |
+
<h2 className={modalTitleLgClassName}>
|
| 1568 |
+
{t(I18nKey.BACKEND$CHOOSER_TITLE)}
|
| 1569 |
+
</h2>
|
| 1570 |
+
<p
|
| 1571 |
+
className="mt-2 text-sm leading-6 text-[var(--oh-muted)]"
|
| 1572 |
+
data-testid="add-backend-description"
|
| 1573 |
+
>
|
| 1574 |
+
{t(I18nKey.BACKEND$CHOOSER_DESCRIPTION)}{" "}
|
| 1575 |
+
<a
|
| 1576 |
+
href={DEPLOYMENT_OPTIONS_URL}
|
| 1577 |
+
target="_blank"
|
| 1578 |
+
rel="noopener noreferrer"
|
| 1579 |
+
aria-label={t(I18nKey.BACKEND$DEPLOYMENT_OPTIONS)}
|
| 1580 |
+
data-testid="add-backend-deployment-options-link"
|
| 1581 |
+
className="text-primary hover:underline"
|
| 1582 |
+
>
|
| 1583 |
+
{t(I18nKey.CTA$LEARN_MORE)}
|
| 1584 |
+
<ExternalLinkIcon
|
| 1585 |
+
className="ml-1 inline size-3.5 align-[-0.125em]"
|
| 1586 |
+
aria-hidden
|
| 1587 |
+
/>
|
| 1588 |
+
</a>
|
| 1589 |
+
</p>
|
| 1590 |
+
</div>
|
| 1591 |
+
)}
|
| 1592 |
+
|
| 1593 |
+
<div className={hideCloseButton ? undefined : "mt-5"}>
|
| 1594 |
+
<AddBackendConnectionOptions onClose={onClose} source={source} />
|
| 1595 |
+
</div>
|
| 1596 |
+
</div>
|
| 1597 |
+
</ModalBackdrop>
|
| 1598 |
+
);
|
| 1599 |
+
}
|
| 1600 |
+
|
| 1601 |
+
// Edit mode — single-column form (unchanged)
|
| 1602 |
+
const testIdRoot = "edit-backend";
|
| 1603 |
+
return (
|
| 1604 |
+
<ModalBackdrop
|
| 1605 |
+
onClose={onClose}
|
| 1606 |
+
closeOnEscape={false}
|
| 1607 |
+
aria-label={t(I18nKey.BACKEND$EDIT_TITLE)}
|
| 1608 |
+
>
|
| 1609 |
+
<div
|
| 1610 |
+
data-testid={`${testIdRoot}-modal`}
|
| 1611 |
+
className={cn(
|
| 1612 |
+
"relative bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-[var(--oh-border)]",
|
| 1613 |
+
modalWidthClassName("md"),
|
| 1614 |
+
)}
|
| 1615 |
+
>
|
| 1616 |
+
<ModalCloseButton onClose={onClose} testId={`${testIdRoot}-close`} />
|
| 1617 |
+
<h2 className={cn("pr-6", modalTitleLgClassName)}>
|
| 1618 |
+
{t(I18nKey.BACKEND$EDIT_TITLE)}
|
| 1619 |
+
</h2>
|
| 1620 |
+
<BackendForm
|
| 1621 |
+
mode="edit"
|
| 1622 |
+
backend={backend}
|
| 1623 |
+
onSubmitted={onClose}
|
| 1624 |
+
testIdRoot={testIdRoot}
|
| 1625 |
+
/>
|
| 1626 |
+
</div>
|
| 1627 |
+
</ModalBackdrop>
|
| 1628 |
+
);
|
| 1629 |
+
}
|
src/components/features/backends/backend-row.tsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useTranslation } from "react-i18next";
|
| 2 |
+
import { Pencil, RefreshCw, Trash2 } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
import { type Backend } from "#/api/backend-registry/types";
|
| 5 |
+
import {
|
| 6 |
+
isCloudBackendLoggedOutHealthError,
|
| 7 |
+
isInvalidBackendApiKeyHealthError,
|
| 8 |
+
type BackendHealth,
|
| 9 |
+
} from "#/hooks/query/use-backends-health";
|
| 10 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 11 |
+
import { cn } from "#/utils/utils";
|
| 12 |
+
import { BackendStatusDot } from "./backend-status-dot";
|
| 13 |
+
import { BackendVersion } from "./backend-version";
|
| 14 |
+
import { DeviceFlowAuth } from "./device-flow-auth";
|
| 15 |
+
import { getBackendStatusLabel } from "./backend-status-label";
|
| 16 |
+
import { getLockedCloudHost } from "#/api/agent-server-config";
|
| 17 |
+
|
| 18 |
+
const ROW_ACTION_BUTTON_CLASS =
|
| 19 |
+
"inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-white";
|
| 20 |
+
|
| 21 |
+
interface BackendRowProps {
|
| 22 |
+
backend: Backend;
|
| 23 |
+
health: BackendHealth | undefined;
|
| 24 |
+
orgLabel?: string;
|
| 25 |
+
onSelect: () => void;
|
| 26 |
+
onEdit: () => void;
|
| 27 |
+
onRemove: () => void;
|
| 28 |
+
onLogin?: (apiKey: string) => void;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function BackendRow({
|
| 32 |
+
backend,
|
| 33 |
+
health,
|
| 34 |
+
orgLabel,
|
| 35 |
+
onSelect,
|
| 36 |
+
onEdit,
|
| 37 |
+
onRemove,
|
| 38 |
+
onLogin,
|
| 39 |
+
}: BackendRowProps) {
|
| 40 |
+
const { t } = useTranslation("openhands");
|
| 41 |
+
const isInvalidApiKey = isInvalidBackendApiKeyHealthError(health?.lastError);
|
| 42 |
+
const isCloudLoggedOut =
|
| 43 |
+
backend.kind === "cloud" &&
|
| 44 |
+
isCloudBackendLoggedOutHealthError(health?.lastError);
|
| 45 |
+
const statusDetail =
|
| 46 |
+
!isInvalidApiKey &&
|
| 47 |
+
!isCloudLoggedOut &&
|
| 48 |
+
health?.isConnected === false &&
|
| 49 |
+
health.lastError
|
| 50 |
+
? health.lastError
|
| 51 |
+
: null;
|
| 52 |
+
const statusLabel = isCloudLoggedOut
|
| 53 |
+
? t(I18nKey.BACKEND$LOGGED_OUT)
|
| 54 |
+
: getBackendStatusLabel(t, backend, health);
|
| 55 |
+
const statusClassName =
|
| 56 |
+
health?.isConnected === true
|
| 57 |
+
? "text-green-300"
|
| 58 |
+
: health?.isConnected === false
|
| 59 |
+
? "text-red-300"
|
| 60 |
+
: "text-[var(--oh-muted)]";
|
| 61 |
+
const dotStatus = isInvalidApiKey ? false : (health?.isConnected ?? null);
|
| 62 |
+
const canSelect = health?.isConnected === true && !isInvalidApiKey;
|
| 63 |
+
const lockedCloudHost = getLockedCloudHost();
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<li
|
| 67 |
+
className="flex items-stretch"
|
| 68 |
+
data-testid={`manage-backends-row-${backend.name}`}
|
| 69 |
+
>
|
| 70 |
+
<button
|
| 71 |
+
type="button"
|
| 72 |
+
disabled={!canSelect}
|
| 73 |
+
onClick={onSelect}
|
| 74 |
+
className={cn(
|
| 75 |
+
"flex min-w-0 flex-1 items-center gap-3 px-3 py-3 text-left",
|
| 76 |
+
canSelect
|
| 77 |
+
? "cursor-pointer transition-colors hover:bg-interactive-hover focus-visible:bg-interactive-hover focus-visible:outline-none"
|
| 78 |
+
: "cursor-default",
|
| 79 |
+
)}
|
| 80 |
+
>
|
| 81 |
+
<BackendStatusDot isConnected={dotStatus} />
|
| 82 |
+
<div className="flex min-w-0 flex-1 flex-col">
|
| 83 |
+
<div className="flex min-w-0 items-center gap-2">
|
| 84 |
+
<span className="truncate text-sm text-white">{backend.name}</span>
|
| 85 |
+
<BackendVersion backend={backend} />
|
| 86 |
+
</div>
|
| 87 |
+
{orgLabel ? (
|
| 88 |
+
<span
|
| 89 |
+
data-testid={`manage-backends-org-${backend.name}`}
|
| 90 |
+
className="truncate text-xs text-[var(--oh-text-secondary)]"
|
| 91 |
+
>
|
| 92 |
+
{orgLabel}
|
| 93 |
+
</span>
|
| 94 |
+
) : null}
|
| 95 |
+
<span className="truncate text-xs text-[var(--oh-muted)]">
|
| 96 |
+
{backend.host}
|
| 97 |
+
</span>
|
| 98 |
+
<span
|
| 99 |
+
data-testid={`manage-backends-status-${backend.name}`}
|
| 100 |
+
className={cn("truncate text-xs", statusClassName)}
|
| 101 |
+
>
|
| 102 |
+
{statusLabel}
|
| 103 |
+
</span>
|
| 104 |
+
{statusDetail ? (
|
| 105 |
+
<span
|
| 106 |
+
data-testid={`manage-backends-status-detail-${backend.name}`}
|
| 107 |
+
title={statusDetail}
|
| 108 |
+
className="text-xs text-red-300/80 whitespace-normal break-words"
|
| 109 |
+
>
|
| 110 |
+
{statusDetail}
|
| 111 |
+
</span>
|
| 112 |
+
) : null}
|
| 113 |
+
</div>
|
| 114 |
+
<span className="px-2 py-1 rounded-full text-[11px] uppercase tracking-wide text-[var(--oh-text-tertiary)] bg-[var(--oh-surface)] border border-[var(--oh-border)]">
|
| 115 |
+
{backend.kind === "cloud"
|
| 116 |
+
? t(I18nKey.BACKEND$KIND_CLOUD)
|
| 117 |
+
: t(I18nKey.BACKEND$KIND_LOCAL)}
|
| 118 |
+
</span>
|
| 119 |
+
</button>
|
| 120 |
+
<div className="flex shrink-0 items-center gap-2 px-3 py-3">
|
| 121 |
+
{isCloudLoggedOut && onLogin ? (
|
| 122 |
+
<DeviceFlowAuth
|
| 123 |
+
host={backend.host}
|
| 124 |
+
onSuccess={onLogin}
|
| 125 |
+
testIdRoot={`manage-backends-login-${backend.id}`}
|
| 126 |
+
idleButtonLabel={t(I18nKey.BACKEND$LOG_BACK_IN)}
|
| 127 |
+
idleButtonContent={
|
| 128 |
+
<RefreshCw className="size-4" strokeWidth={2} aria-hidden />
|
| 129 |
+
}
|
| 130 |
+
className="w-auto"
|
| 131 |
+
buttonVariant="unstyled"
|
| 132 |
+
buttonClassName={ROW_ACTION_BUTTON_CLASS}
|
| 133 |
+
statusDisplay="modal"
|
| 134 |
+
analyticsSource="manage_backends_modal"
|
| 135 |
+
/>
|
| 136 |
+
) : null}
|
| 137 |
+
{!lockedCloudHost && (
|
| 138 |
+
<button
|
| 139 |
+
type="button"
|
| 140 |
+
onClick={onEdit}
|
| 141 |
+
aria-label={t(I18nKey.BACKEND$EDIT)}
|
| 142 |
+
data-testid={`manage-backends-edit-${backend.name}`}
|
| 143 |
+
className={ROW_ACTION_BUTTON_CLASS}
|
| 144 |
+
>
|
| 145 |
+
<Pencil aria-hidden className="size-4" strokeWidth={2} />
|
| 146 |
+
</button>
|
| 147 |
+
)}
|
| 148 |
+
{!lockedCloudHost && (
|
| 149 |
+
<button
|
| 150 |
+
type="button"
|
| 151 |
+
onClick={onRemove}
|
| 152 |
+
aria-label={t(I18nKey.BACKEND$REMOVE)}
|
| 153 |
+
data-testid={`manage-backends-remove-${backend.name}`}
|
| 154 |
+
className={ROW_ACTION_BUTTON_CLASS}
|
| 155 |
+
>
|
| 156 |
+
<Trash2 aria-hidden className="size-4" strokeWidth={2} />
|
| 157 |
+
</button>
|
| 158 |
+
)}
|
| 159 |
+
</div>
|
| 160 |
+
</li>
|
| 161 |
+
);
|
| 162 |
+
}
|
src/components/features/backends/backend-selector.tsx
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import { useMatch, useNavigate } from "react-router";
|
| 4 |
+
import { Plus, Settings } from "lucide-react";
|
| 5 |
+
import { Dropdown } from "#/ui/dropdown/dropdown";
|
| 6 |
+
import { DropdownOption } from "#/ui/dropdown/types";
|
| 7 |
+
import { getLockedCloudHost } from "#/api/agent-server-config";
|
| 8 |
+
import { isNoBackend } from "#/api/backend-registry/active-store";
|
| 9 |
+
import { useActiveBackendContext } from "#/contexts/active-backend-context";
|
| 10 |
+
import { useAllCloudOrganizations } from "#/hooks/query/use-cloud-organizations";
|
| 11 |
+
import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id";
|
| 12 |
+
import {
|
| 13 |
+
useBackendsHealth,
|
| 14 |
+
type BackendHealth,
|
| 15 |
+
} from "#/hooks/query/use-backends-health";
|
| 16 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 17 |
+
import type { Backend } from "#/api/backend-registry/types";
|
| 18 |
+
// Import the trigger helpers from the lightweight store, not the overlay
|
| 19 |
+
// component, so the eagerly-mounted sidebar/backend-selector graph does not
|
| 20 |
+
// pull in the overlay's render code (the overlay is lazy-loaded from
|
| 21 |
+
// `routes/root-layout.tsx`).
|
| 22 |
+
import {
|
| 23 |
+
ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS,
|
| 24 |
+
triggerEnvironmentSwitch,
|
| 25 |
+
} from "#/components/features/backends/environment-switch-store";
|
| 26 |
+
import { NavigationLink } from "#/components/shared/navigation-link";
|
| 27 |
+
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
|
| 28 |
+
import { useConversationStore } from "#/stores/conversation-store";
|
| 29 |
+
import { AddBackendModal } from "./add-backend-modal";
|
| 30 |
+
import { BackendStatusDot } from "./backend-status-dot";
|
| 31 |
+
import { ManageBackendsModal } from "./manage-backends-modal";
|
| 32 |
+
import { cn } from "#/utils/utils";
|
| 33 |
+
import { formControlTransitionClassName } from "#/utils/form-control-classes";
|
| 34 |
+
import {
|
| 35 |
+
dropdownFooterActionClassName,
|
| 36 |
+
dropdownMenuListClassName,
|
| 37 |
+
dropdownMenuRowIconWrapperClassName,
|
| 38 |
+
} from "#/utils/dropdown-classes";
|
| 39 |
+
|
| 40 |
+
const VALUE_SEPARATOR = "::";
|
| 41 |
+
|
| 42 |
+
function makeOptionValue(backendId: string, orgId: string | null): string {
|
| 43 |
+
return orgId ? `${backendId}${VALUE_SEPARATOR}${orgId}` : backendId;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function parseOptionValue(value: string): {
|
| 47 |
+
backendId: string;
|
| 48 |
+
orgId: string | null;
|
| 49 |
+
} {
|
| 50 |
+
const [backendId, orgId] = value.split(VALUE_SEPARATOR);
|
| 51 |
+
return { backendId, orgId: orgId ?? null };
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function buildStatusPrefix(health: BackendHealth | undefined) {
|
| 55 |
+
return <BackendStatusDot isConnected={health?.isConnected ?? null} />;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function buildNoBackendPrefix() {
|
| 59 |
+
return <BackendStatusDot isConnected="unavailable" />;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function buildOptions(
|
| 63 |
+
registered: Backend[],
|
| 64 |
+
personalWorkspaceLabel: string,
|
| 65 |
+
cloudOrgs: ReturnType<typeof useAllCloudOrganizations>,
|
| 66 |
+
currentUserIds: ReturnType<typeof useCloudCurrentUserId>,
|
| 67 |
+
healthByBackendId: Record<string, BackendHealth>,
|
| 68 |
+
): DropdownOption[] {
|
| 69 |
+
const options: DropdownOption[] = [];
|
| 70 |
+
|
| 71 |
+
const locals = registered.filter((b) => b.kind === "local");
|
| 72 |
+
const clouds = registered.filter((b) => b.kind === "cloud");
|
| 73 |
+
|
| 74 |
+
for (const b of locals) {
|
| 75 |
+
options.push({
|
| 76 |
+
value: makeOptionValue(b.id, null),
|
| 77 |
+
label: b.name,
|
| 78 |
+
prefix: buildStatusPrefix(healthByBackendId[b.id]),
|
| 79 |
+
});
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
for (const b of clouds) {
|
| 83 |
+
const entry = cloudOrgs[b.id];
|
| 84 |
+
const prefix = buildStatusPrefix(healthByBackendId[b.id]);
|
| 85 |
+
if (!entry || entry.orgs.length === 0) {
|
| 86 |
+
options.push({
|
| 87 |
+
value: makeOptionValue(b.id, null),
|
| 88 |
+
label: b.name,
|
| 89 |
+
prefix,
|
| 90 |
+
});
|
| 91 |
+
} else {
|
| 92 |
+
// Personal-workspace rule (per the cloud contract): the org whose
|
| 93 |
+
// id matches the calling user's id is the user's personal
|
| 94 |
+
// workspace. We resolve `user_id` once per backend (via /me on any
|
| 95 |
+
// one org) and apply it across all orgs of that backend.
|
| 96 |
+
const userIdForBackend = currentUserIds[b.id]?.userId ?? null;
|
| 97 |
+
|
| 98 |
+
for (const org of entry.orgs) {
|
| 99 |
+
const isPersonal = !!userIdForBackend && userIdForBackend === org.id;
|
| 100 |
+
const orgLabel = isPersonal ? personalWorkspaceLabel : org.name;
|
| 101 |
+
options.push({
|
| 102 |
+
value: makeOptionValue(b.id, org.id),
|
| 103 |
+
label: `${b.name} – ${orgLabel}`,
|
| 104 |
+
// All org rows for the same cloud backend share that backend's
|
| 105 |
+
// single connectivity verdict — there is no per-org probe.
|
| 106 |
+
prefix,
|
| 107 |
+
});
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return options;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
interface BackendSelectorProps {
|
| 116 |
+
/** Render the menu above the trigger (e.g. when pinned to bottom of sidebar). */
|
| 117 |
+
openUpward?: boolean;
|
| 118 |
+
/** Hide the selector input trigger and only render the dropdown menu. */
|
| 119 |
+
hideTrigger?: boolean;
|
| 120 |
+
/** Whether the dropdown menu should start open on mount. */
|
| 121 |
+
defaultOpen?: boolean;
|
| 122 |
+
/** Callback fired after selecting a backend/org option. */
|
| 123 |
+
onSelectOption?: () => void;
|
| 124 |
+
/**
|
| 125 |
+
* Override the internal Add Backend modal handling. When provided,
|
| 126 |
+
* clicking "Add Backend" calls this instead of opening BackendSelector's
|
| 127 |
+
* own modal. Useful when the selector is mounted inside an ephemeral
|
| 128 |
+
* container (e.g. the collapsed-sidebar popover) and the modal must
|
| 129 |
+
* survive the parent unmounting.
|
| 130 |
+
*/
|
| 131 |
+
onOpenAddBackend?: () => void;
|
| 132 |
+
/** Same as onOpenAddBackend but for the Manage Backends modal. */
|
| 133 |
+
onOpenManageBackends?: () => void;
|
| 134 |
+
/**
|
| 135 |
+
* Whether the surrounding sidebar rail is in its collapsed variant. Passed
|
| 136 |
+
* down from `SidebarRailBody` so the mobile drawer (which always renders
|
| 137 |
+
* the expanded rail) can override the persisted desktop value.
|
| 138 |
+
*/
|
| 139 |
+
sidebarCollapsed?: boolean;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
export function BackendSelector({
|
| 143 |
+
openUpward = false,
|
| 144 |
+
hideTrigger = false,
|
| 145 |
+
defaultOpen = false,
|
| 146 |
+
onSelectOption,
|
| 147 |
+
onOpenAddBackend,
|
| 148 |
+
onOpenManageBackends,
|
| 149 |
+
sidebarCollapsed = false,
|
| 150 |
+
}: BackendSelectorProps = {}) {
|
| 151 |
+
const { t } = useTranslation("openhands");
|
| 152 |
+
const { backends, active, setActive } = useActiveBackendContext();
|
| 153 |
+
const cloudOrgs = useAllCloudOrganizations();
|
| 154 |
+
const currentUserIds = useCloudCurrentUserId();
|
| 155 |
+
// Probe each registered backend every 10s.
|
| 156 |
+
const healthByBackendId = useBackendsHealth(backends);
|
| 157 |
+
const navigate = useNavigate();
|
| 158 |
+
const settingsMatch = useMatch("/settings");
|
| 159 |
+
const settingsSubrouteMatch = useMatch("/settings/*");
|
| 160 |
+
const conversationMatch = useMatch("/conversations/:conversationId");
|
| 161 |
+
const automationDetailMatch = useMatch("/automations/:automationId");
|
| 162 |
+
const [addBackendModalOpen, setAddBackendModalOpen] = React.useState(false);
|
| 163 |
+
const [manageBackendsModalOpen, setManageBackendsModalOpen] =
|
| 164 |
+
React.useState(false);
|
| 165 |
+
|
| 166 |
+
const personalWorkspaceLabel = t(I18nKey.BACKEND$PERSONAL_WORKSPACE);
|
| 167 |
+
|
| 168 |
+
const options = React.useMemo(
|
| 169 |
+
() =>
|
| 170 |
+
buildOptions(
|
| 171 |
+
backends,
|
| 172 |
+
personalWorkspaceLabel,
|
| 173 |
+
cloudOrgs,
|
| 174 |
+
currentUserIds,
|
| 175 |
+
healthByBackendId,
|
| 176 |
+
),
|
| 177 |
+
[
|
| 178 |
+
backends,
|
| 179 |
+
personalWorkspaceLabel,
|
| 180 |
+
cloudOrgs,
|
| 181 |
+
currentUserIds,
|
| 182 |
+
healthByBackendId,
|
| 183 |
+
],
|
| 184 |
+
);
|
| 185 |
+
|
| 186 |
+
const noBackendSelected = isNoBackend(active.backend);
|
| 187 |
+
const noBackendLabel = t(I18nKey.BACKEND$NO_BACKEND_AVAILABLE);
|
| 188 |
+
const activeValue = makeOptionValue(active.backend.id, active.orgId);
|
| 189 |
+
const activeOption = noBackendSelected
|
| 190 |
+
? undefined
|
| 191 |
+
: options.find((o) => o.value === activeValue);
|
| 192 |
+
const isSettingsActive = Boolean(settingsMatch || settingsSubrouteMatch);
|
| 193 |
+
const settingsLabel = t(I18nKey.SIDEBAR$SETTINGS);
|
| 194 |
+
// `org` is consumed by the cloud settings loader so the page opens on the
|
| 195 |
+
// org that is active here instead of the cloud's last-used org.
|
| 196 |
+
const cloudSettingsOrgQuery = active.orgId
|
| 197 |
+
? `?org=${encodeURIComponent(active.orgId)}`
|
| 198 |
+
: "";
|
| 199 |
+
const isRightPanelShown = useConversationStore(
|
| 200 |
+
(state) => state.isRightPanelShown,
|
| 201 |
+
);
|
| 202 |
+
// When the sidebar rail is expanded, `placement="left"` hugs the main
|
| 203 |
+
// canvas and reads awkwardly; prefer above the control. When the rail is
|
| 204 |
+
// collapsed, keep left except on active conversation + open right drawer.
|
| 205 |
+
const settingsTooltipPlacement =
|
| 206 |
+
!sidebarCollapsed || (conversationMatch && isRightPanelShown)
|
| 207 |
+
? "top"
|
| 208 |
+
: "left";
|
| 209 |
+
|
| 210 |
+
const someCloudLoading = Object.values(cloudOrgs).some((c) => c.isLoading);
|
| 211 |
+
|
| 212 |
+
// Self-heal a malformed `(cloudBackendId, null)` selection.
|
| 213 |
+
//
|
| 214 |
+
// Once a cloud backend's orgs resolve, the dropdown only renders
|
| 215 |
+
// per-org rows for it — the `(backendId, null)` row disappears, so
|
| 216 |
+
// selecting that shape would drift from what the dropdown can render
|
| 217 |
+
// (UI says "Local", APIs hit cloud). When we detect the drift, snap
|
| 218 |
+
// the selection onto Cloud's current org first, then fall back to the
|
| 219 |
+
// personal workspace (or, lacking a /me result, the first org). The
|
| 220 |
+
// selection is recorded locally only; the cloud request scope follows
|
| 221 |
+
// from the X-Org-Id header sent by `callCloudProxy`, so the cloud UI's
|
| 222 |
+
// org choice is never mutated as a side effect.
|
| 223 |
+
React.useEffect(() => {
|
| 224 |
+
if (noBackendSelected || active.backend.kind !== "cloud" || active.orgId)
|
| 225 |
+
return;
|
| 226 |
+
const { backend } = active;
|
| 227 |
+
const entry = cloudOrgs[backend.id];
|
| 228 |
+
if (!entry || entry.orgs.length === 0) return;
|
| 229 |
+
|
| 230 |
+
const currentOrg = entry.currentOrgId
|
| 231 |
+
? entry.orgs.find((o) => o.id === entry.currentOrgId)
|
| 232 |
+
: undefined;
|
| 233 |
+
const userId = currentUserIds[backend.id]?.userId ?? null;
|
| 234 |
+
const personal = userId
|
| 235 |
+
? entry.orgs.find((o) => o.id === userId)
|
| 236 |
+
: undefined;
|
| 237 |
+
const target = currentOrg ?? personal ?? entry.orgs[0];
|
| 238 |
+
if (target) {
|
| 239 |
+
setActive(backend.id, target.id);
|
| 240 |
+
}
|
| 241 |
+
}, [active, cloudOrgs, currentUserIds, setActive, noBackendSelected]);
|
| 242 |
+
|
| 243 |
+
const openAddBackendModal = React.useCallback(() => {
|
| 244 |
+
if (onOpenAddBackend) {
|
| 245 |
+
onOpenAddBackend();
|
| 246 |
+
onSelectOption?.();
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
setAddBackendModalOpen(true);
|
| 250 |
+
}, [onOpenAddBackend, onSelectOption]);
|
| 251 |
+
|
| 252 |
+
const openManageBackendsModal = React.useCallback(() => {
|
| 253 |
+
if (onOpenManageBackends) {
|
| 254 |
+
onOpenManageBackends();
|
| 255 |
+
onSelectOption?.();
|
| 256 |
+
return;
|
| 257 |
+
}
|
| 258 |
+
setManageBackendsModalOpen(true);
|
| 259 |
+
}, [onOpenManageBackends, onSelectOption]);
|
| 260 |
+
|
| 261 |
+
const isLockedToCloud = getLockedCloudHost() !== null;
|
| 262 |
+
// A cookie-auth (OHE-hosted) Canvas has nothing to add, manage, or
|
| 263 |
+
// reconnect — its single locked backend is owned by the main-app session.
|
| 264 |
+
const hideBackendFooter =
|
| 265 |
+
isLockedToCloud && active.backend.authMode === "cookie";
|
| 266 |
+
|
| 267 |
+
const preventDropdownMenuClose = React.useCallback(
|
| 268 |
+
(event: React.SyntheticEvent<HTMLButtonElement>) => {
|
| 269 |
+
event.preventDefault();
|
| 270 |
+
event.stopPropagation();
|
| 271 |
+
},
|
| 272 |
+
[],
|
| 273 |
+
);
|
| 274 |
+
|
| 275 |
+
const handleAddBackendClick = React.useCallback(
|
| 276 |
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
| 277 |
+
preventDropdownMenuClose(event);
|
| 278 |
+
openAddBackendModal();
|
| 279 |
+
},
|
| 280 |
+
[openAddBackendModal, preventDropdownMenuClose],
|
| 281 |
+
);
|
| 282 |
+
|
| 283 |
+
const handleAddBackendTouchEnd = React.useCallback(
|
| 284 |
+
(event: React.TouchEvent<HTMLButtonElement>) => {
|
| 285 |
+
preventDropdownMenuClose(event);
|
| 286 |
+
openAddBackendModal();
|
| 287 |
+
},
|
| 288 |
+
[openAddBackendModal, preventDropdownMenuClose],
|
| 289 |
+
);
|
| 290 |
+
|
| 291 |
+
const handleManageBackendsClick = React.useCallback(
|
| 292 |
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
| 293 |
+
preventDropdownMenuClose(event);
|
| 294 |
+
openManageBackendsModal();
|
| 295 |
+
},
|
| 296 |
+
[openManageBackendsModal, preventDropdownMenuClose],
|
| 297 |
+
);
|
| 298 |
+
|
| 299 |
+
const handleManageBackendsTouchEnd = React.useCallback(
|
| 300 |
+
(event: React.TouchEvent<HTMLButtonElement>) => {
|
| 301 |
+
preventDropdownMenuClose(event);
|
| 302 |
+
openManageBackendsModal();
|
| 303 |
+
},
|
| 304 |
+
[openManageBackendsModal, preventDropdownMenuClose],
|
| 305 |
+
);
|
| 306 |
+
|
| 307 |
+
const addBackendFooter = hideBackendFooter ? undefined : (
|
| 308 |
+
<div className={dropdownMenuListClassName}>
|
| 309 |
+
{isLockedToCloud ? null : (
|
| 310 |
+
<button
|
| 311 |
+
type="button"
|
| 312 |
+
data-testid="add-backend-menu-item"
|
| 313 |
+
onMouseDown={preventDropdownMenuClose}
|
| 314 |
+
onTouchStart={preventDropdownMenuClose}
|
| 315 |
+
onTouchEnd={handleAddBackendTouchEnd}
|
| 316 |
+
onClick={handleAddBackendClick}
|
| 317 |
+
className={cn(
|
| 318 |
+
dropdownFooterActionClassName,
|
| 319 |
+
"cursor-pointer rounded-md",
|
| 320 |
+
)}
|
| 321 |
+
>
|
| 322 |
+
<span className={dropdownMenuRowIconWrapperClassName} aria-hidden>
|
| 323 |
+
<Plus width={16} height={16} />
|
| 324 |
+
</span>
|
| 325 |
+
{t(I18nKey.BACKEND$ADD)}
|
| 326 |
+
</button>
|
| 327 |
+
)}
|
| 328 |
+
<button
|
| 329 |
+
type="button"
|
| 330 |
+
data-testid="manage-backends-menu-item"
|
| 331 |
+
onMouseDown={preventDropdownMenuClose}
|
| 332 |
+
onTouchStart={preventDropdownMenuClose}
|
| 333 |
+
onTouchEnd={handleManageBackendsTouchEnd}
|
| 334 |
+
onClick={handleManageBackendsClick}
|
| 335 |
+
className={cn(
|
| 336 |
+
dropdownFooterActionClassName,
|
| 337 |
+
"cursor-pointer rounded-md",
|
| 338 |
+
)}
|
| 339 |
+
>
|
| 340 |
+
<span className={dropdownMenuRowIconWrapperClassName} aria-hidden>
|
| 341 |
+
<Settings width={16} height={16} />
|
| 342 |
+
</span>
|
| 343 |
+
{t(
|
| 344 |
+
isLockedToCloud
|
| 345 |
+
? I18nKey.BACKEND$RECONNECT_CLOUD
|
| 346 |
+
: I18nKey.BACKEND$MANAGE,
|
| 347 |
+
)}
|
| 348 |
+
</button>
|
| 349 |
+
</div>
|
| 350 |
+
);
|
| 351 |
+
|
| 352 |
+
const handleSelectBackend = React.useCallback(
|
| 353 |
+
async (value: string) => {
|
| 354 |
+
if (value === activeValue) return;
|
| 355 |
+
|
| 356 |
+
const { backendId, orgId } = parseOptionValue(value);
|
| 357 |
+
const target = backends.find((b) => b.id === backendId);
|
| 358 |
+
if (!target) return;
|
| 359 |
+
|
| 360 |
+
triggerEnvironmentSwitch(
|
| 361 |
+
options.find((option) => option.value === value)?.label ?? target.name,
|
| 362 |
+
);
|
| 363 |
+
await new Promise<void>((resolve) => {
|
| 364 |
+
setTimeout(resolve, ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS);
|
| 365 |
+
});
|
| 366 |
+
|
| 367 |
+
// @spec BM-002 — Switching backends keeps the user on the same page
|
| 368 |
+
if (conversationMatch) navigate("/conversations");
|
| 369 |
+
else if (automationDetailMatch) navigate("/automations");
|
| 370 |
+
|
| 371 |
+
setActive(target.id, orgId);
|
| 372 |
+
onSelectOption?.();
|
| 373 |
+
},
|
| 374 |
+
[
|
| 375 |
+
activeValue,
|
| 376 |
+
backends,
|
| 377 |
+
conversationMatch,
|
| 378 |
+
automationDetailMatch,
|
| 379 |
+
navigate,
|
| 380 |
+
options,
|
| 381 |
+
setActive,
|
| 382 |
+
t,
|
| 383 |
+
onSelectOption,
|
| 384 |
+
],
|
| 385 |
+
);
|
| 386 |
+
|
| 387 |
+
return (
|
| 388 |
+
<>
|
| 389 |
+
<div className="flex items-center gap-2 w-full">
|
| 390 |
+
<div className="flex-1 min-w-0">
|
| 391 |
+
<Dropdown
|
| 392 |
+
testId="backend-selector"
|
| 393 |
+
key={`${activeValue}-${activeOption?.label ?? ""}`}
|
| 394 |
+
defaultValue={
|
| 395 |
+
activeOption ?? {
|
| 396 |
+
value: activeValue,
|
| 397 |
+
label: noBackendSelected ? noBackendLabel : active.backend.name,
|
| 398 |
+
prefix: noBackendSelected
|
| 399 |
+
? buildNoBackendPrefix()
|
| 400 |
+
: buildStatusPrefix(healthByBackendId[active.backend.id]),
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
footer={addBackendFooter}
|
| 404 |
+
openUpward={openUpward}
|
| 405 |
+
hideTrigger={hideTrigger}
|
| 406 |
+
defaultOpen={defaultOpen}
|
| 407 |
+
openOnHover={!hideTrigger}
|
| 408 |
+
onChange={(item) => {
|
| 409 |
+
if (!item) return;
|
| 410 |
+
void handleSelectBackend(item.value);
|
| 411 |
+
}}
|
| 412 |
+
placeholder={
|
| 413 |
+
noBackendSelected ? noBackendLabel : active.backend.name
|
| 414 |
+
}
|
| 415 |
+
loading={someCloudLoading}
|
| 416 |
+
options={options}
|
| 417 |
+
className="h-10 px-2 py-0 bg-transparent border-transparent hover:bg-[var(--oh-surface-raised)] focus-within:bg-[var(--oh-surface-raised)] focus-within:border-transparent focus-within:ring-0"
|
| 418 |
+
/>
|
| 419 |
+
</div>
|
| 420 |
+
{!hideTrigger ? (
|
| 421 |
+
<StyledTooltip
|
| 422 |
+
content={settingsLabel}
|
| 423 |
+
placement={settingsTooltipPlacement}
|
| 424 |
+
offset={10}
|
| 425 |
+
>
|
| 426 |
+
{active.backend.kind === "cloud" ? (
|
| 427 |
+
<a
|
| 428 |
+
href={`${active.backend.host.replace(/\/+$/, "")}/settings${cloudSettingsOrgQuery}`}
|
| 429 |
+
target={isLockedToCloud ? undefined : "_blank"}
|
| 430 |
+
rel={isLockedToCloud ? undefined : "noopener noreferrer"}
|
| 431 |
+
data-testid="backend-selector-settings-link"
|
| 432 |
+
aria-label={settingsLabel}
|
| 433 |
+
className={cn(
|
| 434 |
+
"inline-flex items-center justify-center shrink-0 w-9 h-9 rounded-md text-[var(--oh-muted)] hover:text-white hover:bg-[var(--oh-surface-raised)] cursor-pointer",
|
| 435 |
+
formControlTransitionClassName,
|
| 436 |
+
)}
|
| 437 |
+
>
|
| 438 |
+
<Settings width={16} height={16} />
|
| 439 |
+
</a>
|
| 440 |
+
) : (
|
| 441 |
+
<NavigationLink
|
| 442 |
+
to="/settings"
|
| 443 |
+
data-testid="backend-selector-settings-link"
|
| 444 |
+
data-active={isSettingsActive}
|
| 445 |
+
aria-label={settingsLabel}
|
| 446 |
+
className={
|
| 447 |
+
isSettingsActive
|
| 448 |
+
? cn(
|
| 449 |
+
"inline-flex items-center justify-center shrink-0 w-9 h-9 rounded-md bg-tertiary text-white font-normal cursor-pointer",
|
| 450 |
+
formControlTransitionClassName,
|
| 451 |
+
)
|
| 452 |
+
: cn(
|
| 453 |
+
"inline-flex items-center justify-center shrink-0 w-9 h-9 rounded-md text-[var(--oh-muted)] hover:text-white hover:bg-[var(--oh-surface-raised)] cursor-pointer",
|
| 454 |
+
formControlTransitionClassName,
|
| 455 |
+
)
|
| 456 |
+
}
|
| 457 |
+
>
|
| 458 |
+
<Settings width={16} height={16} />
|
| 459 |
+
</NavigationLink>
|
| 460 |
+
)}
|
| 461 |
+
</StyledTooltip>
|
| 462 |
+
) : null}
|
| 463 |
+
</div>
|
| 464 |
+
{addBackendModalOpen ? (
|
| 465 |
+
<AddBackendModal onClose={() => setAddBackendModalOpen(false)} />
|
| 466 |
+
) : null}
|
| 467 |
+
{manageBackendsModalOpen ? (
|
| 468 |
+
<ManageBackendsModal
|
| 469 |
+
onClose={() => setManageBackendsModalOpen(false)}
|
| 470 |
+
/>
|
| 471 |
+
) : null}
|
| 472 |
+
</>
|
| 473 |
+
);
|
| 474 |
+
}
|
src/components/features/backends/backend-status-dot.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { cn } from "#/utils/utils";
|
| 3 |
+
|
| 4 |
+
interface BackendStatusDotProps {
|
| 5 |
+
/** `null` while the first probe is in flight. */
|
| 6 |
+
isConnected: boolean | null | "unavailable";
|
| 7 |
+
className?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Small colored dot that reflects backend reachability:
|
| 12 |
+
* - green when connected
|
| 13 |
+
* - red when disconnected
|
| 14 |
+
* - dim gray while the first probe is in flight
|
| 15 |
+
*/
|
| 16 |
+
export function BackendStatusDot({
|
| 17 |
+
isConnected,
|
| 18 |
+
className,
|
| 19 |
+
}: BackendStatusDotProps) {
|
| 20 |
+
let color: string;
|
| 21 |
+
let label: string;
|
| 22 |
+
let status: string;
|
| 23 |
+
if (isConnected === "unavailable") {
|
| 24 |
+
color = "bg-[var(--oh-text-tertiary)]";
|
| 25 |
+
label = "No Backend Available";
|
| 26 |
+
status = "unavailable";
|
| 27 |
+
} else if (isConnected === true) {
|
| 28 |
+
color = "bg-[var(--oh-status-success)]";
|
| 29 |
+
label = "Connected";
|
| 30 |
+
status = "connected";
|
| 31 |
+
} else if (isConnected === false) {
|
| 32 |
+
color = "bg-red-500";
|
| 33 |
+
label = "Disconnected";
|
| 34 |
+
status = "disconnected";
|
| 35 |
+
} else {
|
| 36 |
+
color = "bg-[var(--oh-interactive-selected)]";
|
| 37 |
+
label = "Checking connection";
|
| 38 |
+
status = "checking";
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<span
|
| 43 |
+
data-testid="backend-status-dot"
|
| 44 |
+
data-status={status}
|
| 45 |
+
aria-label={label}
|
| 46 |
+
title={label}
|
| 47 |
+
role="status"
|
| 48 |
+
className={cn(
|
| 49 |
+
"inline-block w-2 h-2 rounded-full shrink-0",
|
| 50 |
+
color,
|
| 51 |
+
className,
|
| 52 |
+
)}
|
| 53 |
+
/>
|
| 54 |
+
);
|
| 55 |
+
}
|
src/components/features/backends/backend-status-label.test.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { TFunction } from "i18next";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import {
|
| 4 |
+
INVALID_BACKEND_API_KEY_ERROR,
|
| 5 |
+
MISSING_BACKEND_API_KEY_ERROR,
|
| 6 |
+
} from "#/hooks/query/use-backends-health";
|
| 7 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 8 |
+
import { CORS_OR_NETWORK_ERROR_MESSAGE } from "#/utils/user-facing-error";
|
| 9 |
+
import { getBackendStatusLabel } from "./backend-status-label";
|
| 10 |
+
|
| 11 |
+
const t = ((key: string) => key) as TFunction<"openhands">;
|
| 12 |
+
|
| 13 |
+
describe("getBackendStatusLabel", () => {
|
| 14 |
+
it("prefers add API key for Cloud backends with a blank API key", () => {
|
| 15 |
+
expect(
|
| 16 |
+
getBackendStatusLabel(
|
| 17 |
+
t,
|
| 18 |
+
{ kind: "cloud", apiKey: "" },
|
| 19 |
+
{ isConnected: false, lastError: CORS_OR_NETWORK_ERROR_MESSAGE },
|
| 20 |
+
),
|
| 21 |
+
).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_ADD_API_KEY);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it("maps Cloud browser-network failures to API key or network guidance", () => {
|
| 25 |
+
expect(
|
| 26 |
+
getBackendStatusLabel(
|
| 27 |
+
t,
|
| 28 |
+
{ kind: "cloud", apiKey: "oh-cloud-key" },
|
| 29 |
+
{ isConnected: false, lastError: CORS_OR_NETWORK_ERROR_MESSAGE },
|
| 30 |
+
),
|
| 31 |
+
).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_CLOUD_ACCESS);
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
it("keeps local browser-network failures generic", () => {
|
| 35 |
+
expect(
|
| 36 |
+
getBackendStatusLabel(
|
| 37 |
+
t,
|
| 38 |
+
{ kind: "local", apiKey: "" },
|
| 39 |
+
{ isConnected: false, lastError: CORS_OR_NETWORK_ERROR_MESSAGE },
|
| 40 |
+
),
|
| 41 |
+
).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_URL_OR_NETWORK);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
it("returns connected when the health probe is connected", () => {
|
| 45 |
+
expect(
|
| 46 |
+
getBackendStatusLabel(
|
| 47 |
+
t,
|
| 48 |
+
{ kind: "local", apiKey: "" },
|
| 49 |
+
{ isConnected: true, lastError: null },
|
| 50 |
+
),
|
| 51 |
+
).toBe(I18nKey.ONBOARDING$BACKEND_STATUS_CONNECTED);
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it("returns disconnected for failed probes without a more specific reason", () => {
|
| 55 |
+
expect(
|
| 56 |
+
getBackendStatusLabel(
|
| 57 |
+
t,
|
| 58 |
+
{ kind: "local", apiKey: "" },
|
| 59 |
+
{ isConnected: false, lastError: "Unexpected failure" },
|
| 60 |
+
),
|
| 61 |
+
).toBe(I18nKey.ONBOARDING$BACKEND_STATUS_DISCONNECTED);
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
it("returns checking while the health probe is unresolved", () => {
|
| 65 |
+
expect(
|
| 66 |
+
getBackendStatusLabel(
|
| 67 |
+
t,
|
| 68 |
+
{ kind: "local", apiKey: "" },
|
| 69 |
+
{ isConnected: null, lastError: null },
|
| 70 |
+
),
|
| 71 |
+
).toBe(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING);
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
it("maps request timeouts to tunnel guidance", () => {
|
| 75 |
+
expect(
|
| 76 |
+
getBackendStatusLabel(
|
| 77 |
+
t,
|
| 78 |
+
{ kind: "local", apiKey: "" },
|
| 79 |
+
{ isConnected: false, lastError: "Backend request timed out" },
|
| 80 |
+
),
|
| 81 |
+
).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_TUNNEL);
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
it("maps missing and invalid API key health errors to credential guidance", () => {
|
| 85 |
+
expect(
|
| 86 |
+
getBackendStatusLabel(
|
| 87 |
+
t,
|
| 88 |
+
{ kind: "cloud", apiKey: "oh-cloud-key" },
|
| 89 |
+
{ isConnected: false, lastError: MISSING_BACKEND_API_KEY_ERROR },
|
| 90 |
+
),
|
| 91 |
+
).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_ADD_API_KEY);
|
| 92 |
+
|
| 93 |
+
expect(
|
| 94 |
+
getBackendStatusLabel(
|
| 95 |
+
t,
|
| 96 |
+
{ kind: "cloud", apiKey: "oh-cloud-key" },
|
| 97 |
+
{ isConnected: false, lastError: INVALID_BACKEND_API_KEY_ERROR },
|
| 98 |
+
),
|
| 99 |
+
).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_API_KEY);
|
| 100 |
+
});
|
| 101 |
+
});
|
src/components/features/backends/backend-status-label.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { TFunction } from "i18next";
|
| 2 |
+
import {
|
| 3 |
+
isCloudBackendApiKeyOrNetworkHealthError,
|
| 4 |
+
isCloudBackendLoggedOutHealthError,
|
| 5 |
+
isInvalidBackendApiKeyHealthError,
|
| 6 |
+
isMissingBackendApiKeyHealthError,
|
| 7 |
+
} from "#/hooks/query/use-backends-health";
|
| 8 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 9 |
+
import {
|
| 10 |
+
isBackendRequestTimeoutMessage,
|
| 11 |
+
isCorsOrNetworkErrorMessage,
|
| 12 |
+
} from "#/utils/user-facing-error";
|
| 13 |
+
|
| 14 |
+
interface BackendStatusLabelHealth {
|
| 15 |
+
isConnected?: boolean | null;
|
| 16 |
+
lastError?: string | null;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function getBackendStatusLabel(
|
| 20 |
+
t: TFunction<"openhands">,
|
| 21 |
+
backend:
|
| 22 |
+
| {
|
| 23 |
+
kind?: "local" | "cloud";
|
| 24 |
+
apiKey?: string | null;
|
| 25 |
+
}
|
| 26 |
+
| undefined,
|
| 27 |
+
health: BackendStatusLabelHealth | undefined,
|
| 28 |
+
): string {
|
| 29 |
+
const lastError = health?.lastError ?? null;
|
| 30 |
+
const isCloud = backend?.kind === "cloud";
|
| 31 |
+
|
| 32 |
+
if (isCloud && !backend?.apiKey?.trim()) {
|
| 33 |
+
return t(I18nKey.BACKEND$STATUS_DISCONNECTED_ADD_API_KEY);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
if (isMissingBackendApiKeyHealthError(lastError)) {
|
| 37 |
+
return t(I18nKey.BACKEND$STATUS_DISCONNECTED_ADD_API_KEY);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (isInvalidBackendApiKeyHealthError(lastError)) {
|
| 41 |
+
return t(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_API_KEY);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (isCloudBackendLoggedOutHealthError(lastError)) {
|
| 45 |
+
return t(I18nKey.BACKEND$LOGGED_OUT);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (health?.isConnected === true) {
|
| 49 |
+
return t(I18nKey.ONBOARDING$BACKEND_STATUS_CONNECTED);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (
|
| 53 |
+
isCloud &&
|
| 54 |
+
health?.isConnected === false &&
|
| 55 |
+
(isCloudBackendApiKeyOrNetworkHealthError(lastError) ||
|
| 56 |
+
isCorsOrNetworkErrorMessage(lastError))
|
| 57 |
+
) {
|
| 58 |
+
return t(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_CLOUD_ACCESS);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (
|
| 62 |
+
health?.isConnected === false &&
|
| 63 |
+
isBackendRequestTimeoutMessage(lastError)
|
| 64 |
+
) {
|
| 65 |
+
return t(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_TUNNEL);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (health?.isConnected === false && isCorsOrNetworkErrorMessage(lastError)) {
|
| 69 |
+
return t(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_URL_OR_NETWORK);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if (health?.isConnected === false) {
|
| 73 |
+
return t(I18nKey.ONBOARDING$BACKEND_STATUS_DISCONNECTED);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
return t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING);
|
| 77 |
+
}
|
src/components/features/backends/backend-version.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useTranslation } from "react-i18next";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
+
|
| 4 |
+
import { ServerClient } from "@openhands/typescript-client/clients";
|
| 5 |
+
import { type Backend } from "#/api/backend-registry/types";
|
| 6 |
+
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
|
| 7 |
+
import { getDisplayAgentServerVersion } from "#/api/agent-server-compatibility";
|
| 8 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 9 |
+
|
| 10 |
+
export function BackendVersion({ backend }: { backend: Backend }) {
|
| 11 |
+
const { t } = useTranslation("openhands");
|
| 12 |
+
const { data: version } = useQuery({
|
| 13 |
+
queryKey: ["backend-version", backend.host, backend.apiKey],
|
| 14 |
+
queryFn: async () => {
|
| 15 |
+
const info = await new ServerClient(
|
| 16 |
+
getAgentServerClientOptions({
|
| 17 |
+
host: backend.host,
|
| 18 |
+
sessionApiKey: backend.apiKey || null,
|
| 19 |
+
timeout: 5000,
|
| 20 |
+
}),
|
| 21 |
+
).getServerInfo();
|
| 22 |
+
return getDisplayAgentServerVersion(info);
|
| 23 |
+
},
|
| 24 |
+
retry: false,
|
| 25 |
+
staleTime: 60_000,
|
| 26 |
+
enabled: backend.kind === "local",
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
if (!version) return null;
|
| 30 |
+
|
| 31 |
+
return (
|
| 32 |
+
<span
|
| 33 |
+
className="inline-flex shrink-0 items-center rounded-full border border-[var(--oh-border)] bg-[var(--oh-surface)] px-1.5 py-0.5 text-[10px] font-medium leading-none text-[var(--oh-text-dim)]"
|
| 34 |
+
data-testid={`manage-backends-version-${backend.name}`}
|
| 35 |
+
>
|
| 36 |
+
{t(I18nKey.BACKEND$VERSION_LABEL, { version })}
|
| 37 |
+
</span>
|
| 38 |
+
);
|
| 39 |
+
}
|
src/components/features/backends/device-flow-auth.tsx
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { useTranslation } from "react-i18next";
|
| 3 |
+
import { BrandButton } from "#/components/features/settings/brand-button";
|
| 4 |
+
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
| 5 |
+
import { ModalBody } from "#/components/shared/modals/modal-body";
|
| 6 |
+
import { useDeviceFlow, type DeviceFlowStatus } from "#/hooks/use-device-flow";
|
| 7 |
+
import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics";
|
| 8 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 9 |
+
import { cn } from "#/utils/utils";
|
| 10 |
+
|
| 11 |
+
type DeviceFlowButtonVariant =
|
| 12 |
+
| "primary"
|
| 13 |
+
| "secondary"
|
| 14 |
+
| "tertiary"
|
| 15 |
+
| "unstyled";
|
| 16 |
+
type DeviceFlowStatusDisplay = "inline" | "modal";
|
| 17 |
+
|
| 18 |
+
interface DeviceFlowAuthProps {
|
| 19 |
+
/** The host URL for the cloud backend */
|
| 20 |
+
host: string;
|
| 21 |
+
/** Callback when authentication succeeds with the API key */
|
| 22 |
+
onSuccess: (apiKey: string) => void;
|
| 23 |
+
/** Test ID prefix for the component */
|
| 24 |
+
testIdRoot: string;
|
| 25 |
+
/** Whether the login button should be disabled (e.g., when no host is entered) */
|
| 26 |
+
isDisabled?: boolean;
|
| 27 |
+
/** Override for the idle button label and icon-only accessible name. */
|
| 28 |
+
idleButtonLabel?: string;
|
| 29 |
+
/** Optional visible content for the idle button. Defaults to the idle label. */
|
| 30 |
+
idleButtonContent?: React.ReactNode;
|
| 31 |
+
/** Optional content shown only before authentication starts. */
|
| 32 |
+
idleDescription?: React.ReactNode;
|
| 33 |
+
/**
|
| 34 |
+
* Optional content rendered under the idle button and, like
|
| 35 |
+
* `idleDescription`, only before authentication starts — so secondary
|
| 36 |
+
* controls step aside once the flow takes over the surface.
|
| 37 |
+
*/
|
| 38 |
+
idleFooter?: React.ReactNode;
|
| 39 |
+
/** Optional classes for the root wrapper. */
|
| 40 |
+
className?: string;
|
| 41 |
+
/** Optional classes for the idle button. */
|
| 42 |
+
buttonClassName?: string;
|
| 43 |
+
/** Visual variant for the idle button. */
|
| 44 |
+
buttonVariant?: DeviceFlowButtonVariant;
|
| 45 |
+
/** Whether in-progress auth content should render inline or in a modal. */
|
| 46 |
+
statusDisplay?: DeviceFlowStatusDisplay;
|
| 47 |
+
/** Product surface that initiated this authorization attempt. */
|
| 48 |
+
analyticsSource?: CloudConnectionSource;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Device Flow authentication UI component.
|
| 53 |
+
*
|
| 54 |
+
* Shows a "Connect to OpenHands" button that initiates OAuth 2.0 Device Flow
|
| 55 |
+
* authentication. Displays status during the auth process and auto-opens
|
| 56 |
+
* the browser for user authorization.
|
| 57 |
+
*/
|
| 58 |
+
/**
|
| 59 |
+
* Validate that a URL is safe to open in a popup.
|
| 60 |
+
* Prevents XSS via javascript: URLs or other malicious schemes.
|
| 61 |
+
*/
|
| 62 |
+
function isValidVerificationUrl(url: string): boolean {
|
| 63 |
+
try {
|
| 64 |
+
const parsed = new URL(url);
|
| 65 |
+
return parsed.protocol === "https:";
|
| 66 |
+
} catch {
|
| 67 |
+
return false;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function DeviceFlowAuth({
|
| 72 |
+
host,
|
| 73 |
+
onSuccess,
|
| 74 |
+
testIdRoot,
|
| 75 |
+
isDisabled = false,
|
| 76 |
+
idleButtonLabel,
|
| 77 |
+
idleButtonContent,
|
| 78 |
+
idleDescription,
|
| 79 |
+
idleFooter,
|
| 80 |
+
className,
|
| 81 |
+
buttonClassName,
|
| 82 |
+
buttonVariant = "primary",
|
| 83 |
+
statusDisplay = "inline",
|
| 84 |
+
analyticsSource,
|
| 85 |
+
}: DeviceFlowAuthProps) {
|
| 86 |
+
const { t } = useTranslation("openhands");
|
| 87 |
+
const deviceFlow = useDeviceFlow();
|
| 88 |
+
const popupRef = React.useRef<Window | null>(null);
|
| 89 |
+
|
| 90 |
+
// Close popup on unmount or when auth completes/errors
|
| 91 |
+
React.useEffect(() => {
|
| 92 |
+
return () => {
|
| 93 |
+
popupRef.current?.close();
|
| 94 |
+
};
|
| 95 |
+
}, []);
|
| 96 |
+
|
| 97 |
+
// Update popup URL when verification URL becomes available
|
| 98 |
+
React.useEffect(() => {
|
| 99 |
+
if (
|
| 100 |
+
deviceFlow.status === "awaiting_authorization" &&
|
| 101 |
+
deviceFlow.verificationUrl &&
|
| 102 |
+
popupRef.current &&
|
| 103 |
+
!popupRef.current.closed
|
| 104 |
+
) {
|
| 105 |
+
// Validate URL before assigning to prevent XSS
|
| 106 |
+
if (!isValidVerificationUrl(deviceFlow.verificationUrl)) {
|
| 107 |
+
console.error("Invalid verification URL protocol");
|
| 108 |
+
return;
|
| 109 |
+
}
|
| 110 |
+
try {
|
| 111 |
+
popupRef.current.location.href = deviceFlow.verificationUrl;
|
| 112 |
+
} catch {
|
| 113 |
+
// Cross-origin error - popup was navigated away
|
| 114 |
+
// Open a new one as fallback
|
| 115 |
+
popupRef.current = window.open(
|
| 116 |
+
deviceFlow.verificationUrl,
|
| 117 |
+
"_blank",
|
| 118 |
+
"noopener,noreferrer",
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}, [deviceFlow.status, deviceFlow.verificationUrl]);
|
| 123 |
+
|
| 124 |
+
// Call onSuccess when authentication completes
|
| 125 |
+
React.useEffect(() => {
|
| 126 |
+
if (deviceFlow.status === "success" && deviceFlow.apiKey) {
|
| 127 |
+
try {
|
| 128 |
+
onSuccess(deviceFlow.apiKey);
|
| 129 |
+
} finally {
|
| 130 |
+
deviceFlow.reset();
|
| 131 |
+
popupRef.current?.close();
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
}, [deviceFlow.status, deviceFlow.apiKey, deviceFlow.reset, onSuccess]);
|
| 135 |
+
|
| 136 |
+
const handleStartAuth = () => {
|
| 137 |
+
// Normalize and validate the host URL
|
| 138 |
+
const normalizedHost = host.trim().replace(/\/+$/, "");
|
| 139 |
+
const fullHost = /^https?:\/\//i.test(normalizedHost)
|
| 140 |
+
? normalizedHost
|
| 141 |
+
: `https://${normalizedHost}`;
|
| 142 |
+
|
| 143 |
+
// Validate URL before proceeding
|
| 144 |
+
try {
|
| 145 |
+
const url = new URL(fullHost);
|
| 146 |
+
// Check for URL manipulation attacks
|
| 147 |
+
if (url.username || url.password) {
|
| 148 |
+
throw new Error("Invalid URL format");
|
| 149 |
+
}
|
| 150 |
+
} catch {
|
| 151 |
+
return; // Invalid URL, don't proceed
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Open popup immediately on user click to avoid popup blocker
|
| 155 |
+
// Start with about:blank and update URL once we have verification URL
|
| 156 |
+
// Note: We intentionally don't use "noopener" here because we need to
|
| 157 |
+
// maintain a reference to update the popup's location when the
|
| 158 |
+
// verification URL becomes available
|
| 159 |
+
popupRef.current = window.open("about:blank", "_blank");
|
| 160 |
+
|
| 161 |
+
if (!popupRef.current) {
|
| 162 |
+
// Popup was blocked - flow will still work, user can click manual link
|
| 163 |
+
console.warn("Popup blocked - user will need to use manual link");
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
deviceFlow.start(fullHost, analyticsSource);
|
| 167 |
+
};
|
| 168 |
+
|
| 169 |
+
const handleCancel = () => {
|
| 170 |
+
deviceFlow.cancel();
|
| 171 |
+
popupRef.current?.close();
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
const statusContent = (
|
| 175 |
+
<DeviceFlowStatusContent
|
| 176 |
+
status={deviceFlow.status}
|
| 177 |
+
error={deviceFlow.error}
|
| 178 |
+
verificationUrl={deviceFlow.verificationUrl}
|
| 179 |
+
testIdRoot={testIdRoot}
|
| 180 |
+
onCancel={handleCancel}
|
| 181 |
+
onRetry={handleStartAuth}
|
| 182 |
+
/>
|
| 183 |
+
);
|
| 184 |
+
const showStatusModal =
|
| 185 |
+
statusDisplay === "modal" && deviceFlow.status !== "idle";
|
| 186 |
+
const idleLabel = idleButtonLabel ?? t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS);
|
| 187 |
+
|
| 188 |
+
return (
|
| 189 |
+
<div
|
| 190 |
+
data-testid={`${testIdRoot}-device-flow`}
|
| 191 |
+
className={cn("flex flex-col gap-3", className)}
|
| 192 |
+
>
|
| 193 |
+
{deviceFlow.status === "idle" ? idleDescription : null}
|
| 194 |
+
|
| 195 |
+
{deviceFlow.status === "idle" && buttonVariant === "unstyled" && (
|
| 196 |
+
<button
|
| 197 |
+
type="button"
|
| 198 |
+
onClick={handleStartAuth}
|
| 199 |
+
data-testid={`${testIdRoot}-login-button`}
|
| 200 |
+
className={buttonClassName}
|
| 201 |
+
disabled={isDisabled}
|
| 202 |
+
aria-label={idleButtonContent ? idleLabel : undefined}
|
| 203 |
+
>
|
| 204 |
+
{idleButtonContent ?? idleLabel}
|
| 205 |
+
</button>
|
| 206 |
+
)}
|
| 207 |
+
|
| 208 |
+
{deviceFlow.status === "idle" && buttonVariant !== "unstyled" && (
|
| 209 |
+
<BrandButton
|
| 210 |
+
type="button"
|
| 211 |
+
variant={buttonVariant}
|
| 212 |
+
onClick={handleStartAuth}
|
| 213 |
+
testId={`${testIdRoot}-login-button`}
|
| 214 |
+
className={buttonClassName}
|
| 215 |
+
isDisabled={isDisabled}
|
| 216 |
+
ariaLabel={idleButtonContent ? idleLabel : undefined}
|
| 217 |
+
>
|
| 218 |
+
{idleButtonContent ?? idleLabel}
|
| 219 |
+
</BrandButton>
|
| 220 |
+
)}
|
| 221 |
+
|
| 222 |
+
{deviceFlow.status === "idle" ? idleFooter : null}
|
| 223 |
+
|
| 224 |
+
{statusDisplay === "inline" ? statusContent : null}
|
| 225 |
+
|
| 226 |
+
{showStatusModal ? (
|
| 227 |
+
<ModalBackdrop
|
| 228 |
+
onClose={handleCancel}
|
| 229 |
+
aria-label={t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS)}
|
| 230 |
+
closeOnBackdropClick={false}
|
| 231 |
+
>
|
| 232 |
+
<ModalBody
|
| 233 |
+
testID={`${testIdRoot}-auth-modal`}
|
| 234 |
+
width="sm"
|
| 235 |
+
className="items-stretch border border-[var(--oh-border)]"
|
| 236 |
+
>
|
| 237 |
+
{statusContent}
|
| 238 |
+
</ModalBody>
|
| 239 |
+
</ModalBackdrop>
|
| 240 |
+
) : null}
|
| 241 |
+
</div>
|
| 242 |
+
);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
interface DeviceFlowStatusContentProps {
|
| 246 |
+
status: DeviceFlowStatus;
|
| 247 |
+
error: string | null;
|
| 248 |
+
verificationUrl: string | null;
|
| 249 |
+
testIdRoot: string;
|
| 250 |
+
onCancel: () => void;
|
| 251 |
+
onRetry: () => void;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
function DeviceFlowStatusContent({
|
| 255 |
+
status,
|
| 256 |
+
error,
|
| 257 |
+
verificationUrl,
|
| 258 |
+
testIdRoot,
|
| 259 |
+
onCancel,
|
| 260 |
+
onRetry,
|
| 261 |
+
}: DeviceFlowStatusContentProps) {
|
| 262 |
+
const { t } = useTranslation("openhands");
|
| 263 |
+
|
| 264 |
+
if (status === "idle" || status === "success") return null;
|
| 265 |
+
|
| 266 |
+
if (status === "starting") {
|
| 267 |
+
return (
|
| 268 |
+
<div
|
| 269 |
+
className="flex items-center justify-center gap-2"
|
| 270 |
+
data-testid={`${testIdRoot}-auth-starting`}
|
| 271 |
+
role="status"
|
| 272 |
+
aria-live="polite"
|
| 273 |
+
>
|
| 274 |
+
<LoadingSpinner />
|
| 275 |
+
<span className="text-sm text-[var(--oh-text-tertiary)]">
|
| 276 |
+
{t(I18nKey.BACKEND$AUTH_STARTING)}
|
| 277 |
+
</span>
|
| 278 |
+
</div>
|
| 279 |
+
);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
if (status === "awaiting_authorization") {
|
| 283 |
+
const validVerificationUrl =
|
| 284 |
+
verificationUrl && isValidVerificationUrl(verificationUrl)
|
| 285 |
+
? verificationUrl
|
| 286 |
+
: null;
|
| 287 |
+
|
| 288 |
+
return (
|
| 289 |
+
<div
|
| 290 |
+
className="flex flex-col gap-4"
|
| 291 |
+
data-testid={`${testIdRoot}-auth-awaiting`}
|
| 292 |
+
role="status"
|
| 293 |
+
aria-live="polite"
|
| 294 |
+
>
|
| 295 |
+
<div className="flex items-center justify-center gap-2">
|
| 296 |
+
<LoadingSpinner />
|
| 297 |
+
<span className="text-sm font-medium text-white">
|
| 298 |
+
{t(I18nKey.BACKEND$AUTH_AWAITING)}
|
| 299 |
+
</span>
|
| 300 |
+
</div>
|
| 301 |
+
<div className="flex flex-col gap-1">
|
| 302 |
+
<p className="text-center text-sm leading-5 text-[var(--oh-text-tertiary)]">
|
| 303 |
+
{validVerificationUrl
|
| 304 |
+
? `${t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)} ${t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}`
|
| 305 |
+
: t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)}
|
| 306 |
+
</p>
|
| 307 |
+
{validVerificationUrl ? (
|
| 308 |
+
<a
|
| 309 |
+
href={validVerificationUrl}
|
| 310 |
+
target="_blank"
|
| 311 |
+
rel="noopener noreferrer"
|
| 312 |
+
className="max-h-16 overflow-auto break-all text-center text-xs text-blue-400 hover:underline"
|
| 313 |
+
>
|
| 314 |
+
{validVerificationUrl}
|
| 315 |
+
</a>
|
| 316 |
+
) : null}
|
| 317 |
+
</div>
|
| 318 |
+
<BrandButton
|
| 319 |
+
type="button"
|
| 320 |
+
variant="secondary"
|
| 321 |
+
onClick={onCancel}
|
| 322 |
+
testId={`${testIdRoot}-auth-cancel`}
|
| 323 |
+
className="w-full"
|
| 324 |
+
>
|
| 325 |
+
{t(I18nKey.BACKEND$AUTH_CANCEL)}
|
| 326 |
+
</BrandButton>
|
| 327 |
+
</div>
|
| 328 |
+
);
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
return (
|
| 332 |
+
<div
|
| 333 |
+
className="flex flex-col gap-3 rounded-lg border border-red-700 bg-red-900/20 p-4"
|
| 334 |
+
data-testid={`${testIdRoot}-auth-error`}
|
| 335 |
+
role="alert"
|
| 336 |
+
>
|
| 337 |
+
<p className="text-sm text-red-400">{error}</p>
|
| 338 |
+
<BrandButton
|
| 339 |
+
type="button"
|
| 340 |
+
variant="secondary"
|
| 341 |
+
onClick={onRetry}
|
| 342 |
+
testId={`${testIdRoot}-auth-retry`}
|
| 343 |
+
className="w-full"
|
| 344 |
+
>
|
| 345 |
+
{t(I18nKey.BACKEND$AUTH_RETRY)}
|
| 346 |
+
</BrandButton>
|
| 347 |
+
</div>
|
| 348 |
+
);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
function LoadingSpinner() {
|
| 352 |
+
return (
|
| 353 |
+
<svg
|
| 354 |
+
className="animate-spin h-4 w-4 text-white"
|
| 355 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 356 |
+
fill="none"
|
| 357 |
+
viewBox="0 0 24 24"
|
| 358 |
+
aria-hidden="true"
|
| 359 |
+
>
|
| 360 |
+
<path
|
| 361 |
+
className="opacity-75"
|
| 362 |
+
fill="currentColor"
|
| 363 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
| 364 |
+
/>
|
| 365 |
+
</svg>
|
| 366 |
+
);
|
| 367 |
+
}
|
src/components/features/backends/environment-switch-overlay.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { createPortal } from "react-dom";
|
| 3 |
+
import { useTranslation } from "react-i18next";
|
| 4 |
+
import { I18nKey } from "#/i18n/declaration";
|
| 5 |
+
import {
|
| 6 |
+
getEnvironmentSwitchSnapshot,
|
| 7 |
+
subscribeEnvironmentSwitch,
|
| 8 |
+
} from "./environment-switch-store";
|
| 9 |
+
|
| 10 |
+
// Re-export the store API so existing call sites and tests that import from
|
| 11 |
+
// this module keep working unchanged.
|
| 12 |
+
export {
|
| 13 |
+
ENVIRONMENT_SWITCH_DURATION_MS,
|
| 14 |
+
ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS,
|
| 15 |
+
triggerEnvironmentSwitch,
|
| 16 |
+
dismissEnvironmentSwitch,
|
| 17 |
+
__resetEnvironmentSwitchOverlayForTests,
|
| 18 |
+
} from "./environment-switch-store";
|
| 19 |
+
|
| 20 |
+
function EnvironmentSwitchIcon({ className }: { className?: string }) {
|
| 21 |
+
return (
|
| 22 |
+
<svg
|
| 23 |
+
viewBox="0 0 74.17 22"
|
| 24 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 25 |
+
className={className}
|
| 26 |
+
aria-hidden
|
| 27 |
+
>
|
| 28 |
+
<g>
|
| 29 |
+
<rect
|
| 30 |
+
x="1"
|
| 31 |
+
y="1"
|
| 32 |
+
width="20"
|
| 33 |
+
height="8"
|
| 34 |
+
rx="2"
|
| 35 |
+
ry="2"
|
| 36 |
+
fill="none"
|
| 37 |
+
stroke="currentColor"
|
| 38 |
+
strokeWidth="1.5"
|
| 39 |
+
/>
|
| 40 |
+
<rect
|
| 41 |
+
x="1"
|
| 42 |
+
y="13"
|
| 43 |
+
width="20"
|
| 44 |
+
height="8"
|
| 45 |
+
rx="2"
|
| 46 |
+
ry="2"
|
| 47 |
+
fill="none"
|
| 48 |
+
stroke="currentColor"
|
| 49 |
+
strokeWidth="1.5"
|
| 50 |
+
/>
|
| 51 |
+
<line
|
| 52 |
+
x1="5"
|
| 53 |
+
y1="5"
|
| 54 |
+
x2="5.01"
|
| 55 |
+
y2="5"
|
| 56 |
+
stroke="currentColor"
|
| 57 |
+
strokeWidth="1.5"
|
| 58 |
+
strokeLinecap="round"
|
| 59 |
+
/>
|
| 60 |
+
<line
|
| 61 |
+
x1="5"
|
| 62 |
+
y1="17"
|
| 63 |
+
x2="5.01"
|
| 64 |
+
y2="17"
|
| 65 |
+
stroke="currentColor"
|
| 66 |
+
strokeWidth="1.5"
|
| 67 |
+
strokeLinecap="round"
|
| 68 |
+
/>
|
| 69 |
+
</g>
|
| 70 |
+
<g>
|
| 71 |
+
<rect
|
| 72 |
+
x="53.17"
|
| 73 |
+
y="1"
|
| 74 |
+
width="20"
|
| 75 |
+
height="8"
|
| 76 |
+
rx="2"
|
| 77 |
+
ry="2"
|
| 78 |
+
fill="none"
|
| 79 |
+
stroke="currentColor"
|
| 80 |
+
strokeWidth="1.5"
|
| 81 |
+
/>
|
| 82 |
+
<rect
|
| 83 |
+
x="53.17"
|
| 84 |
+
y="13"
|
| 85 |
+
width="20"
|
| 86 |
+
height="8"
|
| 87 |
+
rx="2"
|
| 88 |
+
ry="2"
|
| 89 |
+
fill="none"
|
| 90 |
+
stroke="currentColor"
|
| 91 |
+
strokeWidth="1.5"
|
| 92 |
+
/>
|
| 93 |
+
<line
|
| 94 |
+
x1="57.17"
|
| 95 |
+
y1="5"
|
| 96 |
+
x2="57.18"
|
| 97 |
+
y2="5"
|
| 98 |
+
stroke="currentColor"
|
| 99 |
+
strokeWidth="1.5"
|
| 100 |
+
strokeLinecap="round"
|
| 101 |
+
/>
|
| 102 |
+
<line
|
| 103 |
+
x1="57.17"
|
| 104 |
+
y1="17"
|
| 105 |
+
x2="57.18"
|
| 106 |
+
y2="17"
|
| 107 |
+
stroke="currentColor"
|
| 108 |
+
strokeWidth="1.5"
|
| 109 |
+
strokeLinecap="round"
|
| 110 |
+
/>
|
| 111 |
+
</g>
|
| 112 |
+
<g>
|
| 113 |
+
<path
|
| 114 |
+
d="M43.09,7l4,4-4,4"
|
| 115 |
+
fill="none"
|
| 116 |
+
stroke="currentColor"
|
| 117 |
+
strokeWidth="1.5"
|
| 118 |
+
strokeLinecap="round"
|
| 119 |
+
strokeLinejoin="round"
|
| 120 |
+
/>
|
| 121 |
+
<path
|
| 122 |
+
d="M27.09,11h20"
|
| 123 |
+
fill="none"
|
| 124 |
+
stroke="currentColor"
|
| 125 |
+
strokeWidth="1.5"
|
| 126 |
+
strokeLinecap="round"
|
| 127 |
+
strokeLinejoin="round"
|
| 128 |
+
/>
|
| 129 |
+
<path
|
| 130 |
+
d="M31.09,7l-4,4,4,4"
|
| 131 |
+
fill="none"
|
| 132 |
+
stroke="currentColor"
|
| 133 |
+
strokeWidth="1.5"
|
| 134 |
+
strokeLinecap="round"
|
| 135 |
+
strokeLinejoin="round"
|
| 136 |
+
/>
|
| 137 |
+
</g>
|
| 138 |
+
</svg>
|
| 139 |
+
);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
export function EnvironmentSwitchOverlay() {
|
| 143 |
+
const { t } = useTranslation("openhands");
|
| 144 |
+
const { visible, target } = React.useSyncExternalStore(
|
| 145 |
+
subscribeEnvironmentSwitch,
|
| 146 |
+
getEnvironmentSwitchSnapshot,
|
| 147 |
+
getEnvironmentSwitchSnapshot,
|
| 148 |
+
);
|
| 149 |
+
|
| 150 |
+
if (!visible || typeof document === "undefined") return null;
|
| 151 |
+
|
| 152 |
+
return createPortal(
|
| 153 |
+
<div
|
| 154 |
+
data-testid="environment-switch-overlay"
|
| 155 |
+
data-target={target}
|
| 156 |
+
className="environment-switch-overlay pointer-events-none fixed inset-0 z-[2147483646] flex items-center justify-center"
|
| 157 |
+
aria-live="polite"
|
| 158 |
+
aria-atomic="true"
|
| 159 |
+
>
|
| 160 |
+
<div className="pointer-events-none flex min-w-[280px] max-w-[420px] flex-col items-center gap-2 rounded-xl border border-[var(--oh-border)] bg-[var(--oh-surface)] px-5 py-4 text-[var(--oh-foreground)] shadow-2xl">
|
| 161 |
+
<EnvironmentSwitchIcon className="mb-2 h-6 w-20 shrink-0 text-[var(--oh-foreground)]" />
|
| 162 |
+
<p className="text-center text-sm font-medium">
|
| 163 |
+
{t(I18nKey.BACKEND$SWITCHING_TO, { environment: target })}
|
| 164 |
+
</p>
|
| 165 |
+
</div>
|
| 166 |
+
</div>,
|
| 167 |
+
document.body,
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// React.lazy() expects a default export.
|
| 172 |
+
export default EnvironmentSwitchOverlay;
|