SaylorTwift HF Staff commited on
Commit
9b906ea
·
verified ·
1 Parent(s): 3d700dd

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. src/components/browser/index.ts +3 -0
  2. src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts +60 -0
  3. src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts +132 -0
  4. src/components/conversation-events/chat/event-content-helpers/get-action-content.ts +269 -0
  5. src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts +147 -0
  6. src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx +370 -0
  7. src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts +28 -0
  8. src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts +436 -0
  9. src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts +70 -0
  10. src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts +128 -0
  11. src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts +42 -0
  12. src/components/conversation-events/chat/event-content-helpers/shared.ts +6 -0
  13. src/components/conversation-events/chat/event-content-helpers/should-render-event.ts +148 -0
  14. src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx +58 -0
  15. src/components/conversation-events/chat/event-message-components/critic-result-display.tsx +247 -0
  16. src/components/conversation-events/chat/event-message-components/error-event-message.tsx +16 -0
  17. src/components/conversation-events/chat/event-message-components/event-group.tsx +169 -0
  18. src/components/conversation-events/chat/event-message-components/finish-event-message.tsx +35 -0
  19. src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx +127 -0
  20. src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx +1 -0
  21. src/components/conversation-events/chat/event-message-components/index.ts +8 -0
  22. src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx +96 -0
  23. src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx +73 -0
  24. src/components/conversation-events/chat/event-message-components/thought-event-message.tsx +40 -0
  25. src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx +132 -0
  26. src/components/conversation-events/chat/event-message.tsx +375 -0
  27. src/components/conversation-events/chat/event-thought-helpers.ts +112 -0
  28. src/components/conversation-events/chat/group-events.ts +130 -0
  29. src/components/conversation-events/chat/hooks/use-plan-preview-events.ts +119 -0
  30. src/components/conversation-events/chat/index.ts +8 -0
  31. src/components/conversation-events/chat/messages.tsx +142 -0
  32. src/components/conversation-events/chat/task-tracking/task-item.tsx +56 -0
  33. src/components/conversation-events/chat/task-tracking/task-list-section.test.tsx +69 -0
  34. src/components/conversation-events/chat/task-tracking/task-list-section.tsx +33 -0
  35. src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.test.tsx +66 -0
  36. src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx +23 -0
  37. src/components/conversation/index.ts +8 -0
  38. src/components/features/alerts/alert-banner.tsx +143 -0
  39. src/components/features/backends/add-backend-modal.tsx +9 -0
  40. src/components/features/backends/api-key-entry-screen.tsx +150 -0
  41. src/components/features/backends/backend-form-modal.test.tsx +80 -0
  42. src/components/features/backends/backend-form-modal.tsx +1629 -0
  43. src/components/features/backends/backend-row.tsx +162 -0
  44. src/components/features/backends/backend-selector.tsx +474 -0
  45. src/components/features/backends/backend-status-dot.tsx +55 -0
  46. src/components/features/backends/backend-status-label.test.ts +101 -0
  47. src/components/features/backends/backend-status-label.ts +77 -0
  48. src/components/features/backends/backend-version.tsx +39 -0
  49. src/components/features/backends/device-flow-auth.tsx +367 -0
  50. 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;