diff --git a/src/components/browser/index.ts b/src/components/browser/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..221de2ec80e8a1c2968b896b01ee3ca586d6cd41 --- /dev/null +++ b/src/components/browser/index.ts @@ -0,0 +1,3 @@ +export { BrowserPanel } from "../features/browser/browser"; +export { BrowserSnapshot } from "../features/browser/browser-snapshot"; +export { EmptyBrowserMessage } from "../features/browser/empty-browser-message"; diff --git a/src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts b/src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..9348c7cecc8c0921cf8a4700925f0999f6b8dc01 --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/create-skill-ready-event.ts @@ -0,0 +1,60 @@ +import { MessageEvent } from "#/types/agent-server/core"; +import { BaseEvent } from "#/types/agent-server/core/base/event"; +import { + getSkillReadyContent, + getSkillReadyItems, + SkillReadyItem, +} from "./get-skill-ready-content"; + +export type { SkillReadyItem }; + +/** + * Synthetic event type for Skill Ready events. + * This extends BaseEvent and includes a marker to identify it as a skill ready event. + */ +export interface SkillReadyEvent extends BaseEvent { + _isSkillReadyEvent: true; + _skillReadyContent: string; + _skillReadyItems: SkillReadyItem[]; +} + +/** + * Type guard for Skill Ready events. + */ +export const isSkillReadyEvent = (event: unknown): event is SkillReadyEvent => + typeof event === "object" && + event !== null && + "_isSkillReadyEvent" in event && + event._isSkillReadyEvent === true; + +/** + * Creates a synthetic "Skill Ready" event from a user MessageEvent. + * This event appears as originating from the agent and contains formatted + * information about activated skills and extended content. + */ +export const createSkillReadyEvent = ( + userEvent: MessageEvent, +): SkillReadyEvent => { + const activatedSkills = userEvent.activated_skills || []; + + const extendedContent = userEvent.extended_content || []; + + // Only create event if we have skills or extended content + if (activatedSkills.length === 0 && extendedContent.length === 0) { + throw new Error( + "Cannot create skill ready event without activated skills or extended content", + ); + } + + const content = getSkillReadyContent(activatedSkills, extendedContent); + const items = getSkillReadyItems(activatedSkills, extendedContent); + + return { + id: `${userEvent.id}-skill-ready`, + timestamp: userEvent.timestamp, + source: "agent", + _isSkillReadyEvent: true, + _skillReadyContent: content, + _skillReadyItems: items, + }; +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts new file mode 100644 index 0000000000000000000000000000000000000000..b53c07bebe536035dac92a1499149b1aa589a17c --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content.ts @@ -0,0 +1,132 @@ +import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; +import i18n from "#/i18n"; +import { MAX_CONTENT_LENGTH } from "./shared"; +import { I18nKey } from "#/i18n/declaration"; + +/** + * Pick the translation key used for the ACP tool call title row. Mirrors + * ACTION_MESSAGE$RUN / $EDIT / $READ etc. + */ +export const getACPToolCallTitleKey = (event: ACPToolCallEvent): string => { + switch (event.tool_kind) { + case "execute": + return "ACTION_MESSAGE$ACP_RUN"; + case "edit": + return "ACTION_MESSAGE$ACP_EDIT"; + case "read": + return "ACTION_MESSAGE$ACP_READ"; + case "fetch": + return "ACTION_MESSAGE$ACP_FETCH"; + default: + return "ACTION_MESSAGE$ACP_TOOL"; + } +}; + +// English verb prefixes ACP servers sometimes inline into the title. +// Claude Code emits ``"Read /Users/foo/bar"`` for a read tool — combined +// with the i18n template ``"Reading {{title}}"`` that lands as +// ``"Reading Read /Users/foo/bar"``. The redundant leading verb is the +// part we strip; the template's own verb (which is i18n'd) stays. +// +// Keyed by ``tool_kind`` so the strip is scoped to where double-verbing +// actually shows up. The english-only check is intentional — ACP servers +// are anglophone tools and emit english titles regardless of the user's +// canvas locale; matching translated verbs would mean every locale's +// strip list goes stale the moment a new server is added. +const REDUNDANT_TITLE_PREFIXES: Partial> = { + read: ["Read"], + edit: ["Edit", "Write"], + execute: ["Bash", "Run"], + fetch: ["Fetch", "WebFetch"], +}; + +/** + * Strip a leading verb from ``event.title`` that would duplicate the + * verb baked into the i18n template (see ``REDUNDANT_TITLE_PREFIXES``). + * + * The match is anchored, case-sensitive, and requires the prefix to be + * followed by whitespace so a token like ``"Reads"`` (an actual verb + * elsewhere in the title) is left alone. If no prefix matches, the title + * is returned verbatim. + */ +export const stripRedundantTitlePrefix = (event: ACPToolCallEvent): string => { + const title = event.title; + const tool_kind = event.tool_kind; + if (!title || !tool_kind) return title; + const prefixes = REDUNDANT_TITLE_PREFIXES[tool_kind]; + if (!prefixes) return title; + for (const prefix of prefixes) { + if ( + title.length > prefix.length && + title.startsWith(prefix) && + /\s/.test(title.charAt(prefix.length)) + ) { + return title.slice(prefix.length).trimStart(); + } + } + return title; +}; + +/** + * Stringify an arbitrary raw_input / raw_output payload for markdown + * rendering. Strings pass through; objects are pretty-printed JSON. + */ +const stringifyPayload = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +}; + +const truncate = (content: string): string => + content.length > MAX_CONTENT_LENGTH + ? `${content.slice(0, MAX_CONTENT_LENGTH)}...` + : content; + +/** + * Build the markdown-flavored body for an ACP tool call card. Mirrors the + * shape of ``getTerminalObservationContent`` (``Command:`` + ``Output:`` + * fenced blocks) so the rendered card lines up with regular OpenHands + * observations. + * + * For ``tool_kind === "execute"`` we surface ``raw_input.command`` as the + * command line; for others we fall back to a pretty-printed JSON dump of + * the input. Output is always dumped as a fenced block, with the same + * "(no output)" fallback copy used by the bash observation renderer. + */ +export const getACPToolCallContent = (event: ACPToolCallEvent): string => { + const toolKind = event.tool_kind; + const rawInput = event.raw_input; + const rawOutput = event.raw_output; + const isError = event.is_error; + + let output = ""; + + // Input block — command for execute, JSON dump otherwise. + if ( + toolKind === "execute" && + rawInput && + typeof rawInput === "object" && + "command" in rawInput && + typeof (rawInput as { command: unknown }).command === "string" + ) { + const { command } = rawInput as { command: string }; + output += `Command: \`${command}\`\n\n`; + } else if (rawInput !== null && rawInput !== undefined && rawInput !== "") { + const inputStr = stringifyPayload(rawInput); + if (inputStr.trim()) { + output += `Input:\n\`\`\`json\n${inputStr}\n\`\`\`\n\n`; + } + } + + // Output block — matches the bash observation layout exactly. + const outputStr = truncate(stringifyPayload(rawOutput).trim()); + const outputLabel = isError ? "**Error:**" : "Output:"; + const outputBody = outputStr || i18n.t(I18nKey.OBSERVATION$COMMAND_NO_OUTPUT); + output += `${outputLabel}\n\`\`\`\n${outputBody}\n\`\`\``; + + return output; +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-action-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-action-content.ts new file mode 100644 index 0000000000000000000000000000000000000000..0feaf47b7794a6ae89c6cf02b41a978aee9945ea --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-action-content.ts @@ -0,0 +1,269 @@ +import { ActionEvent } from "#/types/agent-server/core"; +import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared"; +import i18n from "#/i18n"; +import { SecurityRisk } from "#/types/agent-server/core/base/common"; +import { I18nKey } from "#/i18n/declaration"; +import { + ExecuteBashAction, + TerminalAction, + FileEditorAction, + StrReplaceEditorAction, + MCPToolAction, + ThinkAction, + FinishAction, + TaskTrackerAction, + BrowserNavigateAction, + BrowserClickAction, + BrowserTypeAction, + BrowserGetStateAction, + BrowserGetContentAction, + BrowserScrollAction, + BrowserGoBackAction, + BrowserListTabsAction, + BrowserSwitchTabAction, + BrowserCloseTabAction, + GlobAction, + GrepAction, + InvokeSkillAction, +} from "#/types/agent-server/core/base/action"; + +const getRiskText = (risk: SecurityRisk) => { + switch (risk) { + case SecurityRisk.LOW: + return i18n.t(I18nKey.SECURITY$LOW_RISK); + case SecurityRisk.MEDIUM: + return i18n.t(I18nKey.SECURITY$MEDIUM_RISK); + case SecurityRisk.HIGH: + return i18n.t(I18nKey.SECURITY$HIGH_RISK); + case SecurityRisk.UNKNOWN: + default: + return i18n.t(I18nKey.SECURITY$UNKNOWN_RISK); + } +}; + +const getNoContentActionContent = (): string => ""; + +// Invoke-skill actions +const getInvokeSkillActionContent = (action: InvokeSkillAction): string => + action.name ? `**Skill:** \`${action.name}\`` : getNoContentActionContent(); + +// Grep/Glob search actions +const getSearchActionContent = ( + event: ActionEvent, +): string => { + const { action } = event; + const parts: string[] = []; + if (action.pattern) { + parts.push(`**Pattern:** \`${action.pattern}\``); + } + if (action.path) { + parts.push(`**Path:** \`${action.path}\``); + } + if ("include" in action && action.include) { + parts.push(`**Include:** \`${action.include}\``); + } + return parts.length > 0 ? parts.join("\n") : getNoContentActionContent(); +}; + +// File Editor Actions +const getFileEditorActionContent = ( + action: FileEditorAction | StrReplaceEditorAction, +): string => { + // Early return if not a create command or no file text + if (action.command !== "create" || !action.file_text) { + return getNoContentActionContent(); + } + + // Process file text with length truncation + let fileText = action.file_text; + if (fileText.length > MAX_CONTENT_LENGTH) { + fileText = `${fileText.slice(0, MAX_CONTENT_LENGTH)}...`; + } + + return `${action.path}\n${fileText}`; +}; + +// Command Actions +const getExecuteBashActionContent = ( + event: ActionEvent, +): string => { + let content = `Command:\n\`${event.action.command}\``; + + // Add security risk information if it's HIGH or MEDIUM + if ( + event.security_risk === SecurityRisk.HIGH || + event.security_risk === SecurityRisk.MEDIUM + ) { + content += `\n\n${getRiskText(event.security_risk)}`; + } + + return content; +}; + +// Tool Actions +const getMCPToolActionContent = (action: MCPToolAction): string => { + // For V1, the tool name is in the event's tool_name property, not in the action + let details = `**MCP Tool Call**\n\n`; + details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(action.data, null, 2)}\n\`\`\``; + return details; +}; + +// Simple Actions +const getThinkActionContent = (action: ThinkAction): string => action.thought; + +const getFinishActionContent = (action: FinishAction): string => + action.message.trim(); + +// Complex Actions +const getTaskTrackerActionContent = (action: TaskTrackerAction): string => { + let content = `**Command:** \`${action.command}\``; + + // Handle plan command with task list + if (action.command === "plan") { + if (action.task_list && action.task_list.length > 0) { + content += `\n\n**Task List (${action.task_list.length} ${action.task_list.length === 1 ? "item" : "items"}):**\n`; + action.task_list.forEach((task, index: number) => { + const statusMap = { + todo: "⏳", + in_progress: "🔄", + done: "✅", + }; + const statusIcon = + statusMap[task.status as keyof typeof statusMap] || "❓"; + content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`; + if (task.notes) { + content += `\n *Notes: ${task.notes}*`; + } + }); + } else { + content += "\n\n**Task List:** Empty"; + } + } + + return content; +}; + +// Browser Actions +type BrowserAction = + | BrowserNavigateAction + | BrowserClickAction + | BrowserTypeAction + | BrowserGetStateAction + | BrowserGetContentAction + | BrowserScrollAction + | BrowserGoBackAction + | BrowserListTabsAction + | BrowserSwitchTabAction + | BrowserCloseTabAction; + +const getBrowserActionContent = (action: BrowserAction): string => { + switch (action.kind) { + case "BrowserNavigateAction": { + let content = `Browsing ${action.url}`; + if (action.new_tab) { + content += `\n**New Tab:** Yes`; + } + return content; + } + case "BrowserClickAction": { + let content = `**Element Index:** ${action.index}`; + if (action.new_tab) { + content += `\n**New Tab:** Yes`; + } + return content; + } + case "BrowserTypeAction": { + const textPreview = + action.text.length > 50 + ? `${action.text.slice(0, 50)}...` + : action.text; + return `**Element Index:** ${action.index}\n**Text:** ${textPreview}`; + } + case "BrowserGetStateAction": { + if (action.include_screenshot) { + return `**Include Screenshot:** Yes`; + } + return getNoContentActionContent(); + } + case "BrowserGetContentAction": { + const parts: string[] = []; + if (action.extract_links) { + parts.push(`**Extract Links:** Yes`); + } + if (action.start_from_char > 0) { + parts.push(`**Start From Character:** ${action.start_from_char}`); + } + return parts.length > 0 ? parts.join("\n") : getNoContentActionContent(); + } + case "BrowserScrollAction": { + return `**Direction:** ${action.direction}`; + } + case "BrowserGoBackAction": { + return getNoContentActionContent(); + } + case "BrowserListTabsAction": { + return getNoContentActionContent(); + } + case "BrowserSwitchTabAction": { + return `**Tab ID:** ${action.tab_id}`; + } + case "BrowserCloseTabAction": { + return `**Tab ID:** ${action.tab_id}`; + } + default: + return getNoContentActionContent(); + } +}; + +export const getActionContent = (event: ActionEvent): string => { + const { action } = event; + const actionType = action.kind; + + switch (actionType) { + case "FileEditorAction": + case "StrReplaceEditorAction": + return getFileEditorActionContent(action); + + case "ExecuteBashAction": + case "TerminalAction": + return getExecuteBashActionContent( + event as ActionEvent, + ); + + case "MCPToolAction": + return getMCPToolActionContent(action); + + case "ThinkAction": + return getThinkActionContent(action); + + case "FinishAction": + return getFinishActionContent(action); + + case "TaskTrackerAction": + return getTaskTrackerActionContent(action); + + case "BrowserNavigateAction": + case "BrowserClickAction": + case "BrowserTypeAction": + case "BrowserGetStateAction": + case "BrowserGetContentAction": + case "BrowserScrollAction": + case "BrowserGoBackAction": + case "BrowserListTabsAction": + case "BrowserSwitchTabAction": + case "BrowserCloseTabAction": + return getBrowserActionContent(action); + + case "GrepAction": + case "GlobAction": + return getSearchActionContent( + event as ActionEvent, + ); + + case "InvokeSkillAction": + return getInvokeSkillActionContent(action as InvokeSkillAction); + + default: + return getDefaultEventContent(event); + } +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts b/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts new file mode 100644 index 0000000000000000000000000000000000000000..95d68e0e57da18218dfc8b17460f230916916d8d --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts @@ -0,0 +1,147 @@ +import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_ACTION_KIND } from "#/constants/child-conversation"; +import type { ActionEvent } from "#/types/agent-server/core"; + +export type EventTitleDescriptor = + | { + readonly kind: "translation"; + readonly key: string; + readonly values: Readonly>; + } + | { + readonly kind: "text"; + readonly text: string; + }; + +export const trimEventTitleText = (text: string, maxLength: number): string => { + if (!text) return ""; + return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; +}; + +const isServerFallbackSummary = (summary: string): boolean => + /^[a-z][a-z0-9_]*\s*:\s*[[{]/i.test(summary); + +export const getActionSummaryTitle = (event: ActionEvent): string | null => { + const summary = event.summary?.trim().replace(/\s+/g, " ") || ""; + return !summary || isServerFallbackSummary(summary) ? null : summary; +}; + +export const getActionEventTitleDescriptor = ( + event: ActionEvent, +): EventTitleDescriptor => { + const summary = getActionSummaryTitle(event); + if (summary) { + return { kind: "text", text: summary }; + } + + const actionType = event.action.kind; + + switch (actionType) { + case "ExecuteBashAction": + case "TerminalAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$RUN", + values: { command: trimEventTitleText(event.action.command, 80) }, + }; + case "FileEditorAction": + case "StrReplaceEditorAction": { + const key = + event.action.command === "view" + ? "ACTION_MESSAGE$READ" + : event.action.command === "create" + ? "ACTION_MESSAGE$WRITE" + : "ACTION_MESSAGE$EDIT"; + return { + kind: "translation", + key, + values: { path: event.action.path }, + }; + } + case "MCPToolAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$CALL_TOOL_MCP", + values: { mcp_tool_name: event.tool_name }, + }; + case "InvokeSkillAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$INVOKE_SKILL", + values: { name: event.action.name }, + }; + case "TaskAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$TASK", + values: { name: event.action.subagent_type }, + }; + case "ThinkAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$THINK", + values: {}, + }; + case "FinishAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$FINISH", + values: {}, + }; + case "TaskTrackerAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$TASK_TRACKING", + values: {}, + }; + case "GrepAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$GREP", + values: { + pattern: event.action.pattern + ? trimEventTitleText(event.action.pattern, 50) + : "", + }, + }; + case "GlobAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$GLOB", + values: { + pattern: event.action.pattern + ? trimEventTitleText(event.action.pattern, 50) + : "", + }, + }; + case "BrowserNavigateAction": + case "BrowserClickAction": + case "BrowserTypeAction": + case "BrowserGetStateAction": + case "BrowserGetContentAction": + case "BrowserScrollAction": + case "BrowserGoBackAction": + case "BrowserListTabsAction": + case "BrowserSwitchTabAction": + case "BrowserCloseTabAction": + return { + kind: "translation", + key: "ACTION_MESSAGE$BROWSE", + values: {}, + }; + case "CanvasUIAction": + case CANVAS_UI_CLIENT_ACTION_KIND: + return { kind: "text", text: "CANVASUI" }; + case LAUNCH_CHILD_CONVERSATION_ACTION_KIND: + return { + kind: "translation", + key: "ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION", + values: {}, + }; + default: + return { + kind: "text", + text: String(actionType).replace("Action", "").toUpperCase(), + }; + } +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0ae422ac018f803e07715380118f669b8c8a0a3 --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx @@ -0,0 +1,370 @@ +import { Trans } from "react-i18next"; +import React from "react"; +import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; +import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation"; +import { + OpenHandsEvent, + ObservationEvent, + ActionEvent, +} from "#/types/agent-server/core"; +import { + isActionEvent, + isObservationEvent, + isACPToolCallEvent, + isCanvasUIActionEvent, +} from "#/types/agent-server/type-guards"; +import { MonoComponent } from "../../../features/chat/mono-component"; +import { PathComponent } from "../../../features/chat/path-component"; +import { getActionContent } from "./get-action-content"; +import { getObservationContent } from "./get-observation-content"; +import { + getACPToolCallContent, + getACPToolCallTitleKey, + stripRedundantTitlePrefix, +} from "./get-acp-tool-call-content"; +import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content"; +import { TaskTrackerObservation } from "#/types/agent-server/core/base/observation"; +import { SkillReadyEvent, isSkillReadyEvent } from "./create-skill-ready-event"; +import { resolveVisualizerBody } from "../../../features/chat/tool-visualizers/dispatcher"; +import i18n from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; +import { + getActionEventTitleDescriptor, + getActionSummaryTitle, + trimEventTitleText, + type EventTitleDescriptor, +} from "./get-action-event-title"; + +// Helper function to create title from translation key +const createTitleFromKey = ( + key: string, + values: Readonly>, +): React.ReactNode => { + if (!i18n.exists(key)) { + return key; + } + + return ( + , + cmd: , + }} + /> + ); +}; + +const renderTitleDescriptor = ( + descriptor: EventTitleDescriptor, +): React.ReactNode => + descriptor.kind === "text" + ? descriptor.text + : createTitleFromKey(descriptor.key, descriptor.values); + +const getNonEmptyString = ( + value: unknown, + maxLength?: number, +): string | null => { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + return maxLength ? trimEventTitleText(trimmed, maxLength) : trimmed; +}; + +const getVisionInspectActionTitle = ( + event: ActionEvent, +): React.ReactNode | null => { + if (event.tool_name !== "inspect_image_with_vision") { + return null; + } + + const action = event.action as unknown as Record; + const profileName = getNonEmptyString(action.profile_name); + + return profileName + ? `Describing image with ${profileName}` + : "Describing image with auxiliary vision LLM"; +}; + +const getVisionInspectObservationTitle = ( + event: ObservationEvent, + correspondingAction?: ActionEvent, +): React.ReactNode | null => { + const actionToolName = correspondingAction?.tool_name; + const observation = event.observation as unknown as Record; + const observationToolName = getNonEmptyString(observation.tool_name); + + if ( + actionToolName !== "inspect_image_with_vision" && + observationToolName !== "inspect_image_with_vision" && + String(event.observation.kind) !== "VisionInspectObservation" + ) { + return null; + } + + const action = (correspondingAction?.action ?? {}) as unknown as Record< + string, + unknown + >; + const label = + getNonEmptyString(action.profile_name) ?? + getNonEmptyString(observation.profile_name) ?? + getNonEmptyString(observation.base_url, 80) ?? + getNonEmptyString(observation.endpoint, 80) ?? + getNonEmptyString(observation.model, 80); + + return label + ? `Describing image with ${label}` + : "Describing image with auxiliary vision LLM"; +}; + +// Action Event Processing +const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => { + // Early return if not an action event + if (!isActionEvent(event)) { + return ""; + } + + const visionInspectTitle = getVisionInspectActionTitle(event); + if (visionInspectTitle) { + return visionInspectTitle; + } + + return renderTitleDescriptor(getActionEventTitleDescriptor(event)); +}; + +// Observation Event Processing +const getObservationEventTitle = ( + event: OpenHandsEvent, + correspondingAction?: ActionEvent, +): React.ReactNode => { + // Early return if not an observation event + if (!isObservationEvent(event)) { + return ""; + } + + const visionInspectObservationTitle = getVisionInspectObservationTitle( + event, + correspondingAction, + ); + if (visionInspectObservationTitle) { + return visionInspectObservationTitle; + } + + if (correspondingAction) { + const visionInspectTitle = getVisionInspectActionTitle(correspondingAction); + if (visionInspectTitle) { + return visionInspectTitle; + } + + const summaryTitle = getActionSummaryTitle(correspondingAction); + if (summaryTitle) { + return summaryTitle; + } + } + + const observationType = event.observation.kind; + let observationKey = ""; + let observationValues: Record = {}; + + switch (observationType) { + case "ExecuteBashObservation": + case "TerminalObservation": + observationKey = "OBSERVATION_MESSAGE$RUN"; + observationValues = { + command: event.observation.command + ? trimEventTitleText(event.observation.command, 80) + : "", + }; + break; + case "FileEditorObservation": + case "StrReplaceEditorObservation": + if (event.observation.command === "view") { + observationKey = "OBSERVATION_MESSAGE$READ"; + } else if (event.observation.command === "create") { + observationKey = "OBSERVATION_MESSAGE$WRITE"; + } else { + observationKey = "OBSERVATION_MESSAGE$EDIT"; + } + observationValues = { + path: event.observation.path || "", + }; + break; + case "MCPToolObservation": + observationKey = "OBSERVATION_MESSAGE$MCP"; + observationValues = { + mcp_tool_name: event.observation.tool_name, + }; + break; + case "InvokeSkillObservation": + observationKey = "OBSERVATION_MESSAGE$INVOKE_SKILL"; + observationValues = { + name: event.observation.skill_name, + }; + break; + case "TaskObservation": + observationKey = "OBSERVATION_MESSAGE$TASK"; + observationValues = { + name: event.observation.subagent, + }; + break; + case "CanvasUIObservation": + observationKey = "OBSERVATION_MESSAGE$CANVAS_UI"; + break; + case "ClientToolObservation": + if (event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME) { + observationKey = "OBSERVATION_MESSAGE$CANVAS_UI"; + break; + } + if (event.tool_name === LAUNCH_CHILD_CONVERSATION_TOOL_NAME) { + observationKey = "OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION"; + break; + } + return observationType.replace("Observation", "").toUpperCase(); + case "SwitchLLMObservation": + observationKey = event.observation.is_error + ? "MODEL$SWITCH_FAILED" + : "MODEL$SWITCHED_TO_PROFILE"; + observationValues = { + name: event.observation.profile_name, + }; + break; + case "BrowserObservation": + observationKey = "OBSERVATION_MESSAGE$BROWSE"; + break; + case "TaskTrackerObservation": { + const { command } = event.observation; + if (command === "plan") { + observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN"; + } else { + // command === "view" + observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW"; + } + break; + } + case "ThinkObservation": + observationKey = "OBSERVATION_MESSAGE$THINK"; + break; + case "GlobObservation": + observationKey = "OBSERVATION_MESSAGE$GLOB"; + observationValues = { + pattern: event.observation.pattern + ? trimEventTitleText(event.observation.pattern, 50) + : "", + }; + break; + case "GrepObservation": + observationKey = "OBSERVATION_MESSAGE$GREP"; + observationValues = { + pattern: event.observation.pattern + ? trimEventTitleText(event.observation.pattern, 50) + : "", + }; + break; + default: + // For unknown observations, use the type name + return observationType.replace("Observation", "").toUpperCase(); + } + + if (observationKey) { + return createTitleFromKey(observationKey, observationValues); + } + + return observationType; +}; + +const getCanvasUIClientObservationContent = ( + event: ObservationEvent, + correspondingAction?: ActionEvent, +): string | null => { + if ( + event.observation.kind !== "ClientToolObservation" || + event.tool_name !== CANVAS_UI_CLIENT_TOOL_NAME || + !correspondingAction || + !isCanvasUIActionEvent(correspondingAction) + ) { + return null; + } + + return `UI command '${correspondingAction.action.command}' dispatched to the Agent Canvas frontend.`; +}; + +export const getEventContent = ( + event: OpenHandsEvent | SkillReadyEvent, + correspondingAction?: ActionEvent, +) => { + let title: React.ReactNode = ""; + let details: string | React.ReactNode = ""; + + // Handle Skill Ready events first + if (isSkillReadyEvent(event)) { + // Use translation key if available, otherwise use "SKILL READY" + const skillReadyKey = "OBSERVATION_MESSAGE$SKILL_READY"; + if (i18n.exists(skillReadyKey)) { + title = createTitleFromKey(skillReadyKey, {}); + } else { + title = "Skill Ready"; + } + details = event._skillReadyContent; + } else if (isActionEvent(event)) { + title = getActionEventTitle(event); + // Per-tool React visualizer when one is registered; markdown otherwise. + details = resolveVisualizerBody(event) ?? getActionContent(event); + } else if (isObservationEvent(event)) { + title = getObservationEventTitle(event, correspondingAction); + + // For TaskTrackerObservation, use React component instead of markdown + if (event.observation.kind === "TaskTrackerObservation") { + details = ( + } + /> + ); + } else { + details = + getCanvasUIClientObservationContent(event, correspondingAction) ?? + resolveVisualizerBody(event, correspondingAction) ?? + getObservationContent(event); + } + } else if (isACPToolCallEvent(event)) { + // ACP sub-agent tool calls reuse the same card shape as observations: + // title is "Running/Editing/Reading …" via a translation key that + // mirrors ACTION_MESSAGE$RUN etc.; details are markdown built from + // raw_input + raw_output the same way getTerminalObservationContent + // builds "Command: / Output:" blocks. + title = createTitleFromKey(getACPToolCallTitleKey(event), { + // Strip a redundant verb prefix the ACP server may have inlined + // (Claude Code emits ``"Read /path"`` for a read tool; combined + // with the ``"Reading {{title}}"`` template that lands + // as ``"Reading Read /path"``). See ``stripRedundantTitlePrefix``. + title: stripRedundantTitlePrefix(event), + }); + details = getACPToolCallContent(event); + } else if ( + // Lenient fallback for action-like events that fail the strict isActionEvent() guard + // (e.g., missing tool_name or tool_call_id). Extract a title from the action kind + // so the UI shows something meaningful instead of "Unknown event". + event.source === "agent" && + "action" in event && + event.action !== null && + typeof event.action === "object" && + "kind" in event.action && + typeof event.action.kind === "string" + ) { + title = String(event.action.kind).replace("Action", "").toUpperCase(); + } + + return { + title: title || i18n.t(I18nKey.EVENT$UNKNOWN_EVENT), + details, + }; +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts b/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts new file mode 100644 index 0000000000000000000000000000000000000000..855cc775ff5ee3b344394417c0a94b914bf50317 --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-invoke-skill-items.ts @@ -0,0 +1,28 @@ +import { ObservationEvent } from "#/types/agent-server/core"; +import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation"; +import { SkillReadyItem } from "./get-skill-ready-content"; + +/** + * Maps an InvokeSkillObservation to the SkillReadyItem shape so invoke-skill + * tool calls can reuse the Skill Ready expandable list. A single skill is + * invoked per observation, so this yields at most one item. Returns an empty + * array when there is neither a skill name nor any text content to show, which + * tells the caller to fall back to the default markdown body. + */ +export const getInvokeSkillItems = ( + event: ObservationEvent, +): SkillReadyItem[] => { + const { observation } = event; + + const content = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n") + .trim(); + + if (!observation.skill_name && !content) { + return []; + } + + return [{ name: observation.skill_name, content }]; +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts new file mode 100644 index 0000000000000000000000000000000000000000..45ec656e43ea61740c1136f177d11b5aa2440939 --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-observation-content.ts @@ -0,0 +1,436 @@ +import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui"; +import { ObservationEvent } from "#/types/agent-server/core"; +import { getObservationResult } from "./get-observation-result"; +import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared"; +import i18n from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; +import { + MCPToolObservation, + FinishObservation, + ThinkObservation, + BrowserObservation, + ExecuteBashObservation, + TerminalObservation, + FileEditorObservation, + StrReplaceEditorObservation, + TaskTrackerObservation, + GlobObservation, + GrepObservation, + InvokeSkillObservation, + CanvasUIObservation, + SwitchLLMObservation, +} from "#/types/agent-server/core/base/observation"; + +// File Editor Observations +const getFileEditorObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + if (observation.error) { + return `**Error:**\n${observation.error}`; + } + + // Extract text content from the observation if it exists + const textContent = + "content" in observation && Array.isArray(observation.content) + ? observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n") + : null; + + const successMessage = getObservationResult(event) === "success"; + + // For view commands or successful edits with content changes, format as code block + if ( + (successMessage && + "old_content" in observation && + "new_content" in observation && + observation.old_content && + observation.new_content) || + observation.command === "view" + ) { + // Prefer content over output for view commands, fallback to output if content is not available + const displayContent = textContent || observation.output; + return `\`\`\`\n${displayContent}\n\`\`\``; + } + + // For other commands, prefer content if available, otherwise use output + return textContent || observation.output; +}; + +// Command Observations +const getTerminalObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = textContent || ""; + + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`; + } + + // Build the output string + let output = ""; + + // Display the command if available + if (observation.command) { + output += `Command: \`${observation.command}\`\n\n`; + } + + // Display the output + output += `Output:\n\`\`\`sh\n${content.trim() || i18n.t(I18nKey.OBSERVATION$COMMAND_NO_OUTPUT)}\n\`\`\``; + + return output; +}; + +// Tool Observations +const getBrowserObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = + "content" in observation && Array.isArray(observation.content) + ? observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n") + : observation.output || ""; + + let contentDetails = ""; + + if (observation.error) { + contentDetails += `**Error:**\n${observation.error}`; + } else if (textContent) { + contentDetails += `**Output:**\n${textContent}`; + } else { + contentDetails += "Browser action completed successfully."; + } + + if (contentDetails.length > MAX_CONTENT_LENGTH) { + contentDetails = `${contentDetails.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; + } + + return contentDetails; +}; + +const getMCPToolObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = `**Tool:** ${observation.tool_name}\n\n`; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else { + content += `**Result:**\n${textContent}`; + } + + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`; + } + + return content; +}; + +// Invoke-skill observations +const getInvokeSkillObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + const header = observation.skill_name + ? `**Skill:** \`${observation.skill_name}\`\n\n` + : ""; + const body = observation.is_error + ? `**Error:**\n${textContent}` + : textContent; + + let content = `${header}${body}`; + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; + } + return content; +}; + +// Canvas UI observations — just surface the acknowledgement text. +const getCanvasUIObservationContent = ( + event: ObservationEvent, +): string => + event.observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + +const getSwitchLLMObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + if (observation.is_error) { + return textContent + ? `**Error:**\n${textContent}` + : `**Error:**\nFailed to switch LLM profile \`${observation.profile_name}\`.`; + } + + const parts = [`**Profile:** \`${observation.profile_name}\``]; + if (observation.active_model) { + parts.push(`**Active model:** \`${observation.active_model}\``); + } + if (observation.reason) { + parts.push(`**Reason:** ${observation.reason}`); + } + + return parts.join("\n"); +}; + +// Complex Observations +const getTaskTrackerObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + const { command, task_list: taskList } = observation; + let content = `**Command:** \`${command}\``; + + if (command === "plan" && taskList.length > 0) { + content += `\n\n**Task List (${taskList.length} ${taskList.length === 1 ? "item" : "items"}):**\n`; + + taskList.forEach((task, index: number) => { + const statusMap = { + todo: "⏳", + in_progress: "🔄", + done: "✅", + }; + const statusIcon = + statusMap[task.status as keyof typeof statusMap] || "❓"; + + content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`; + if (task.notes) { + content += `\n *Notes: ${task.notes}*`; + } + }); + } else if (command === "plan") { + content += "\n\n**Task List:** Empty"; + } + + if ( + "content" in observation && + observation.content && + typeof observation.content === "string" && + observation.content.trim() + ) { + content += `\n\n**Result:** ${observation.content.trim()}`; + } + + return content; +}; + +// Simple Observations +const getThinkObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return textContent || ""; +}; + +const getFinishObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = ""; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else { + content += textContent; + } + + return content; +}; + +// Glob Observations +const getGlobObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = `**Pattern:** \`${observation.pattern}\`\n`; + content += `**Search Path:** \`${observation.search_path}\`\n\n`; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else if (observation.files.length === 0) { + content += "**Result:** No files found."; + } else { + content += `**Files Found (${observation.files.length}${observation.truncated ? "+, truncated" : ""}):**\n`; + content += observation.files.map((f) => `- \`${f}\``).join("\n"); + } + + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; + } + + return content; +}; + +// Grep Observations +const getGrepObservationContent = ( + event: ObservationEvent, +): string => { + const { observation } = event; + + // Extract text content from the observation + const textContent = observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + + let content = `**Pattern:** \`${observation.pattern}\`\n`; + content += `**Search Path:** \`${observation.search_path}\`\n`; + if (observation.include_pattern) { + content += `**Include:** \`${observation.include_pattern}\`\n`; + } + content += "\n"; + + if (observation.is_error) { + content += `**Error:**\n${textContent}`; + } else if (observation.matches.length === 0) { + content += "**Result:** No matches found."; + } else { + content += `**Matches (${observation.matches.length}${observation.truncated ? "+, truncated" : ""}):**\n`; + content += observation.matches.map((f) => `- \`${f}\``).join("\n"); + } + + if (content.length > MAX_CONTENT_LENGTH) { + content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`; + } + + return content; +}; + +export const getObservationContent = (event: ObservationEvent): string => { + const observationType = event.observation.kind; + + switch (observationType) { + case "FileEditorObservation": + case "StrReplaceEditorObservation": + return getFileEditorObservationContent( + event as ObservationEvent< + FileEditorObservation | StrReplaceEditorObservation + >, + ); + + case "ExecuteBashObservation": + case "TerminalObservation": + return getTerminalObservationContent( + event as ObservationEvent, + ); + + case "BrowserObservation": + return getBrowserObservationContent( + event as ObservationEvent, + ); + + case "MCPToolObservation": + return getMCPToolObservationContent( + event as ObservationEvent, + ); + + case "TaskTrackerObservation": + return getTaskTrackerObservationContent( + event as ObservationEvent, + ); + + case "ThinkObservation": + return getThinkObservationContent( + event as ObservationEvent, + ); + + case "FinishObservation": + return getFinishObservationContent( + event as ObservationEvent, + ); + + case "GlobObservation": + return getGlobObservationContent( + event as ObservationEvent, + ); + + case "GrepObservation": + return getGrepObservationContent( + event as ObservationEvent, + ); + + case "InvokeSkillObservation": + return getInvokeSkillObservationContent( + event as ObservationEvent, + ); + + case "CanvasUIObservation": + return getCanvasUIObservationContent( + event as ObservationEvent, + ); + + case "ClientToolObservation": + return event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME + ? getCanvasUIObservationContent( + event as ObservationEvent, + ) + : getDefaultEventContent(event); + + case "SwitchLLMObservation": + return getSwitchLLMObservationContent( + event as ObservationEvent, + ); + + default: + return getDefaultEventContent(event); + } +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts b/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ac7a1eb1d8b422da3ab6526395813c86ec5e0e1 --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-observation-result.ts @@ -0,0 +1,70 @@ +import { ObservationEvent } from "#/types/agent-server/core"; +import { ACPToolCallEvent } from "#/types/agent-server/core/events/acp-tool-call-event"; + +export type ObservationResultStatus = "success" | "error" | "timeout"; + +/** + * Map an ACPToolCallEvent's lifecycle + error flags to the same + * success/error status the rest of the UI uses. A non-terminal call + * (``pending`` / ``in_progress``) falls through to ``undefined`` so the + * SuccessIndicator renders nothing — the card shows as "running" via the + * absence of a check mark, matching how regular ActionEvents are displayed + * before their ObservationEvent arrives. + */ +export const getACPToolCallResult = ( + event: ACPToolCallEvent, +): ObservationResultStatus | undefined => { + if (event.is_error || event.status === "failed") return "error"; + if (event.status === "completed") return "success"; + return undefined; +}; + +export const getObservationResult = ( + event: ObservationEvent, +): ObservationResultStatus => { + const { observation } = event; + const observationType = observation.kind; + + switch (observationType) { + case "ExecuteBashObservation": { + const exitCode = observation.exit_code; + const { metadata } = observation; + + if (exitCode === -1 || metadata.exit_code === -1) return "timeout"; // Command timed out + if (exitCode === 0 || metadata.exit_code === 0) return "success"; // Command executed successfully + return "error"; // Command failed + } + case "TerminalObservation": { + const exitCode = + observation.exit_code ?? observation.metadata.exit_code ?? null; + + if (observation.timeout || exitCode === -1) return "timeout"; + if (exitCode === 0) return "success"; + if (observation.is_error) return "error"; + return "success"; + } + case "FileEditorObservation": + case "StrReplaceEditorObservation": + // Check if there's an error + if (observation.error) return "error"; + return "success"; + case "MCPToolObservation": + if (observation.is_error) return "error"; + return "success"; + case "SwitchLLMObservation": + if (observation.is_error) return "error"; + return "success"; + case "InvokeSkillObservation": + if (observation.is_error) return "error"; + return "success"; + case "TaskObservation": + if (observation.is_error || observation.status === "failed") + return "error"; + return "success"; + case "CanvasUIObservation": + if (observation.is_error) return "error"; + return "success"; + default: + return "success"; + } +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts b/src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5666f971cdcc1caff8f095f76a992ce4b2b3271 --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/get-skill-ready-content.ts @@ -0,0 +1,128 @@ +import { TextContent } from "#/types/agent-server/core/base/common"; + +/** + * Represents a single activated skill with its name and associated content. + */ +export interface SkillReadyItem { + name: string; + content: string; +} + +/** + * Extracts all text content from an array of TextContent items. + */ +function extractAllText(extendedContent: TextContent[]): string { + return extendedContent + .filter((c) => c.type === "text") + .map((c) => c.text) + .join(""); +} + +/** + * Extracts all blocks from the given text. + * Returns an array of content strings (without the wrapper tags). + */ +const extractExtraInfoBlocks = (text: string): string[] => { + const blockRegex = /([\s\S]*?)<\/EXTRA_INFO>/gi; + return Array.from(text.matchAll(blockRegex), (match) => + match[1].trim(), + ).filter(Boolean); +}; + +/** + * Formats a single skill with its corresponding content block. + */ +const formatSkillWithContent = ( + skill: string, + contentBlock: string | undefined, +): string => { + let formatted = `\n\n- **${skill}**`; + + if (contentBlock) { + formatted += `\n\n${contentBlock}`; + } + + return formatted; +}; + +/** + * Formats skills paired with their corresponding extended content blocks. + */ +const formatSkillKnowledge = ( + activatedSkills: string[], + extraInfoBlocks: string[], +): string => { + let content = `\n\n**Triggered Skill Knowledge:**`; + + activatedSkills.forEach((skill, index) => { + const contentBlock = extraInfoBlocks[index]; + content += formatSkillWithContent(skill, contentBlock); + }); + + return content; +}; + +/** + * Formats extended content blocks when no skills are present. + */ +const formatExtendedContentOnly = (extraInfoBlocks: string[]): string => { + let content = `\n\n**Extended Content:**`; + + extraInfoBlocks.forEach((block) => { + content += `\n\n${block}`; + }); + + return content; +}; + +/** + * Extracts EXTRA_INFO blocks from extended content. + */ +const getExtraInfoBlocks = (extendedContent?: TextContent[]): string[] => { + if (!extendedContent) return []; + return extractExtraInfoBlocks(extractAllText(extendedContent)); +}; + +/** + * Formats activated skills and extended content into markdown for display. + * Each skill is paired with its corresponding block by index. + */ +export const getSkillReadyContent = ( + activatedSkills: string[], + extendedContent: TextContent[], +): string => { + const extraInfoBlocks = getExtraInfoBlocks(extendedContent); + + if (activatedSkills && activatedSkills.length > 0) { + return formatSkillKnowledge(activatedSkills, extraInfoBlocks); + } + + if (extraInfoBlocks.length > 0) { + return formatExtendedContentOnly(extraInfoBlocks); + } + + return ""; +}; + +/** + * Returns structured skill items with their names and associated content. + * Each skill is paired with its corresponding block by index. + */ +export const getSkillReadyItems = ( + activatedSkills: string[], + extendedContent: TextContent[], +): SkillReadyItem[] => { + const extraInfoBlocks = getExtraInfoBlocks(extendedContent); + + if (activatedSkills && activatedSkills.length > 0) { + return activatedSkills.map((skill, index) => ({ + name: skill, + content: extraInfoBlocks[index] ?? "", + })); + } + + return extraInfoBlocks.map((block, index) => ({ + name: `Extended Content ${index + 1}`, + content: block, + })); +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts b/src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc6301c922f59a3be4e2d1027c31389d1d9d5b67 --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/parse-message-from-event.ts @@ -0,0 +1,42 @@ +import { MessageEvent } from "#/types/agent-server/core"; +import i18n from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; + +export const parseMessageFromEvent = (event: MessageEvent): string => { + const message = event.llm_message; + + // Safety check: ensure llm_message exists and has content + if (!message?.content) { + return ""; + } + + // Get the text content from the message + let textContent = ""; + if (message.content) { + if (Array.isArray(message.content)) { + // Handle array of content blocks + textContent = message.content + .filter((content) => content.type === "text") + .map((content) => content.text) + .join("\n"); + } else if (typeof message.content === "string") { + // Handle string content + textContent = message.content; + } + } + + // Check if there are image_urls in the message content + const hasImages = + Array.isArray(message.content) && + message.content.some((content) => content.type === "image"); + + if (!hasImages) { + return textContent; + } + + // If there are images, try to split by the augmented prompt delimiter + const delimiter = i18n.t(I18nKey.CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE); + const parts = textContent.split(delimiter); + + return parts[0]; +}; diff --git a/src/components/conversation-events/chat/event-content-helpers/shared.ts b/src/components/conversation-events/chat/event-content-helpers/shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ea19b4886efd2fa41d0e9fb2836ace345a7ec66 --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/shared.ts @@ -0,0 +1,6 @@ +import { OpenHandsEvent } from "#/types/agent-server/core"; + +export const MAX_CONTENT_LENGTH = 1000; + +export const getDefaultEventContent = (event: OpenHandsEvent): string => + `\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``; diff --git a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e8005b3617047223a251df87ae9df73ee0d6f9c --- /dev/null +++ b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts @@ -0,0 +1,148 @@ +import { CHILD_CONVERSATION_RESULT_PREFIX } from "#/constants/child-conversation"; +import { MessageEvent, OpenHandsEvent } from "#/types/agent-server/core"; +import { + isActionEvent, + isObservationEvent, + isMessageEvent, + isAgentErrorEvent, + isConversationStateUpdateEvent, + isGoalConversationStateUpdateEvent, + isHookExecutionEvent, + isACPToolCallEvent, + isStreamingDeltaEvent, +} from "#/types/agent-server/type-guards"; + +// Prefixes of the SDK goal-loop re-prompts (FOLLOWUP_PROMPT / RESUME_PROMPT in +// openhands.sdk .../conversation/goal/prompts.py). The goal loop injects these +// as `user` messages each round to steer the agent, and FOLLOWUP_PROMPT embeds +// the judge's verdict — which the goal banner already surfaces. The persisted +// event carries no marker distinguishing them from real user input, so we match +// the prompt text to keep this machinery out of the chat. Brittle by design; +// keep in sync with the SDK prompts (the durable fix is a persisted goal-loop +// flag on the event). +const GOAL_REPROMPT_PREFIXES = [ + "The goal is NOT yet complete (audit iteration", + "Resuming a goal that was paused or interrupted.", +]; + +const userMessageText = (event: MessageEvent): string | null => { + if (event.llm_message?.role !== "user") return null; + const content = event.llm_message.content; + return Array.isArray(content) + ? content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n") + : ""; +}; + +const isGoalLoopReprompt = (event: MessageEvent): boolean => { + const text = userMessageText(event); + if (text === null) return false; + return GOAL_REPROMPT_PREFIXES.some((prefix) => text.startsWith(prefix)); +}; + +// The frontend posts the outcome of a `launch_child_conversation` call back as +// a `user` message because client tools have no result channel (see +// `services/child-conversation-launch.ts`). It is a JSON payload addressed to +// the agent, which reports the child's id and link in its own reply, so the +// raw message does not belong in the chat. +const isChildConversationResult = (event: MessageEvent): boolean => + userMessageText(event)?.startsWith(CHILD_CONVERSATION_RESULT_PREFIX) ?? false; + +export const shouldRenderEvent = (event: OpenHandsEvent) => { + if (isConversationStateUpdateEvent(event)) { + // A finished `/goal` loop renders inline so it settles into the + // conversation; the live (active) banner is shown separately by + // GoalStatusBanner, and all other state updates (and the in-progress goal + // events) stay hidden. + return isGoalConversationStateUpdateEvent(event) && !event.value.active; + } + + // Render action events (with filtering) + if (isActionEvent(event)) { + // For V1, action is an object with kind property + const actionType = event.action.kind; + + if (!actionType) { + return false; + } + + // Hide user commands from the chat interface + if (actionType === "ExecuteBashAction" && event.source === "user") { + return false; + } + + // Hide PlanningFileEditorAction - handled separately with PlanPreview component + if (actionType === "PlanningFileEditorAction") { + return false; + } + + // The model switch tool reuses the same inline model message UI as + // `/model ` once the observation arrives. + if (actionType === "SwitchLLMAction") { + return false; + } + + return true; + } + + // Render observation events + if (isObservationEvent(event)) { + // Successful model switches are rendered through ModelMessages so they + // look identical to `/model ` confirmations. Failed switches + // still render as observations so the error remains visible in chat. + if ( + event.observation.kind === "SwitchLLMObservation" && + !event.observation.is_error + ) { + return false; + } + + return true; + } + + // Render message events (user and assistant messages), except the goal loop's + // injected re-prompts — the judge feedback they carry is shown in the goal + // banner, so otherwise they leak into the chat as fake user turns — and the + // child-conversation launch results, which are machine payloads the agent + // relays in its own reply. + if (isMessageEvent(event)) { + return !isGoalLoopReprompt(event) && !isChildConversationResult(event); + } + + // Render agent error events + if (isAgentErrorEvent(event)) { + return true; + } + + // Render hook execution events + if (isHookExecutionEvent(event)) { + return true; + } + + // Render ACP sub-agent tool call events at every lifecycle stage. The SDK + // now persists exactly two events per ``tool_call_id`` — one early + // ``started`` event (``pending`` / ``in_progress``) and one terminal + // (``completed`` / ``failed``) event — the action->observation pair for a + // tool call. The ``started`` event renders the card as "running" (no check + // mark; see ``getACPToolCallResult``) and ``handleEventForUI`` replaces it + // in place by ``tool_call_id`` once the terminal event arrives, mirroring + // how an ObservationEvent supersedes its ActionEvent. The old terminal-only + // gate existed because the source fanned out one cumulative-output frame per + // ``ToolCallProgress``, which flashed half-formed cards mid-stream; that + // fan-out is gone, so the running card is now a single clean event. + if (isACPToolCallEvent(event)) { + return true; + } + + if (isStreamingDeltaEvent(event)) { + return event.content !== null || event.reasoning_content !== null; + } + + // Don't render any other event types (system events, etc.) + return false; +}; + +export const hasUserEvent = (events: OpenHandsEvent[]) => + events.some((event) => event.source === "user"); diff --git a/src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx b/src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7b5100ec43ffcf36a2e945e19a6270cab2df4bfc --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/collapsible-thinking.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import ArrowDown from "#/icons/angle-down-solid.svg?react"; +import ArrowUp from "#/icons/angle-up-solid.svg?react"; +import LightbulbIcon from "#/icons/lightbulb.svg?react"; +import { I18nKey } from "#/i18n/declaration"; +import { MarkdownRenderer } from "../../../features/markdown/markdown-renderer"; + +interface CollapsibleThinkingProps { + /** The thinking / reasoning content to display when expanded. */ + content: string; +} + +/** + * Renders agent thinking or extended reasoning content inside a collapsible + * section. Collapsed by default so the chat stays compact — especially + * useful when the thinking language differs from the conversation language. + */ +export function CollapsibleThinking({ content }: CollapsibleThinkingProps) { + const { t } = useTranslation("openhands"); + const [expanded, setExpanded] = React.useState(false); + + if (!content.trim()) { + return null; + } + + const Chevron = expanded ? ArrowUp : ArrowDown; + + return ( +
+ + + {expanded && ( +
+ {content} +
+ )} +
+ ); +} diff --git a/src/components/conversation-events/chat/event-message-components/critic-result-display.tsx b/src/components/conversation-events/chat/event-message-components/critic-result-display.tsx new file mode 100644 index 0000000000000000000000000000000000000000..709f16f509bc593a1d04b7187900af2ac58a2a62 --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/critic-result-display.tsx @@ -0,0 +1,247 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import ArrowDown from "#/icons/angle-down-solid.svg?react"; +import ArrowUp from "#/icons/angle-up-solid.svg?react"; +import { useSettings } from "#/hooks/query/use-settings"; +import { I18nKey } from "#/i18n/declaration"; +import type { + CriticResult, + CriticFeature, + CriticCategorizedFeatures, +} from "#/types/agent-server/core/base/critic"; + +/** + * Normalize potentially malformed runtime scores before rendering. + */ +function normalizeScore(score: number): number { + if (!Number.isFinite(score)) return 0; + return Math.min(1, Math.max(0, score)); +} + +/** + * Convert a normalized score (0-1) to a 5-star rating string. + */ +function getStarRating(normalizedScore: number): { + filled: number; + empty: number; +} { + const filled = Math.round(normalizedScore * 5); + return { filled, empty: 5 - filled }; +} + +/** + * Get the color class for the star rating based on score. + */ +function getScoreColorClass(score: number): string { + if (score >= 0.6) return "text-green-400"; + if (score >= 0.4) return "text-yellow-400"; + return "text-red-400"; +} + +/** + * Get the color class for an issue probability. + */ +function getIssueColorClass(probability: number): string { + if (probability >= 0.7) return "text-red-400 font-semibold"; + if (probability >= 0.5) return "text-yellow-400"; + return "text-neutral-400"; +} + +function isSettingsRecord(value: unknown): value is Record { + return ( + value !== null && + value !== undefined && + typeof value === "object" && + !Array.isArray(value) + ); +} + +function getIterativeRefinementEnabled( + agentSettings: Record | null | undefined, +): boolean | null { + const verification = agentSettings?.verification; + if (!isSettingsRecord(verification)) { + return null; + } + + const value = verification.enable_iterative_refinement; + return typeof value === "boolean" ? value : null; +} + +/** + * Renders a single issue feature with its probability. + */ +function FeatureItem({ feature }: { feature: CriticFeature }) { + const percentage = Math.round(feature.probability * 100); + const colorClass = getIssueColorClass(feature.probability); + + return ( + + {feature.display_name} + ({percentage}%) + + ); +} + +/** + * Renders a category of features (e.g., "Potential Issues", "Infrastructure"). + */ +function FeatureCategory({ + label, + features, +}: { + label: string; + features: CriticFeature[]; +}) { + if (!features || features.length === 0) return null; + + return ( +
+ {label} + {features.map((feature, i) => ( + + {i > 0 && ·} + + + ))} +
+ ); +} + +/** + * Renders the categorized features breakdown. + */ +function FeaturesBreakdown({ + categorized, +}: { + categorized: CriticCategorizedFeatures; +}) { + const { t } = useTranslation(); + + const hasAgentIssues = + categorized.agent_behavioral_issues && + categorized.agent_behavioral_issues.length > 0; + const hasUserPatterns = + categorized.user_followup_patterns && + categorized.user_followup_patterns.length > 0; + const hasInfra = + categorized.infrastructure_issues && + categorized.infrastructure_issues.length > 0; + const hasOther = categorized.other && categorized.other.length > 0; + + if (!hasAgentIssues && !hasUserPatterns && !hasInfra && !hasOther) { + return null; + } + + return ( +
+ {hasAgentIssues && ( + + )} + {hasInfra && ( + + )} + {hasUserPatterns && ( + + )} + {hasOther && ( + + )} +
+ ); +} + +interface CriticResultDisplayProps { + criticResult: CriticResult; +} + +/** + * Displays a critic evaluation result with star rating, score percentage, + * and expandable categorized feature breakdown. + */ +export function CriticResultDisplay({ + criticResult, +}: CriticResultDisplayProps) { + const { t } = useTranslation(); + const { data: settings } = useSettings(); + const [expanded, setExpanded] = React.useState(false); + + const normalizedScore = normalizeScore(criticResult.score); + const { filled, empty } = getStarRating(normalizedScore); + const colorClass = getScoreColorClass(normalizedScore); + const percentage = (normalizedScore * 100).toFixed(1); + const iterativeRefinementEnabled = getIterativeRefinementEnabled( + settings?.agent_settings as Record | null | undefined, + ); + const showIterativeRefinementHint = iterativeRefinementEnabled === false; + + const categorized = criticResult.metadata?.categorized_features; + const hasDetails = + categorized != null && + ((categorized.agent_behavioral_issues ?? []).length > 0 || + (categorized.user_followup_patterns ?? []).length > 0 || + (categorized.infrastructure_issues ?? []).length > 0 || + (categorized.other ?? []).length > 0); + + return ( +
+
+ + {t(I18nKey.CRITIC$SUCCESS_LIKELIHOOD_LABEL)} + + + {"★".repeat(filled)} + {"☆".repeat(empty)} + + ({percentage}%) + + {hasDetails && ( + + )} +
+ + {expanded && hasDetails && ( + + )} + + {showIterativeRefinementHint && ( +

+ {t(I18nKey.CRITIC$ITERATIVE_REFINEMENT_HINT)} +

+ )} +
+ ); +} diff --git a/src/components/conversation-events/chat/event-message-components/error-event-message.tsx b/src/components/conversation-events/chat/event-message-components/error-event-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1b26c39c88d4b5bcda962903536917a810d4b658 --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/error-event-message.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { AgentErrorEvent } from "#/types/agent-server/core"; +import { isAgentErrorEvent } from "#/types/agent-server/type-guards"; +import { ErrorMessage } from "../../../features/chat/error-message"; + +interface ErrorEventMessageProps { + event: AgentErrorEvent; +} + +export function ErrorEventMessage({ event }: ErrorEventMessageProps) { + if (!isAgentErrorEvent(event)) { + return null; + } + + return ; +} diff --git a/src/components/conversation-events/chat/event-message-components/event-group.tsx b/src/components/conversation-events/chat/event-message-components/event-group.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22f61eb3a8806256331c2b877a4b7d2b76e337d3 --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/event-group.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { LoaderCircle } from "lucide-react"; +import ArrowDown from "#/icons/angle-down-solid.svg?react"; +import ArrowUp from "#/icons/angle-up-solid.svg?react"; +import { OpenHandsEvent, ActionEvent } from "#/types/agent-server/core"; +import { + isActionEvent, + isObservationEvent, +} from "#/types/agent-server/type-guards"; +import { I18nKey } from "#/i18n/declaration"; +import { getEventContent } from "../event-content-helpers/get-event-content"; +import { IsInEventGroupContext } from "../../../features/chat/is-in-event-group-context"; +import { PathInteractiveContext } from "../../../features/chat/path-component"; + +interface EventGroupProps { + /** The events represented by this group. Used to compute the summary. */ + events: OpenHandsEvent[]; + /** + * Full event history. Used to resolve the action that produced the latest + * observation in the group so the summary title matches what the individual + * card would show (e.g. "Editing path/to/file"). Falls back to `events` when + * omitted. + */ + allEvents?: OpenHandsEvent[]; + /** + * `true` once an event outside this group has been emitted after it, so the + * group is no longer the "live" tail of the chat. While `false` (the + * default), the group keeps showing the most recent action's title as its + * prominent summary, with the count of completed actions shown subtly on + * the right. + */ + isFinalized?: boolean; + /** The fully-rendered event messages to show when the group is expanded. */ + children: React.ReactNode; +} + +/** + * Collapsible container that wraps a run of consecutive agent action/observation + * events into a single summary card. + * + * Collapsed, while the group is still the live tail of the chat + * (`isFinalized=false`): + * - Left (prominent): the title of the most recent action/observation in + * the group — i.e. either the action currently in flight, or the latest + * completed step. + * - Right (subdued): "{completed}/{total} actions completed" while at + * least one action is still pending (with a spinner), otherwise + * "{count} actions completed" followed by a success check. + * + * Collapsed, after the group has been "moved past" (`isFinalized=true`): + * - "{count} actions completed" is promoted to the prominent foreground + * style and the count is the only thing shown next to the chevron. + * + * Expanded: + * - Renders the children verbatim, so each individual action/observation can + * still be expanded the way it was before grouping. + */ +export function EventGroup({ + events, + allEvents, + isFinalized = false, + children, +}: EventGroupProps) { + const { t } = useTranslation("openhands"); + const [expanded, setExpanded] = React.useState(false); + const contentId = React.useId(); + const buttonId = `${contentId}-toggle`; + + if (events.length === 0) { + return null; + } + + // Each ObservationEvent in the group is a completed action. An ActionEvent + // that's still here (i.e. not yet replaced by its observation in the UI + // events array) is an action currently in flight. + const pendingAction = events.find((e): e is ActionEvent => isActionEvent(e)); + const completedCount = events.filter(isObservationEvent).length; + const totalCount = events.length; + const isRunning = !!pendingAction; + + // Title of the most recent groupable event. While running this is the + // pending action; otherwise it's the latest observation, with its + // originating action looked up so the title can be the action-style summary + // ("Editing path/to/file") instead of the observation default. + const latestEvent = events[events.length - 1]; + let latestTitle: React.ReactNode = null; + if (latestEvent) { + if (isActionEvent(latestEvent)) { + latestTitle = getEventContent(latestEvent).title; + } else if (isObservationEvent(latestEvent)) { + const lookupSource = allEvents ?? events; + const correspondingAction = lookupSource.find( + (e): e is ActionEvent => + isActionEvent(e) && e.id === latestEvent.action_id, + ); + latestTitle = getEventContent(latestEvent, correspondingAction).title; + } + } + + const countSummary = isRunning + ? t(I18nKey.EVENT_GROUP$ACTIONS_PROGRESS, { + completed: completedCount, + total: totalCount, + }) + : t(I18nKey.EVENT_GROUP$ACTIONS_COMPLETED, { count: totalCount }); + + const Chevron = expanded ? ArrowUp : ArrowDown; + + return ( +
+ + + {expanded && ( +
+ + {children} + +
+ )} +
+ ); +} diff --git a/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx b/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..244086262ce4b3e463366024fd591b85bf8d0f49 --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/finish-event-message.tsx @@ -0,0 +1,35 @@ +import { ActionEvent } from "#/types/agent-server/core"; +import { FinishAction } from "#/types/agent-server/core/base/action"; +import { ChatMessage } from "../../../features/chat/chat-message"; +import { getEventContent } from "../event-content-helpers/get-event-content"; +import { CriticResultDisplay } from "./critic-result-display"; + +interface FinishEventMessageProps { + event: ActionEvent; + isFromPlanningAgent?: boolean; +} + +export function FinishEventMessage({ + event, + isFromPlanningAgent = false, +}: FinishEventMessageProps) { + const eventContent = getEventContent(event); + const message = + typeof eventContent.details === "string" + ? eventContent.details + : String(eventContent.details); + + return ( + <> + + {event.critic_result != null && ( + + )} + + ); +} diff --git a/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx b/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21c0bf270c7a8c0db092ec9c536a5dce9960b10a --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/generic-event-message-wrapper.tsx @@ -0,0 +1,127 @@ +import { + OpenHandsEvent, + ActionEvent, + ObservationEvent, +} from "#/types/agent-server/core"; +import { InvokeSkillObservation } from "#/types/agent-server/core/base/observation"; +import { I18nKey } from "#/i18n/declaration"; +import { GenericEventMessage } from "../../../features/chat/generic-event-message"; +import { getEventContent } from "../event-content-helpers/get-event-content"; +import { + getACPToolCallResult, + getObservationResult, + ObservationResultStatus, +} from "../event-content-helpers/get-observation-result"; +import { + isACPToolCallEvent, + isObservationEvent, +} from "#/types/agent-server/type-guards"; +import { + SkillReadyEvent, + SkillReadyItem, + isSkillReadyEvent, +} from "../event-content-helpers/create-skill-ready-event"; +import { getInvokeSkillItems } from "../event-content-helpers/get-invoke-skill-items"; +import { SkillReadyContentList } from "./skill-ready-content-list"; +import SkillsIcon from "#/icons/skills.svg?react"; +import { isMarkdownFileEditorEvent } from "#/components/features/chat/tool-visualizers/primitives/markdown-file-preview"; + +interface GenericEventMessageWrapperProps { + event: OpenHandsEvent | SkillReadyEvent; + isLastMessage: boolean; + correspondingAction?: ActionEvent; +} + +/** + * Resolves the expandable skill-knowledge list shared by Skill Ready events and + * invoke-skill tool observations. Returns the list items and their header + * label, or null when the event carries no skill knowledge (so the caller keeps + * the default details body). Additional skill-knowledge sources slot in as one + * more branch here. + */ +function getSkillKnowledge( + event: OpenHandsEvent | SkillReadyEvent, +): { items: SkillReadyItem[]; titleKey: I18nKey } | null { + if (isSkillReadyEvent(event)) { + return event._skillReadyItems.length > 0 + ? { + items: event._skillReadyItems, + titleKey: I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE, + } + : null; + } + if ( + isObservationEvent(event) && + event.observation.kind === "InvokeSkillObservation" + ) { + const items = getInvokeSkillItems( + event as ObservationEvent, + ); + return items.length > 0 + ? { items, titleKey: I18nKey.SKILLS$INVOKED_SKILL_KNOWLEDGE } + : null; + } + return null; +} + +export function GenericEventMessageWrapper({ + event, + correspondingAction, +}: GenericEventMessageWrapperProps) { + const { title, details } = getEventContent(event, correspondingAction); + + // TaskTrackerObservation has its own rendering + if ( + !isSkillReadyEvent(event) && + isObservationEvent(event) && + event.observation.kind === "TaskTrackerObservation" + ) { + return
{details}
; + } + + // Determine success status + let success: ObservationResultStatus | undefined; + if (isSkillReadyEvent(event)) { + success = "success"; + } else if (isObservationEvent(event)) { + success = getObservationResult(event); + } else if (isACPToolCallEvent(event)) { + success = getACPToolCallResult(event); + } + + // Skill Ready events and invoke-skill tool observations both render the + // expandable skill-knowledge list (with the skills icon); they differ only in + // the header label. + const skillKnowledge = getSkillKnowledge(event); + const bodyDetails = skillKnowledge ? ( + + ) : ( + details + ); + + // Markdown file-editor cards carry a clipped preview; expand them by + // default so the artifact is visible without an extra chevron click. + const initiallyExpanded = + !isSkillReadyEvent(event) && + isMarkdownFileEditorEvent(event, correspondingAction); + + return ( +
+ + ) : undefined + } + /> +
+ ); +} diff --git a/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx b/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4bec021ab798a9a405727d00a772bd0719c9a8fb --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/hook-execution-event-message.tsx @@ -0,0 +1 @@ +export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message"; diff --git a/src/components/conversation-events/chat/event-message-components/index.ts b/src/components/conversation-events/chat/event-message-components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4da5c75b3de4b32546f03276dd7f9c160caf28b0 --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/index.ts @@ -0,0 +1,8 @@ +export { UserAssistantEventMessage } from "./user-assistant-event-message"; +export { ErrorEventMessage } from "./error-event-message"; +export { FinishEventMessage } from "./finish-event-message"; +export { GenericEventMessageWrapper } from "./generic-event-message-wrapper"; +export { ThoughtEventMessage } from "./thought-event-message"; +export { HookExecutionEventMessage } from "./hook-execution-event-message"; +export { EventGroup } from "./event-group"; +export { CriticResultDisplay } from "./critic-result-display"; diff --git a/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx b/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e9da328d8fb91213af06c4c2ff6212f8a67d4368 --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/skill-item-expanded.tsx @@ -0,0 +1,96 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { METADATA_PREFIXES } from "#/utils/constants"; +import { Typography } from "#/ui/typography"; +import { MarkdownRenderer } from "../../../features/markdown/markdown-renderer"; + +interface ParsedSkillContent { + matchInfo: string | null; + filePath: string | null; + body: string; +} + +/** + * Parses skill content into metadata (keyword match info, file path) + * and the actual skill body. + */ +export function parseSkillContent(content: string): ParsedSkillContent { + const lines = content.split("\n"); + let matchInfo: string | null = null; + let filePath: string | null = null; + let bodyStartIndex = 0; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + + if (line.startsWith(METADATA_PREFIXES[0])) { + matchInfo = line.trim(); + bodyStartIndex = i + 1; + } else if (line.startsWith(METADATA_PREFIXES[2])) { + filePath = line.replace(METADATA_PREFIXES[2], "").trim(); + bodyStartIndex = i + 1; + } else if ( + line.startsWith(METADATA_PREFIXES[1]) || + line.startsWith(METADATA_PREFIXES[3]) + ) { + bodyStartIndex = i + 1; + } else if (line.trim() === "" && i <= bodyStartIndex) { + bodyStartIndex = i + 1; + } else { + break; + } + } + + return { + matchInfo, + filePath, + body: lines.slice(bodyStartIndex).join("\n").trim(), + }; +} + +/** + * Wraps `...` tags in the content with bold markers + * so the markdown renderer displays them as bold text. + */ +export function styleImportantTags(text: string): string { + return text.replace( + /([\s\S]*?)<\/important>/gi, + (_match, inner) => `**${inner.trim()}**`, + ); +} + +interface SkillItemExpandedProps { + content: string; +} + +export function SkillItemExpanded({ content }: SkillItemExpandedProps) { + const { t } = useTranslation("openhands"); + const { matchInfo, filePath, body } = parseSkillContent(content); + const hasMetadata = matchInfo || filePath; + + return ( +
+ {hasMetadata && ( +
+ {matchInfo &&

{matchInfo}

} + {filePath && ( +

+ + {t(I18nKey.COMMON$PATH)}{" "} + + + {filePath} + +

+ )} +
+ )} + + {hasMetadata && body && ( +
+ )} + + {body && {styleImportantTags(body)}} +
+ ); +} diff --git a/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx b/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..97018eb264f54a3c0302722d139c5e019f3df1b2 --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/skill-ready-content-list.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { SkillReadyItem } from "../event-content-helpers/create-skill-ready-event"; +import { SkillItemExpanded } from "./skill-item-expanded"; + +interface SkillReadyContentListProps { + items: SkillReadyItem[]; + /** + * Translation key for the list header. Defaults to the "Triggered Skill + * Knowledge:" label used by Skill Ready events; invoke-skill observations + * pass "Invoked Skill Knowledge:" instead. + */ + titleKey?: I18nKey; +} + +export function SkillReadyContentList({ + items, + titleKey = I18nKey.SKILLS$TRIGGERED_SKILL_KNOWLEDGE, +}: SkillReadyContentListProps) { + const { t } = useTranslation("openhands"); + const [expandedSkills, setExpandedSkills] = React.useState< + Record + >({}); + + const toggleSkill = (name: string) => { + setExpandedSkills((prev) => ({ ...prev, [name]: !prev[name] })); + }; + + return ( +
+ + {t(titleKey)} + + {items.map((item) => { + const isExpanded = expandedSkills[item.name] || false; + + return ( +
+ + + {isExpanded && item.content && ( + <> +
+ + + )} +
+ ); + })} +
+ ); +} diff --git a/src/components/conversation-events/chat/event-message-components/thought-event-message.tsx b/src/components/conversation-events/chat/event-message-components/thought-event-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..19ce187ecec515640503f5023bd8e2c7e93c47fe --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/thought-event-message.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { ActionEvent } from "#/types/agent-server/core"; +import { ChatMessage } from "../../../features/chat/chat-message"; + +interface ThoughtEventMessageProps { + event: ActionEvent; + actions?: Array<{ + icon: React.ReactNode; + onClick: () => void; + tooltip?: string; + }>; + isFromPlanningAgent?: boolean; +} + +export function ThoughtEventMessage({ + event, + actions, + isFromPlanningAgent = false, +}: ThoughtEventMessageProps) { + // Extract thought content from the action event + const thoughtContent = event.thought + .filter((t) => t.type === "text") + .map((t) => t.text) + .join("\n"); + + // If there's no thought content, don't render anything + if (!thoughtContent) { + return null; + } + + return ( + + ); +} diff --git a/src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx b/src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..64d5e4b931ed859bad38cbc615c0ef2149043a13 --- /dev/null +++ b/src/components/conversation-events/chat/event-message-components/user-assistant-event-message.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigation } from "#/context/navigation-context"; +import { MessageEvent } from "#/types/agent-server/core"; +import { ChatMessage } from "../../../features/chat/chat-message"; +import { ImageCarousel } from "../../../features/images/image-carousel"; +import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event"; +import { CriticResultDisplay } from "./critic-result-display"; +import { CollapsibleThinking } from "./collapsible-thinking"; +import { splitInlineThink } from "../event-thought-helpers"; +import RepoForkedIcon from "#/icons/repo-forked.svg?react"; +import { I18nKey } from "#/i18n/declaration"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useForkConversation } from "#/hooks/mutation/use-fork-conversation"; +import { useConversationStore } from "#/stores/conversation-store"; +import ConversationService from "#/api/conversation-service/conversation-service.api"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; + +interface UserAssistantEventMessageProps { + event: MessageEvent; + isLastMessage: boolean; + isFromPlanningAgent: boolean; +} + +export function UserAssistantEventMessage({ + event, + isFromPlanningAgent, +}: UserAssistantEventMessageProps) { + const { t } = useTranslation("openhands"); + const { navigate } = useNavigation(); + const { conversationId } = useOptionalConversationId(); + const isCloud = useActiveBackend().backend.kind === "cloud"; + const { mutate: forkConversation, isPending: isForking } = + useForkConversation(); + const setMessageToSend = useConversationStore( + (state) => state.setMessageToSend, + ); + // Blocks a same-tick double-click, before `isForking` flips. + const forkInFlightRef = React.useRef(false); + + const parsed = parseMessageFromEvent(event); + // Route an inline block (e.g. from a streamed reply) to the thinking + // section so reloaded conversations match the live rendering. + const { reasoning, message } = + event.source === "agent" + ? splitInlineThink(parsed) + : { reasoning: "", message: parsed }; + + const imageUrls: string[] = []; + if (Array.isArray(event.llm_message.content)) { + event.llm_message.content.forEach((content) => { + if (content.type === "image") { + imageUrls.push(...content.image_urls); + } + }); + } + + // "Branch from here": a user message is "edit message" (excludes the message, + // restores its text to the composer); an assistant message branches + // inclusively. Local agent-server only, inside a conversation. + const canBranch = !isCloud && !!conversationId; + const handleBranch = () => { + if (!conversationId || isForking || forkInFlightRef.current) return; + forkInFlightRef.current = true; + + // Distinct title so the fork doesn't read identically to its source. + // getCurrentConversation() is a shared singleton — match the id in case it + // still holds the previously-viewed conversation. + const source = ConversationService.getCurrentConversation(); + const sourceTitle = + source?.id === conversationId ? source.title : undefined; + const branchTitle = sourceTitle ? `${sourceTitle} (branch)` : undefined; + + // Only edit when there's text: an image-only message parses to "", so + // branch it inclusively rather than dropping the image. + const isEdit = event.source === "user" && message.length > 0; + + forkConversation( + { + sourceConversationId: conversationId, + eventId: event.id, + ...(isEdit ? { editText: message } : {}), + ...(branchTitle ? { title: branchTitle } : {}), + }, + { + onSuccess: ({ info, excluded }) => { + navigate(`/conversations/${info.id}`); + // Prefill only when excluded (else the send duplicates it). Deferred + // so the new conversation's composer receives it (as useLaunchSkillInChat). + if (excluded) { + window.setTimeout(() => setMessageToSend(message), 0); + } + }, + onError: (error) => + displayErrorToast(error instanceof Error ? error.message : null), + onSettled: () => { + forkInFlightRef.current = false; + }, + }, + ); + }; + const actions = canBranch + ? [ + { + icon: , + onClick: handleBranch, + tooltip: t(I18nKey.CHAT_INTERFACE$BRANCH_FROM_HERE), + }, + ] + : undefined; + + return ( + <> + {reasoning && } + + {imageUrls.length > 0 && ( + + )} + + {event.source === "agent" && event.critic_result != null && ( + + )} + + ); +} diff --git a/src/components/conversation-events/chat/event-message.tsx b/src/components/conversation-events/chat/event-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e35a4df71c5595b97faea30ec5f78d214ec95f50 --- /dev/null +++ b/src/components/conversation-events/chat/event-message.tsx @@ -0,0 +1,375 @@ +import React from "react"; +import { + OpenHandsEvent, + MessageEvent, + ActionEvent, +} from "#/types/agent-server/core"; +import { + FinishAction, + ThinkAction, +} from "#/types/agent-server/core/base/action"; +import { + isActionEvent, + isObservationEvent, + isAgentErrorEvent, + isUserMessageEvent, + isPlanningFileEditorObservationEvent, + isHookExecutionEvent, + isACPToolCallEvent, + isStreamingDeltaEvent, + isConversationStateUpdateEvent, + isGoalConversationStateUpdateEvent, +} from "#/types/agent-server/type-guards"; +import { useConfig } from "#/hooks/query/use-config"; +import { useConversationStore } from "#/stores/conversation-store"; +import { useAgentState, usePlanningAgentState } from "#/hooks/use-agent-state"; +import { AgentState } from "#/types/agent-state"; +import { ChatMessage } from "#/components/features/chat/chat-message"; +import { GoalStatusContent } from "#/components/features/chat/goal-status-content"; +import { PlanPreview } from "../../features/chat/plan-preview"; +import { ErrorEventMessage } from "./event-message-components/error-event-message"; +import { UserAssistantEventMessage } from "./event-message-components/user-assistant-event-message"; +import { FinishEventMessage } from "./event-message-components/finish-event-message"; +import { GenericEventMessageWrapper } from "./event-message-components/generic-event-message-wrapper"; +import { ThoughtEventMessage } from "./event-message-components/thought-event-message"; +import { CollapsibleThinking } from "./event-message-components/collapsible-thinking"; +import { HookExecutionEventMessage } from "./event-message-components/hook-execution-event-message"; +import { createSkillReadyEvent } from "./event-content-helpers/create-skill-ready-event"; +import { shouldShowPlanPreview } from "./hooks/use-plan-preview-events"; +import { getReasoningContent, splitInlineThink } from "./event-thought-helpers"; + +interface EventMessageProps { + event: OpenHandsEvent & { isFromPlanningAgent?: boolean }; + messages: OpenHandsEvent[]; + isLastMessage: boolean; + isInLast10Actions: boolean; + /** Set of event IDs that should render PlanPreview (one per user message phase) */ + planPreviewEventIds?: Set; + /** + * When true, do not render the inline `ThoughtEventMessage` for action / + * observation events. The caller is expected to render the thought + * separately (e.g. via a hoisted "thought" rendered item) so the message + * pane and the in-group rendering don't double up on the same content. + * Has no effect on `ThinkAction`, whose thought IS its action body. + */ + suppressThought?: boolean; +} + +const getActivatedSkills = (event: MessageEvent): string[] => + event.activated_skills || []; + +/** + * Checks if extended content contains valid text content. + */ +const hasValidExtendedContent = ( + extendedContent: MessageEvent["extended_content"], +): boolean => { + if (!extendedContent || extendedContent.length === 0) { + return false; + } + + return extendedContent.some( + (content) => content.type === "text" && content.text.trim().length > 0, + ); +}; + +/** + * Determines if a Skill Ready event should be displayed for the given message event. + */ +const shouldShowSkillReadyEvent = (messageEvent: MessageEvent): boolean => { + const activatedSkills = getActivatedSkills(messageEvent); + const hasActivatedSkills = activatedSkills.length > 0; + const hasExtendedContent = hasValidExtendedContent( + messageEvent.extended_content, + ); + + return hasActivatedSkills && hasExtendedContent; +}; + +interface CommonProps { + isLastMessage: boolean; + isInLast10Actions: boolean; + config: unknown; + isFromPlanningAgent: boolean; +} + +/** + * Renders a user message with its corresponding Skill Ready event. + */ +const renderUserMessageWithSkillReady = ( + messageEvent: MessageEvent, + commonProps: CommonProps, + isLastMessage: boolean, +): React.ReactElement => { + try { + const skillReadyEvent = createSkillReadyEvent(messageEvent); + return ( + <> + + + + ); + } catch (error) { + // If skill ready event creation fails, just render the user message + return ( + + ); + } +}; + +/** + * Renders the plan preview. Its own component so `usePlanningAgentState()` + * only subscribes on this rare row, not every message in the conversation. + */ +function PlanningObservationPreview({ + planContent, + isLastMessage, + isMainAgentRunning, +}: { + planContent: string | null; + isLastMessage: boolean; + isMainAgentRunning: boolean; +}) { + const { + localPlanningConversationId, + curPlanningAgentState, + isPlanningAgentRunning, + } = usePlanningAgentState(); + + // Guard on the id explicitly — useAgentState(undefined) falls back to the + // route conversation, which could be mistaken for the planner's activity. + const isStreaming = + isLastMessage && + !!localPlanningConversationId && + curPlanningAgentState === AgentState.RUNNING; + + return ( + + ); +} + +export function EventMessage({ + event, + messages, + isLastMessage, + isInLast10Actions, + planPreviewEventIds, + suppressThought = false, +}: EventMessageProps) { + const { data: config } = useConfig(); + const { planContent } = useConversationStore(); + const { curAgentState } = useAgentState(); + + // Planner-running state is folded in by PlanningObservationPreview below, + // not read here, to avoid a second useAgentState() subscription per row. + const isAgentRunning = + curAgentState === AgentState.RUNNING || + curAgentState === AgentState.LOADING; + + // Read isFromPlanningAgent directly from the event object + const isFromPlanningAgent = event.isFromPlanningAgent || false; + + // Common props for components that need them + const commonProps = { + isLastMessage, + isInLast10Actions, + config, + isFromPlanningAgent, + }; + + // Finished `/goal` loop: render the terminal status inline so it settles into + // the conversation. should-render-event.ts only lets the terminal goal event + // through; the active loop is shown by the separate bottom GoalStatusBanner. + if ( + isConversationStateUpdateEvent(event) && + isGoalConversationStateUpdateEvent(event) + ) { + return ; + } + + // Agent error events + if (isAgentErrorEvent(event)) { + return ; + } + + // Hook execution events + if (isHookExecutionEvent(event)) { + return ; + } + + // ACP sub-agent tool call events (Claude Code, Codex, Gemini CLI, …) + // render through the same generic wrapper used for observation events so + // the card shape, success indicator and markdown rendering all match. + if (isACPToolCallEvent(event)) { + return ( + + ); + } + + if (isStreamingDeltaEvent(event)) { + // Route an inline block to the thinking section, not the bubble. + const { reasoning: inlineThink, message } = splitInlineThink( + event.content ?? "", + { streaming: true }, + ); + const reasoningContent = [event.reasoning_content ?? "", inlineThink] + .filter(Boolean) + .join("\n\n"); + return ( + <> + {reasoningContent && } + {message && ( + + )} + + ); + } + + // Finish actions + if (isActionEvent(event) && event.action.kind === "FinishAction") { + return ( + } + {...commonProps} + /> + ); + } + + // ThinkAction - render the thought inside a collapsible section so it + // doesn't dominate the chat, especially when the thinking language + // (usually English) differs from the conversation language. + if (isActionEvent(event) && event.action.kind === "ThinkAction") { + const thinkAction = event as ActionEvent; + return ; + } + + // Action events - render thought + action (will be replaced by thought + observation) + if (isActionEvent(event)) { + const reasoningContent = getReasoningContent(event); + return ( + <> + {reasoningContent && } + {!suppressThought && ( + + )} + + + ); + } + + // Observation events - find the corresponding action and render thought + observation + if (isObservationEvent(event)) { + // Handle PlanningFileEditorObservation specially + if (isPlanningFileEditorObservationEvent(event)) { + // Only show PlanPreview if this event is marked as the one to display + // (last PlanningFileEditorObservation in its phase) + if ( + planPreviewEventIds && + shouldShowPlanPreview(event.id, planPreviewEventIds) + ) { + return ( + + ); + } + // Not the designated preview event for this phase - render nothing + // This prevents duplicate previews within the same phase + return null; + } + + // Find the action that this observation is responding to + const correspondingAction = messages.find( + (msg) => isActionEvent(msg) && msg.id === event.action_id, + ); + + // Skip ThoughtEventMessage for ThinkAction (thought IS the action) + const shouldShowThought = + !suppressThought && + correspondingAction && + isActionEvent(correspondingAction) && + correspondingAction.action.kind !== "ThinkAction"; + + const reasoningContent = + correspondingAction && isActionEvent(correspondingAction) + ? getReasoningContent(correspondingAction) + : ""; + + return ( + <> + {reasoningContent && } + {shouldShowThought && ( + + )} + + + ); + } + + // Message events (user and assistant messages) + if (!isActionEvent(event) && !isObservationEvent(event)) { + const messageEvent = event as MessageEvent; + + // Check if this is a user message that should display a Skill Ready event + if (isUserMessageEvent(event) && shouldShowSkillReadyEvent(messageEvent)) { + return renderUserMessageWithSkillReady( + messageEvent, + commonProps, + isLastMessage, + ); + } + + // Render normal message event (user or assistant) + return ( + + ); + } + + // Generic fallback for all other events + return ( + + ); +} diff --git a/src/components/conversation-events/chat/event-thought-helpers.ts b/src/components/conversation-events/chat/event-thought-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..101240af6542a21038e497f09c5e2c6493bc6e63 --- /dev/null +++ b/src/components/conversation-events/chat/event-thought-helpers.ts @@ -0,0 +1,112 @@ +import { ActionEvent, OpenHandsEvent } from "#/types/agent-server/core"; +import { ThinkingBlock } from "#/types/agent-server/core/base/event"; +import { + isActionEvent, + isObservationEvent, +} from "#/types/agent-server/type-guards"; + +/** + * Returns the displayable thought text of an `ActionEvent`, or an empty + * string if the event has no usable thought content. + * + * Mirrors the logic used by `ThoughtEventMessage` so callers stay in sync + * with what gets rendered. + */ +export const getActionThoughtText = (action: ActionEvent): string => + action.thought + .filter((t) => t.type === "text") + .map((t) => t.text) + .join("\n"); + +/** + * Extracts extended thinking / reasoning content from an `ActionEvent`. + * + * Prefers `reasoning_content` (a plain string produced by many reasoning + * models). Falls back to the text from `thinking_blocks` (Anthropic + * extended thinking). Returns an empty string when neither is available. + */ +export const getReasoningContent = (action: ActionEvent): string => { + if (action.reasoning_content) { + return action.reasoning_content; + } + + if (action.thinking_blocks?.length) { + return action.thinking_blocks + .filter((b): b is ThinkingBlock => b.type === "thinking") + .map((b) => b.thinking) + .join("\n\n"); + } + + return ""; +}; + +export const hasNonEmptyThought = (action: ActionEvent): boolean => + getActionThoughtText(action).trim().length > 0; + +/** + * Splits a leading `…` reasoning block out of assistant content + * so it renders in the collapsible thinking section, not the message bubble. + * Some models stream reasoning inline instead of via `reasoning_content`. + * + * Conservative to avoid mangling normal messages: only a `` at the very + * start is touched (later occurrences, e.g. quoted in docs, stay verbatim), + * only the first block is peeled, and an unclosed leading `` is reasoning + * only while `streaming` — in a finalized message it's literal output. + */ +export const splitInlineThink = ( + content: string, + options?: { streaming?: boolean }, +): { reasoning: string; message: string } => { + const OPEN = ""; + const CLOSE = ""; + + // Only a at the very start is reasoning. + const leading = content.replace(/^\s+/, ""); + if (!leading.startsWith(OPEN)) { + return { reasoning: "", message: content }; + } + + const afterOpen = leading.slice(OPEN.length); + const close = afterOpen.indexOf(CLOSE); + + if (close === -1) { + // Unclosed: reasoning-in-progress while streaming, else literal output. + return options?.streaming + ? { reasoning: afterOpen.trim(), message: "" } + : { reasoning: "", message: content }; + } + + return { + reasoning: afterOpen.slice(0, close).trim(), + message: afterOpen.slice(close + CLOSE.length).trim(), + }; +}; + +/** + * Find the `ActionEvent` whose thought should be rendered alongside the + * given UI event. For an `ActionEvent` the thought belongs to itself; for + * an `ObservationEvent` we look up the matching action in `allEvents`. + * + * `ThinkAction` is intentionally excluded because its thought IS the + * action body and is rendered through a separate codepath. + */ +export const getThoughtSourceAction = ( + event: OpenHandsEvent, + allEvents: OpenHandsEvent[], +): ActionEvent | null => { + if (isActionEvent(event)) { + if (event.action.kind === "ThinkAction") return null; + return hasNonEmptyThought(event) ? event : null; + } + + if (isObservationEvent(event)) { + const action = allEvents.find( + (e): e is ActionEvent => isActionEvent(e) && e.id === event.action_id, + ); + if (!action) return null; + if (action.action.kind === "ThinkAction") return null; + return hasNonEmptyThought(action) ? action : null; + } + + return null; +}; diff --git a/src/components/conversation-events/chat/group-events.ts b/src/components/conversation-events/chat/group-events.ts new file mode 100644 index 0000000000000000000000000000000000000000..44dab427d4dadef81812aa50c6a93362d16fd031 --- /dev/null +++ b/src/components/conversation-events/chat/group-events.ts @@ -0,0 +1,130 @@ +import { ActionEvent, OpenHandsEvent } from "#/types/agent-server/core"; +import { + isActionEvent, + isObservationEvent, + isPlanningFileEditorObservationEvent, +} from "#/types/agent-server/type-guards"; +import { isMarkdownFileEditorEvent } from "#/components/features/chat/tool-visualizers/primitives/markdown-file-preview"; +import { getThoughtSourceAction } from "./event-thought-helpers"; + +/** Minimum run-length before consecutive actions get folded into a single + * collapsible group. Even pairs are folded so the chat scroll stays compact + * when the agent fires off back-to-back tool calls. */ +export const EVENT_GROUP_MIN_SIZE = 2; + +/** + * Returns true if the given event is one of the action / observation cards + * that we want to fold into an `EventGroup` when several appear in a row. + * + * Events that have their own dedicated rendering (FinishAction, ThinkAction, + * HookExecution, AgentError, MessageEvent, PlanPreview, markdown file + * artifacts, TaskTracker) are treated as group breakers. + */ +export const isGroupableEvent = ( + event: OpenHandsEvent, + correspondingAction?: ActionEvent, +): boolean => { + if (isActionEvent(event)) { + const { kind } = event.action; + if (kind === "FinishAction" || kind === "ThinkAction") { + return false; + } + // Keep markdown *create* artifact cards outside collapsed groups so their + // clipped preview is visible without expanding a parent summary. + if (isMarkdownFileEditorEvent(event, correspondingAction)) { + return false; + } + return true; + } + + if (isObservationEvent(event)) { + if (isPlanningFileEditorObservationEvent(event)) { + return false; + } + if (isMarkdownFileEditorEvent(event, correspondingAction)) { + return false; + } + if (event.observation.kind === "TaskTrackerObservation") { + return false; + } + return true; + } + + return false; +}; + +export type RenderedItem = + | { kind: "single"; event: OpenHandsEvent; index: number } + | { kind: "thought"; action: ActionEvent; index: number } + | { kind: "group"; events: OpenHandsEvent[]; startIndex: number }; + +/** + * Walk a list of UI events and bucket consecutive groupable events into + * `group` items. Anything that breaks the run, or runs shorter than + * `EVENT_GROUP_MIN_SIZE`, is emitted as `single` items so they keep rendering + * the way they always have. + * + * Whenever a groupable event carries an agent thought (either an + * `ActionEvent.thought` or the corresponding action of an + * `ObservationEvent`), the thought is hoisted out as its own `thought` + * `RenderedItem`, the current run is flushed, and a new run is started with + * the event itself. This keeps reasoning text in the main message stream + * instead of buried inside a collapsed action group. + * + * `allEvents` should be the full event history so observations can find + * their matching action; if omitted, it defaults to `events`. + */ +export const groupEvents = ( + events: OpenHandsEvent[], + minSize: number = EVENT_GROUP_MIN_SIZE, + allEvents: OpenHandsEvent[] = events, +): RenderedItem[] => { + if (minSize < 1) { + throw new Error("minSize must be at least 1"); + } + + const items: RenderedItem[] = []; + const emittedThoughtActionIds = new Set(); + let run: { events: OpenHandsEvent[]; startIndex: number } | null = null; + + const flushRun = () => { + if (!run) return; + if (run.events.length >= minSize) { + items.push({ + kind: "group", + events: run.events, + startIndex: run.startIndex, + }); + } else { + run.events.forEach((event, offset) => { + items.push({ kind: "single", event, index: run!.startIndex + offset }); + }); + } + run = null; + }; + + events.forEach((event, index) => { + const correspondingAction = isObservationEvent(event) + ? allEvents.find( + (candidate): candidate is ActionEvent => + isActionEvent(candidate) && candidate.id === event.action_id, + ) + : undefined; + if (isGroupableEvent(event, correspondingAction)) { + const thoughtAction = getThoughtSourceAction(event, allEvents); + if (thoughtAction && !emittedThoughtActionIds.has(thoughtAction.id)) { + flushRun(); + emittedThoughtActionIds.add(thoughtAction.id); + items.push({ kind: "thought", action: thoughtAction, index }); + } + if (!run) run = { events: [], startIndex: index }; + run.events.push(event); + } else { + flushRun(); + items.push({ kind: "single", event, index }); + } + }); + + flushRun(); + return items; +}; diff --git a/src/components/conversation-events/chat/hooks/use-plan-preview-events.ts b/src/components/conversation-events/chat/hooks/use-plan-preview-events.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c639534e86ed1efc02a3627d10d782685c28d97 --- /dev/null +++ b/src/components/conversation-events/chat/hooks/use-plan-preview-events.ts @@ -0,0 +1,119 @@ +import { useMemo } from "react"; +import { OpenHandsEvent } from "#/types/agent-server/core"; +import { + isUserMessageEvent, + isPlanningFileEditorObservationEvent, +} from "#/types/agent-server/type-guards"; + +/** + * Groups events into phases based on user messages. + * A phase starts with a user message and includes all subsequent events + * until the next user message. + * + * @param events - The full list of events + * @returns Array of phases, where each phase is an array of events + */ +function groupEventsByPhase(events: OpenHandsEvent[]): OpenHandsEvent[][] { + const phases: OpenHandsEvent[][] = []; + let currentPhase: OpenHandsEvent[] = []; + + for (const event of events) { + if (isUserMessageEvent(event)) { + // Start a new phase with the user message + if (currentPhase.length > 0) { + phases.push(currentPhase); + } + currentPhase = [event]; + } else { + // Add event to current phase + currentPhase.push(event); + } + } + + // Don't forget the last phase + if (currentPhase.length > 0) { + phases.push(currentPhase); + } + + return phases; +} + +const isPlanFilePath = (path: string | null): boolean => + path?.toUpperCase().endsWith("PLAN.MD") ?? false; + +/** + * Finds the last PlanningFileEditorObservation for Plan.md in a phase. + * + * @param phase - Array of events in a phase + * @returns The event ID of the last Plan.md observation, or null + */ +function findLastPlanningObservationInPhase( + phase: OpenHandsEvent[], +): string | null { + for (let i = phase.length - 1; i >= 0; i -= 1) { + const event = phase[i]; + if ( + isPlanningFileEditorObservationEvent(event) && + isPlanFilePath(event.observation.path) + ) { + return event.id; + } + } + return null; +} + +export interface PlanPreviewEventInfo { + eventId: string; + /** Index of this plan preview in the conversation (1st, 2nd, etc.) */ + phaseIndex: number; +} + +/** + * Hook to determine which PlanningFileEditorObservation events should render PlanPreview. + * + * This hook implements phase-based grouping where: + * - A phase starts with a user message and ends at the next user message + * - Only the LAST PlanningFileEditorObservation in each phase shows PlanPreview + * - This ensures only one preview per user request, even with multiple observations + * + * Scenario handling: + * - Scenario 1 (Create plan): Multiple observations in one phase → 1 preview + * - Scenario 2 (Create then update): Two user messages → two phases → 2 previews + * - Scenario 3 (Create + update while processing): Two user messages → 2 previews + * + * @param allEvents - Full list of v1 events (for phase detection) + * @returns Set of event IDs that should render PlanPreview + */ +export function usePlanPreviewEvents(allEvents: OpenHandsEvent[]): Set { + return useMemo(() => { + const planPreviewEventIds = new Set(); + + // Group events by phases (user message boundaries) + const phases = groupEventsByPhase(allEvents); + + // For each phase, find the last PlanningFileEditorObservation + phases.forEach((phase) => { + const lastPlanningObservationId = + findLastPlanningObservationInPhase(phase); + if (lastPlanningObservationId) { + planPreviewEventIds.add(lastPlanningObservationId); + } + }); + + return planPreviewEventIds; + }, [allEvents]); +} + +/** + * Check if a specific event should render PlanPreview. + * + * @param eventId - The event ID to check + * @param planPreviewEventIds - Set of event IDs that should render PlanPreview + * @returns true if this event should render PlanPreview + */ +export function shouldShowPlanPreview( + eventId: string, + planPreviewEventIds: Set, +): boolean { + return planPreviewEventIds.has(eventId); +} diff --git a/src/components/conversation-events/chat/index.ts b/src/components/conversation-events/chat/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bce6a75795b45d92dc0792cfe9d928fb5a9bd323 --- /dev/null +++ b/src/components/conversation-events/chat/index.ts @@ -0,0 +1,8 @@ +export { Messages } from "./messages"; +export { EventMessage } from "./event-message"; +export * from "./event-message-components"; +export { getEventContent } from "./event-content-helpers/get-event-content"; +export { + shouldRenderEvent, + hasUserEvent, +} from "./event-content-helpers/should-render-event"; diff --git a/src/components/conversation-events/chat/messages.tsx b/src/components/conversation-events/chat/messages.tsx new file mode 100644 index 0000000000000000000000000000000000000000..367eb60574fcb4c1d4074d4354cd65efd941d862 --- /dev/null +++ b/src/components/conversation-events/chat/messages.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { OpenHandsEvent } from "#/types/agent-server/core"; +import { EventMessage } from "./event-message"; +import { usePlanPreviewEvents } from "./hooks/use-plan-preview-events"; +import { groupEvents } from "./group-events"; +import { EventGroup } from "./event-message-components/event-group"; +import { ThoughtEventMessage } from "./event-message-components/thought-event-message"; +import { useModelStore } from "#/stores/model-store"; +import { ModelMessages } from "#/components/features/chat/model-messages"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { ConversationConfirmationButtons } from "#/components/shared/buttons/conversation-confirmation-buttons"; + +interface MessagesProps { + messages: OpenHandsEvent[]; // UI events (actions replaced by observations) + allEvents: OpenHandsEvent[]; // Full event history (for action lookup) +} + +const getLastEventId = (events: OpenHandsEvent[]) => events.at(-1)?.id; +const getLastEvent = (events: OpenHandsEvent[]) => events.at(-1); + +export const Messages: React.FC = React.memo( + ({ messages, allEvents }) => { + const { conversationId } = useOptionalConversationId(); + // Get the set of event IDs that should render PlanPreview + // This ensures only one preview per user message "phase" + const planPreviewEventIds = usePlanPreviewEvents(allEvents); + + // Set of event ids that have a /model entry anchored to them — used to + // avoid mounting for every event (the component would + // otherwise early-return null). + const modelEntries = useModelStore((s) => + conversationId ? s.entriesByConversation[conversationId] : undefined, + ); + const modelAnchorIds = React.useMemo(() => { + if (!modelEntries || modelEntries.length === 0) return null; + const ids = new Set(); + for (const entry of modelEntries) { + if (entry.anchorEventId !== null) ids.add(entry.anchorEventId); + } + return ids.size > 0 ? ids : null; + }, [modelEntries]); + + const maybeRenderModelMessages = (eventId: string | number | undefined) => { + if (!modelAnchorIds || eventId === undefined) return null; + const key = String(eventId); + if (!modelAnchorIds.has(key)) return null; + return ( + + ); + }; + + // Fold consecutive action/observation events into collapsible groups so a + // long sequence of tool calls doesn't dominate the chat scroll. Items that + // can't be grouped (or that fall in a short run) are still rendered one by + // one, identically to before. Agent thoughts attached to an action are + // hoisted out as their own rendered item so they always show up in the + // message pane and a thought between actions starts a fresh group. + const renderedItems = React.useMemo( + () => groupEvents(messages, undefined, allEvents), + [messages, allEvents], + ); + + const renderEventMessage = ( + event: OpenHandsEvent, + index: number, + suppressThought: boolean, + ) => ( + + ); + + return ( + <> + {renderedItems.map((item, itemIndex) => { + if (item.kind === "single") { + return ( + + {/* Thoughts for singles are also hoisted as their own + "thought" item, so suppress the inline render to avoid + duplication. */} + {renderEventMessage(item.event, item.index, true)} + {maybeRenderModelMessages(item.event.id)} + + ); + } + + if (item.kind === "thought") { + return ( + + + {maybeRenderModelMessages(item.action.id)} + + ); + } + + // A group is "finalized" once another rendered item appears after + // it, signalling the agent has moved on. While the group is still + // the live tail, it keeps showing the latest action title as its + // prominent summary. + const isFinalized = itemIndex < renderedItems.length - 1; + const groupKey = item.events[0]?.id ?? `group-${item.startIndex}`; + return ( + + + {item.events.map((event, offset) => + renderEventMessage(event, item.startIndex + offset, true), + )} + + {item.events.map((event) => ( + + {maybeRenderModelMessages(event.id)} + + ))} + + ); + })} + + + ); + }, + (prevProps, nextProps) => + prevProps.messages.length === nextProps.messages.length && + prevProps.allEvents.length === nextProps.allEvents.length && + getLastEventId(prevProps.messages) === getLastEventId(nextProps.messages) && + getLastEventId(prevProps.allEvents) === + getLastEventId(nextProps.allEvents) && + getLastEvent(prevProps.messages) === getLastEvent(nextProps.messages) && + getLastEvent(prevProps.allEvents) === getLastEvent(nextProps.allEvents), +); + +Messages.displayName = "Messages"; diff --git a/src/components/conversation-events/chat/task-tracking/task-item.tsx b/src/components/conversation-events/chat/task-tracking/task-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf776543518092d51d38146639c0f52a65e02164 --- /dev/null +++ b/src/components/conversation-events/chat/task-tracking/task-item.tsx @@ -0,0 +1,56 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common"; +import CircleIcon from "#/icons/u-circle.svg?react"; +import CheckCircleIcon from "#/icons/u-check-circle.svg?react"; +import CheckCircleHalfIcon from "#/icons/u-check-circle-half.svg?react"; +import { cn } from "#/utils/utils"; +import { Typography } from "#/ui/typography"; +import { I18nKey } from "#/i18n/declaration"; + +interface TaskItemProps { + task: TaskItemType; +} + +export function TaskItem({ task }: TaskItemProps) { + const { t } = useTranslation("openhands"); + + const icon = useMemo(() => { + switch (task.status) { + case "todo": + return ; + case "in_progress": + return ; + case "done": + return ; + default: + return ; + } + }, [task.status]); + + const isDoneStatus = task.status === "done"; + + return ( +
+
{icon}
+
+ + {task.title} + + {task.notes && ( + + {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes} + + )} +
+
+ ); +} diff --git a/src/components/conversation-events/chat/task-tracking/task-list-section.test.tsx b/src/components/conversation-events/chat/task-tracking/task-list-section.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1b96b6b68174203b8dd648815966b6ee1339f1ed --- /dev/null +++ b/src/components/conversation-events/chat/task-tracking/task-list-section.test.tsx @@ -0,0 +1,69 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { I18nKey } from "#/i18n/declaration"; +import type { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common"; +import { TaskListSection } from "./task-list-section"; + +const useTranslationMock = vi.hoisted(() => + vi.fn((namespace: string) => ({ + t: (key: string) => + namespace === "openhands" ? `translated:${key}` : `missing:${key}`, + })), +); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: useTranslationMock, +})); + +const createTask = (overrides: Partial = {}): TaskItemType => ({ + title: "Inspect the behavior", + notes: "", + status: "todo", + ...overrides, +}); + +describe("task tracking list", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows every planned task in its supplied order", () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const taskList = [ + createTask({ title: "Inspect the behavior" }), + createTask({ title: "Add focused coverage", status: "in_progress" }), + createTask({ title: "Verify the result", status: "done" }), + ]; + + const { container } = render(); + + expect( + screen.getByText(`translated:${I18nKey.COMMON$TASKS}`), + ).toBeInTheDocument(); + const renderedTasks = container.querySelectorAll('[data-name="item"]'); + expect(renderedTasks).toHaveLength(3); + expect(Array.from(renderedTasks, (task) => task.textContent)).toEqual([ + "Inspect the behavior", + "Add focused coverage", + "Verify the result", + ]); + expect( + consoleErrorSpy.mock.calls.some( + ([message]) => + typeof message === "string" && message.includes("same key"), + ), + ).toBe(false); + }); + + it("keeps the task section visible when the plan is empty", () => { + const { container } = render(); + + expect( + screen.getByText(`translated:${I18nKey.COMMON$TASKS}`), + ).toBeInTheDocument(); + expect(container.querySelectorAll('[data-name="item"]')).toHaveLength(0); + }); +}); diff --git a/src/components/conversation-events/chat/task-tracking/task-list-section.tsx b/src/components/conversation-events/chat/task-tracking/task-list-section.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b0552e84341e561e670e6f5e1490d8845f2c06b --- /dev/null +++ b/src/components/conversation-events/chat/task-tracking/task-list-section.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { TaskItem } from "./task-item"; +import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; +import { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; + +interface TaskListSectionProps { + taskList: TaskItemType[]; +} + +export function TaskListSection({ taskList }: TaskListSectionProps) { + const { t } = useTranslation("openhands"); + + return ( +
+ {/* Header Tabs */} +
+ + + {t(I18nKey.COMMON$TASKS)} + +
+ + {/* Task Items */} +
+ {taskList.map((task, index) => ( + + ))} +
+
+ ); +} diff --git a/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.test.tsx b/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da60704cc640e4e68f66b108a115137e7376878a --- /dev/null +++ b/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { I18nKey } from "#/i18n/declaration"; +import type { ObservationEvent } from "#/types/agent-server/core"; +import type { TaskItem as TaskItemType } from "#/types/agent-server/core/base/common"; +import type { TaskTrackerObservation } from "#/types/agent-server/core/base/observation"; +import { TaskTrackingObservationContent } from "./task-tracking-observation-content"; + +const createTask = (overrides: Partial = {}): TaskItemType => ({ + title: "Inspect the behavior", + notes: "", + status: "todo", + ...overrides, +}); + +const createTaskTrackingEvent = ( + observationOverrides: Partial = {}, +): ObservationEvent => ({ + id: "observation-1", + timestamp: "2026-07-13T12:00:00.000Z", + source: "environment", + tool_name: "task_tracker", + tool_call_id: "tool-call-1", + action_id: "action-1", + observation: { + kind: "TaskTrackerObservation", + content: "Current plan", + command: "plan", + task_list: [createTask()], + ...observationOverrides, + }, +}); + +describe("task tracking observations", () => { + it("shows a non-empty plan", () => { + const event = createTaskTrackingEvent({ + task_list: [ + createTask({ title: "Inspect the behavior" }), + createTask({ title: "Verify the result", status: "done" }), + ], + }); + + render(); + + expect(screen.getByText(I18nKey.COMMON$TASKS)).toBeInTheDocument(); + expect(screen.getByText("Inspect the behavior")).toBeInTheDocument(); + expect(screen.getByText("Verify the result")).toBeInTheDocument(); + }); + + it("hides task data emitted by commands other than plan", () => { + const event = createTaskTrackingEvent({ command: "update" }); + + render(); + + expect(screen.queryByText(I18nKey.COMMON$TASKS)).not.toBeInTheDocument(); + expect(screen.queryByText("Inspect the behavior")).not.toBeInTheDocument(); + }); + + it("hides an empty plan", () => { + const event = createTaskTrackingEvent({ task_list: [] }); + + render(); + + expect(screen.queryByText(I18nKey.COMMON$TASKS)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx b/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a1f7f85ae80cb227365bdddd7502e37589745972 --- /dev/null +++ b/src/components/conversation-events/chat/task-tracking/task-tracking-observation-content.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { ObservationEvent } from "#/types/agent-server/core"; +import { TaskTrackerObservation } from "#/types/agent-server/core/base/observation"; +import { TaskListSection } from "./task-list-section"; + +interface TaskTrackingObservationContentProps { + event: ObservationEvent; +} + +export function TaskTrackingObservationContent({ + event, +}: TaskTrackingObservationContentProps): React.ReactNode { + const { observation } = event; + const { command, task_list: taskList } = observation; + const shouldShowTaskList = command === "plan" && taskList.length > 0; + + return ( +
+ {/* Task List section - only show for 'plan' command */} + {shouldShowTaskList && } +
+ ); +} diff --git a/src/components/conversation/index.ts b/src/components/conversation/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..60b40c32c96a415b96cf861cb14c4afdb3deb873 --- /dev/null +++ b/src/components/conversation/index.ts @@ -0,0 +1,8 @@ +export { ConversationView } from "../../routes/conversation"; +export { ChatInterface as ChatPanel } from "../features/chat/chat-interface"; +export { ConversationLoading } from "../features/conversation/conversation-loading"; +export { ConversationMain } from "../features/conversation/conversation-main/conversation-main"; +export { ChatInterfaceWrapper } from "../features/conversation/conversation-main/chat-interface-wrapper"; +export { ConversationName } from "../features/conversation/conversation-name"; +export { ConversationNameWithStatus } from "../features/conversation/conversation-name-with-status"; +export { ConversationTabs } from "../features/conversation/conversation-tabs/conversation-tabs"; diff --git a/src/components/features/alerts/alert-banner.tsx b/src/components/features/alerts/alert-banner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..141a110638b4de17582afb64861c172fe4d2086e --- /dev/null +++ b/src/components/features/alerts/alert-banner.tsx @@ -0,0 +1,143 @@ +import { useTranslation } from "react-i18next"; +import { useMemo } from "react"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { FaTriangleExclamation } from "react-icons/fa6"; +import CloseIcon from "#/icons/close.svg?react"; +import { useNavigation } from "#/context/navigation-context"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; + +interface AlertBannerProps { + maintenanceStartTime?: string | null; + faultyModels?: string[]; + errorMessage?: string | null; + updatedAt: string; +} + +export function AlertBanner({ + maintenanceStartTime, + faultyModels, + errorMessage, + updatedAt, +}: AlertBannerProps) { + const { t } = useTranslation("openhands"); + const [dismissedAt, setDismissedAt] = useLocalStorage( + "alert_banner_dismissed_at", + null, + ); + + const { currentPath } = useNavigation(); + + // Format ISO timestamp to user's local timezone + const formatMaintenanceTime = (isoTimeString: string): string => { + const date = new Date(isoTimeString); + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); + }; + + const localTime = maintenanceStartTime + ? formatMaintenanceTime(maintenanceStartTime) + : null; + + const hasMaintenanceAlert = !!maintenanceStartTime; + const hasFaultyModels = faultyModels && faultyModels.length > 0; + const hasErrorMessage = errorMessage && errorMessage.trim().length > 0; + + const hasAnyAlert = hasMaintenanceAlert || hasFaultyModels || hasErrorMessage; + + const isBannerVisible = useMemo(() => { + if (!hasAnyAlert) { + return false; + } + return dismissedAt !== updatedAt; + }, [dismissedAt, updatedAt, hasAnyAlert]); + + // Try to translate error message, fallback to raw message + const translatedErrorMessage = useMemo(() => { + if (!errorMessage) return null; + + // Check if the error message is a translation key (e.g., "ERROR$SOME_KEY") + const translated = t(errorMessage as I18nKey); + // If translation returns the same key, it means no translation exists + if (translated === errorMessage) { + return errorMessage; + } + return translated; + }, [errorMessage, t]); + + if (!isBannerVisible) { + return null; + } + + const renderMessages = () => { + const messages: React.ReactNode[] = []; + + if (hasMaintenanceAlert && localTime) { + messages.push( + + {t(I18nKey.MAINTENANCE$SCHEDULED_MESSAGE, { time: localTime })} + , + ); + } + + if (hasFaultyModels) { + messages.push( + + {t(I18nKey.ALERT$FAULTY_MODELS_MESSAGE)} {faultyModels!.join(", ")} + , + ); + } + + if (hasErrorMessage && translatedErrorMessage) { + messages.push( + + {translatedErrorMessage} + , + ); + } + + return messages; + }; + + return ( +
+
+
+ +
+
{renderMessages()}
+
+ + +
+ ); +} diff --git a/src/components/features/backends/add-backend-modal.tsx b/src/components/features/backends/add-backend-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ecacf96963a432442c9a5e0909fa7f2eac233a31 --- /dev/null +++ b/src/components/features/backends/add-backend-modal.tsx @@ -0,0 +1,9 @@ +import { BackendFormModal } from "./backend-form-modal"; + +interface AddBackendModalProps { + onClose: () => void; +} + +export function AddBackendModal({ onClose }: AddBackendModalProps) { + return ; +} diff --git a/src/components/features/backends/api-key-entry-screen.tsx b/src/components/features/backends/api-key-entry-screen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..324dc87317aa7a215d6f88b1d9b24f11575198d3 --- /dev/null +++ b/src/components/features/backends/api-key-entry-screen.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsClient } from "@openhands/typescript-client/clients"; +import { I18nKey } from "#/i18n/declaration"; +import { isSdkHttpStatusError } from "#/api/agent-server-compatibility"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { isNoBackend } from "#/api/backend-registry/active-store"; +import { useActiveBackendContext } from "#/contexts/active-backend-context"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { + MODAL_MAX_WIDTH_VIEWPORT, + modalWidthClassName, +} from "#/components/shared/modals/modal-body"; +import { cn } from "#/utils/utils"; +import { BackendStatusDot } from "./backend-status-dot"; +import { + BackendForm, + type BackendFormSubmitPayload, +} from "./backend-form-modal"; + +/** + * Full-screen prompt shown when the server is in public mode + * (`VITE_AUTH_REQUIRED=true`) and no valid API key has been configured. + * + * Reuses {@link BackendForm} with `hostReadOnly` + `onSubmitOverride` + * to render the standard name / host / API-key inputs while adding + * server-side validation (calls `GET /api/settings` before persisting) + * and a connection status indicator. + */ +export default function ApiKeyEntryScreen() { + const { t } = useTranslation("openhands"); + const { active, addBackend, updateBackend } = useActiveBackendContext(); + + const host = window.location.origin; + + const [isValidating, setIsValidating] = React.useState(false); + const [connectionStatus, setConnectionStatus] = React.useState< + "idle" | "success" | "error" + >("idle"); + const [errorMessage, setErrorMessage] = React.useState(null); + + const handleSubmitOverride = React.useCallback( + async (payload: BackendFormSubmitPayload) => { + setIsValidating(true); + setConnectionStatus("idle"); + setErrorMessage(null); + + try { + await new SettingsClient( + getAgentServerClientOptions({ + host: payload.host, + sessionApiKey: payload.apiKey, + timeout: 5000, + }), + ).getSettings(); + + setConnectionStatus("success"); + + if (isNoBackend(active.backend)) { + addBackend(payload); + } else { + updateBackend(active.backend.id, payload); + } + + window.location.reload(); + } catch (err: unknown) { + setConnectionStatus("error"); + + if (isSdkHttpStatusError(err, 401)) { + setErrorMessage(t(I18nKey.AUTH$INVALID_KEY)); + } else { + const detail = err instanceof Error ? err.message : String(err); + setErrorMessage( + `${t(I18nKey.AUTH$CONNECTION_FAILED)}${detail ? `: ${detail}` : ""}`, + ); + } + setIsValidating(false); + } + }, + [active.backend, addBackend, updateBackend, t], + ); + + return ( +
+
+
+

+ {t(I18nKey.BACKEND$ADD_TITLE)} +

+
+ +
+ {}} + testIdRoot="api-key-entry" + hostReadOnly + requireApiKey + onSubmitOverride={handleSubmitOverride} + renderActions={({ canSubmit, testIdRoot }) => ( + <> + {connectionStatus !== "idle" && ( +
+ + + {connectionStatus === "error" + ? errorMessage + : t(I18nKey.ONBOARDING$BACKEND_STATUS_CONNECTED)} + +
+ )} + + + {isValidating + ? t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING) + : t(I18nKey.BACKEND$CONNECT)} + + + )} + /> +
+
+
+ ); +} diff --git a/src/components/features/backends/backend-form-modal.test.tsx b/src/components/features/backends/backend-form-modal.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..14493ef5e0aa6b0d1395445de06f634502288028 --- /dev/null +++ b/src/components/features/backends/backend-form-modal.test.tsx @@ -0,0 +1,80 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BackendConnectionOptions } from "./backend-form-modal"; + +// Isolate the manual "add backend" column: the sibling Cloud-login column +// drives an OAuth device flow that is irrelevant to backend-kind selection. +vi.mock("./device-flow-auth", () => ({ + DeviceFlowAuth: () => null, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("BackendConnectionOptions — manual backend type", () => { + it("defaults the Type selector to Cloud for an OpenHands Cloud host", () => { + render( + , + ); + + expect(screen.getByTestId("add-backend-kind-option-cloud")).toHaveAttribute( + "aria-checked", + "true", + ); + expect(screen.getByTestId("add-backend-kind-option-local")).toHaveAttribute( + "aria-checked", + "false", + ); + }); + + it("defaults the Type selector to Local for a self-hosted host on a custom domain", () => { + // A self-hosted OHE on a custom domain is indistinguishable from a local + // agent-server by host alone, so it must not be auto-classified as Cloud. + render( + , + ); + + expect(screen.getByTestId("add-backend-kind-option-local")).toHaveAttribute( + "aria-checked", + "true", + ); + }); + + it("persists the user-selected Cloud kind when adding a self-hosted OHE on a custom domain", async () => { + const onConnected = vi.fn(); + render( + , + ); + + // The user overrides the (Local) default to declare this custom domain a + // Cloud app-server, then connects. + fireEvent.click(screen.getByTestId("add-backend-kind-option-cloud")); + fireEvent.click(screen.getByTestId("add-backend-submit")); + + await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1)); + expect(onConnected).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "cloud", + name: "Adorable Enterprise", + host: "https://app.adorable.build.one", + apiKey: "oh-cloud-key", + }), + "manual", + { agentServerVersion: null }, + ); + }); +}); diff --git a/src/components/features/backends/backend-form-modal.tsx b/src/components/features/backends/backend-form-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a302aa5629f20126e73d214ec63dd37afe924be4 --- /dev/null +++ b/src/components/features/backends/backend-form-modal.tsx @@ -0,0 +1,1629 @@ +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, Globe, Info, Monitor } from "lucide-react"; +import { ServerClient } from "@openhands/typescript-client/clients"; +import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { + MODAL_MAX_WIDTH_VIEWPORT, + modalWidthClassName, +} from "#/components/shared/modals/modal-body"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { SegmentedToggle } from "#/components/features/files-tab/segmented-toggle"; +import { useActiveBackendContext } from "#/contexts/active-backend-context"; +import { useNavigation } from "#/context/navigation-context"; +import { useBackendsHealth } from "#/hooks/query/use-backends-health"; +import { useTracking } from "#/hooks/use-tracking"; +import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { getLockedCloudHost } from "#/api/agent-server-config"; +import { isOpenHandsCloudHost } from "#/api/device-flow-client"; +import { + getDisplayAgentServerVersion, + validateLocalBackend, +} from "#/api/agent-server-compatibility"; +import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react"; +import { I18nKey } from "#/i18n/declaration"; +import type { Backend, BackendKind } from "#/api/backend-registry/types"; +import { getUserFacingConnectionErrorMessage } from "#/utils/user-facing-error"; +import { cn } from "#/utils/utils"; +import { + modalTitleLgClassName, + modalTitleLgMediumClassName, +} from "#/utils/modal-classes"; +import ExternalLinkIcon from "#/icons/external-link.svg?react"; +import ServerIcon from "#/icons/server.svg?react"; +import { getBackendStatusLabel } from "./backend-status-label"; +import { BackendStatusDot } from "./backend-status-dot"; +import { DeviceFlowAuth } from "./device-flow-auth"; + +export type BackendFormMode = "add" | "edit"; + +interface BackendConnectionTestMetadata { + agentServerVersion: string | null; +} + +interface BackendFormModalProps { + mode: BackendFormMode; + /** Required when `mode === "edit"`. */ + backend?: Backend; + onClose: () => void; + /** Analytics surface for the `backend_added` event (add mode only). */ + source?: BackendAddedSource; + /** Hide the close button and disable backdrop/escape dismissal. Used for locked Cloud first-run. */ + hideCloseButton?: boolean; +} + +/** + * Seed the default backend kind from the host. Uses proper hostname-suffix + * matching (via {@link isOpenHandsCloudHost}) rather than a substring test, so + * a look-alike host such as `all-hands-testing.dev` isn't misread as cloud. + * + * This is only a *default*: a self-hosted OpenHands Cloud/Enterprise instance + * on a truly custom domain is indistinguishable from a local agent-server by + * host alone, so the manual add form lets the user override the kind + * explicitly (see the Type selector in ManualConnectionColumn). + */ +function inferKindFromHost(host: string): BackendKind { + return isOpenHandsCloudHost(host) ? "cloud" : "local"; +} + +/** + * Returns true for hostnames that represent a local / private-network address. + * Used by normalizeHost to choose http:// instead of https://. + */ +function isLocalAddress(hostname: string): boolean { + // Strip IPv6 bracket notation: [::1] → ::1 + const h = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + // IPv6 loopback, any-address, and named loopback + if (h === "localhost" || h === "::1" || h === "::" || h === "0.0.0.0") + return true; + // 127.x.x.x loopback range + IPv4-mapped loopback (::ffff:127.x.x.x) + if (/^127\./.test(h) || /^::ffff:127\./i.test(h)) return true; + // RFC 1918 private ranges + if (/^10\./.test(h)) return true; + if (/^192\.168\./.test(h)) return true; + if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; + // IPv6 link-local (fe80::/10) and unique local (fc00::/7) + if (/^fe[89ab][0-9a-f]:/i.test(h)) return true; + if (/^f[cd][0-9a-f]{2}:/i.test(h)) return true; + // mDNS / Bonjour (.local) + if (h.endsWith(".local")) return true; + // Single-label hostnames (no dots, no colons) are local network names. + // Colons are excluded so bare IPv6 addresses don't accidentally match. + if (!h.includes(".") && !h.includes(":")) return true; + return false; +} + +function normalizeHost(host: string): string { + const trimmed = host.trim().replace(/\/+$/, ""); + if (!trimmed) return ""; + // Already has an explicit scheme — respect it. + if (/^https?:\/\//i.test(trimmed)) return trimmed; + // Extract the pure hostname for scheme selection, handling three cases: + // [::1]:8080 → bracket IPv6 notation → extract ::1 + // ::1 → bare IPv6 (multiple colons, no bracket) → whole string + // host:port → regular host:port → part before the colon + const bracketMatch = trimmed.match(/^\[([^\]]+)\]/); + const hostname = bracketMatch + ? bracketMatch[1] + : (trimmed.match(/:/g) ?? []).length > 1 + ? trimmed + : trimmed.split(":")[0]; + const scheme = isLocalAddress(hostname) ? "http" : "https"; + return `${scheme}://${trimmed}`; +} + +/** + * Returns true when `host` represents a reachable backend URL. + * + * Rules (applied in order): + * 1. Must be non-empty after trimming. + * 2. Must contain no whitespace — spaces can never appear in a host/port. + * 3. After normalisation (bare hosts get `https://` prepended), must parse + * as a valid http or https URL with a non-empty hostname. + */ +function isValidHostUrl(host: string): boolean { + const trimmed = host.trim(); + if (!trimmed) return false; + // Spaces anywhere in the input are an immediate rejection. + if (/\s/.test(trimmed)) return false; + const normalized = normalizeHost(trimmed); + if (!normalized) return false; + try { + const url = new URL(normalized); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + url.hostname.length > 0 + ); + } catch { + return false; + } +} + +const DEFAULT_OPENHANDS_CLOUD_HOST = "https://app.all-hands.dev"; +const LOCAL_BACKEND_COMMAND = "agent-canvas --backend-only --port 8001"; +const LOCAL_AGENT_SERVER_DOCS_URL = + "https://github.com/OpenHands/OpenHands/blob/main/docs/DEVELOPMENT.md#alternative-development-workflows"; +const REMOTE_AGENT_SERVER_DOCS_URL = + "https://github.com/OpenHands/OpenHands/blob/main/docs/SELF_HOSTING.md"; +const DEPLOYMENT_OPTIONS_URL = + "https://docs.openhands.dev/overview/introduction"; +export type BackendConnectionMethod = "manual" | "cloud_login"; + +export type BackendAddedSource = CloudConnectionSource; +type AddBackendOption = "cloud" | "agent-server"; +type AgentServerLocation = "local" | "remote"; + +function getConnectionTestFailedTitle( + t: ReturnType["t"], + host: string, +): string { + return t(I18nKey.BACKEND$CONNECTION_TEST_FAILED, { + host, + interpolation: { escapeValue: false }, + }); +} + +function getConnectionErrorDetail(error: unknown): string | null { + return getUserFacingConnectionErrorMessage(error); +} + +function getConnectionTestFailedMessage(title: string, error: unknown): string { + const detail = getConnectionErrorDetail(error); + return detail ? `${title}\n${detail}` : title; +} + +async function testBackendConnection( + backend: Pick, +): Promise { + // Cloud backends authenticate via OAuth; preflight GET is not applicable. + if (backend.kind !== "local") return { agentServerVersion: null }; + const agentServerVersion = await validateLocalBackend(backend, 5000); + return { agentServerVersion }; +} + +/** + * Live status row for the edit form: shows a connection dot, a + * "Local"/"Cloud" label, and the agent server's reported version when + * available. Replaces the legacy local/cloud radio fieldset (kind is + * now inferred from the host). + */ +function BackendStatusBadge({ + backend, + testIdRoot, +}: { + backend: Backend; + testIdRoot: string; +}) { + const { t } = useTranslation("openhands"); + const healthByBackendId = useBackendsHealth([backend]); + const health = healthByBackendId[backend.id]; + const isConnected = health?.isConnected ?? null; + const disabled = health?.disabled === true; + const consecutiveFailures = health?.consecutiveFailures ?? 0; + const lastError = health?.lastError ?? null; + + const { data: version } = useQuery({ + queryKey: ["backend-version", backend.host, backend.apiKey], + queryFn: async () => { + const info = await new ServerClient( + getAgentServerClientOptions({ + host: backend.host, + sessionApiKey: backend.apiKey || null, + timeout: 5000, + }), + ).getServerInfo(); + return getDisplayAgentServerVersion(info); + }, + retry: false, + staleTime: 60_000, + enabled: backend.kind === "local" && !disabled, + }); + + const statusLabel = getBackendStatusLabel(t, backend, health); + + const kindLabel = + backend.kind === "cloud" + ? t(I18nKey.BACKEND$KIND_CLOUD) + : t(I18nKey.BACKEND$KIND_LOCAL); + + return ( +
+
+ + + {statusLabel} + + · + {kindLabel} + {version ? ( + + {t(I18nKey.BACKEND$VERSION_LABEL, { version })} + + ) : null} +
+ + {disabled ? ( +
+ + {t(I18nKey.BACKEND$HEALTH_FAILED_TITLE)} + + + {t(I18nKey.BACKEND$HEALTH_FAILED_DETAIL, { + count: consecutiveFailures, + })} + + {lastError ? ( + + {lastError} + + ) : null} +
+ ) : null} +
+ ); +} + +export interface BackendFormSubmitPayload { + name: string; + host: string; + apiKey: string; + kind: BackendKind; +} + +interface UseBackendFormOptions { + initialName?: string; + initialHost?: string; + initialApiKey?: string; + /** + * Called to test the connection. The hook does NOT call + * `testBackendConnection` directly so callers can inject a + * wrapped version (e.g. with extra logging or different timeout). + * Should throw on failure. + */ + onTestConnection: ( + payload: BackendFormSubmitPayload, + ) => Promise; + /** Called after a successful connection test and persistence. */ + onSuccess: (metadata: BackendConnectionTestMetadata) => void; + /** Require a non-empty API key even when the host looks local. */ + requireApiKey?: boolean; + /** + * When provided, completely replaces the default submit flow + * (onTestConnection + onSuccess). The hook still manages form state + * and canSubmit validation, but the caller owns error handling and + * success side effects. Should throw on failure. + */ + onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise; + /** Fix the persisted backend kind instead of inferring it from the host. */ + fixedKind?: BackendKind; +} + +/** + * Shared hook for the backend-form state used by both `BackendForm` + * (edit/add mode) and `ManualConnectionColumn` (add-mode-only column). + * Encapsulates name / host / apiKey fields, `connectionError`, + * `isSubmitting`, and the shared `handleSubmit` flow. + */ +function useBackendForm({ + initialName = "", + initialHost = "", + initialApiKey = "", + onTestConnection, + onSuccess, + requireApiKey = false, + onSubmitOverride, + fixedKind, +}: UseBackendFormOptions) { + const { t } = useTranslation("openhands"); + + const [name, setName] = React.useState(initialName); + const [host, setHost] = React.useState(initialHost); + const [apiKey, setApiKey] = React.useState(initialApiKey); + const [connectionError, setConnectionError] = React.useState( + null, + ); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [kindOverride, setKindOverride] = React.useState( + null, + ); + + // Kind follows host inference until the user explicitly picks a type, then + // respects that choice. A custom-domain OHE can't be distinguished from a + // custom-domain local agent-server by host alone, so ManualConnectionColumn + // exposes `setKind` (a Type selector) to let the user declare it. + const kind = fixedKind ?? kindOverride ?? inferKindFromHost(host); + const needsApiKey = requireApiKey || kind !== "local"; + const canSubmit = + name.trim().length > 0 && + isValidHostUrl(host) && + (!needsApiKey || apiKey.trim().length > 0); + + const handleSubmit = React.useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit || isSubmitting) return; + + const payload: BackendFormSubmitPayload = { + name: name.trim(), + host: normalizeHost(host), + apiKey: apiKey.trim(), + kind, + }; + + setConnectionError(null); + setIsSubmitting(true); + + try { + // When onSubmitOverride is provided, it completely replaces the + // default flow (onTestConnection + onSuccess). + if (onSubmitOverride) { + await onSubmitOverride(payload); + } else { + const metadata = await onTestConnection(payload); + onSuccess(metadata); + } + } catch (error) { + setConnectionError( + getConnectionTestFailedMessage( + getConnectionTestFailedTitle(t, payload.host), + error, + ), + ); + } finally { + setIsSubmitting(false); + } + }, + [ + canSubmit, + isSubmitting, + name, + host, + apiKey, + kind, + onTestConnection, + onSuccess, + requireApiKey, + onSubmitOverride, + fixedKind, + t, + ], + ); + + return { + name, + setName, + host, + setHost, + apiKey, + setApiKey, + connectionError, + setConnectionError, + isSubmitting, + kind, + setKind: setKindOverride, + canSubmit, + handleSubmit, + }; +} + +export interface BackendFormProps { + mode: BackendFormMode; + /** Required when `mode === "edit"`. */ + backend?: Backend; + /** + * Called after the form is submitted and the backend has been + * persisted. Use this to dismiss a containing modal, advance an + * onboarding step, etc. + */ + onSubmitted: () => void; + /** + * Optional render slot rendered in place of the default + * Save / Cancel button row, so callers (e.g. the onboarding flow) + * can re-skin the action area while still owning submission via the + * standard `
` flow. Receives the form's submit-ready + * state. + */ + renderActions?: (state: { + canSubmit: boolean; + isSubmitting: boolean; + testIdRoot: string; + }) => React.ReactNode; + /** Used to disambiguate test ids across the same screen. */ + testIdRoot?: string; + /** When true, the host field is pre-filled and disabled. */ + hostReadOnly?: boolean; + /** + * When true, a non-empty API key is required for submission regardless + * of the inferred backend kind. The standard add form allows empty + * keys for local backends; the auth-gate screen needs to enforce one. + */ + requireApiKey?: boolean; + /** + * When true, hides the name/host/API-key inputs (and related inline + * errors) while keeping the action row visible — used by onboarding + * after a successful connection probe. + */ + hideConfigurationFields?: boolean; + /** + * Replace the default synchronous add/update-and-close submit with a + * custom async handler. The form builds the payload, validates + * client-side, then hands it to this callback. If the callback throws, + * the form remains open so the caller can surface errors. + */ + onSubmitOverride?: (payload: BackendFormSubmitPayload) => Promise; +} + +/** + * Reusable form body for adding / editing a backend. Renders the + * common name / host / API-key inputs plus the kind selector + * (radio buttons in `add` mode, status badge in `edit` mode). + * + * Rendered as a ``, so consumers should put any extra controls + * either inside `renderActions` or as siblings inside a wrapping + * element — but submission flows through the standard form submit so + * Enter-to-submit still works. + */ +export function BackendForm({ + mode, + backend, + onSubmitted, + renderActions, + testIdRoot: explicitTestIdRoot, + hostReadOnly, + requireApiKey, + hideConfigurationFields = false, + onSubmitOverride, +}: BackendFormProps) { + const { t } = useTranslation("openhands"); + const { addBackend, updateBackend } = useActiveBackendContext(); + + // In edit mode preserve the existing backend's kind so that renaming or + // rotating the API key on a cloud backend (e.g. an OHE/enterprise instance + // on a custom domain) does not silently downgrade it to "local" and switch + // the auth header from `Authorization: Bearer` to `X-Session-API-Key`. + // Only infer from the host when adding a new backend. + const fixedKind: BackendKind | null = + mode === "edit" && backend ? backend.kind : null; + + const { + name, + setName, + host, + setHost, + apiKey, + setApiKey, + connectionError, + setConnectionError, + isSubmitting, + kind: inferredKind, + handleSubmit: runSubmit, + } = useBackendForm({ + initialName: backend?.name ?? "", + initialHost: backend?.host ?? "", + initialApiKey: backend?.apiKey ?? "", + onTestConnection: testBackendConnection, + onSuccess: async () => { + const payload: BackendFormSubmitPayload = { + name: name.trim(), + host: normalizeHost(host), + apiKey: apiKey.trim(), + kind: fixedKind ?? inferredKind, + }; + if (mode === "edit" && backend) { + updateBackend(backend.id, payload); + } else { + addBackend(payload); + } + onSubmitted(); + }, + requireApiKey, + onSubmitOverride, + }); + + // Inline validation: only show errors after the user has left a field. + const [nameTouched, setNameTouched] = React.useState(false); + const [hostTouched, setHostTouched] = React.useState(false); + + const kind = fixedKind ?? inferredKind; + const testIdRoot = + explicitTestIdRoot ?? (mode === "edit" ? "edit-backend" : "add-backend"); + + const needsApiKey = requireApiKey || kind !== "local"; + const canSubmit = + name.trim().length > 0 && + isValidHostUrl(host) && + (!needsApiKey || apiKey.trim().length > 0); + + // Error messages — only surfaced after the user has blurred the field. + const nameError = + nameTouched && !name.trim() ? t(I18nKey.BACKEND$NAME_REQUIRED) : undefined; + const hostError = hostTouched + ? !host.trim() + ? t(I18nKey.BACKEND$HOST_REQUIRED) + : !isValidHostUrl(host) + ? t(I18nKey.BACKEND$HOST_INVALID) + : undefined + : undefined; + + const handleSubmit = async (event: React.FormEvent) => { + if (!canSubmit) { + // Mark all validated fields as touched so inline errors become visible + // (e.g. user pressed Enter before filling required fields). + setNameTouched(true); + setHostTouched(true); + return; + } + await runSubmit(event); + }; + + return ( + +
+ { + setName(value); + setConnectionError(null); + }} + onBlur={() => setNameTouched(true)} + // eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy + placeholder="Production" + className="w-full" + showRequiredTag + error={nameError} + /> + + { + setHost(value); + setConnectionError(null); + } + } + onBlur={() => setHostTouched(true)} + placeholder={DEFAULT_OPENHANDS_CLOUD_HOST} + className="w-full" + showRequiredTag + error={hostError} + isDisabled={hostReadOnly} + /> + + { + setApiKey(value); + setConnectionError(null); + }} + placeholder="" + className="w-full" + /> + + {connectionError ? ( +
+ {connectionError} +
+ ) : null} + + {mode === "edit" && backend && ( + + )} +
+ + {renderActions ? ( + renderActions({ + canSubmit: canSubmit && !isSubmitting, + isSubmitting, + testIdRoot, + }) + ) : ( +
+ + {t(I18nKey.BUTTON$CANCEL)} + + + {t(I18nKey.BACKEND$SAVE)} + +
+ )} + + ); +} + +// ── Add-mode two-column layout ────────────────────────────────────── + +/** + * @spec BM-002 — Adding a backend auto-switches the active selection to it + * (BM-001), so a backend-scoped detail page the user is viewing now belongs + * to the previous backend. Redirect to that section's list so they never see + * stale data, mirroring the switch-backend redirect in BackendSelector. + */ +function useRedirectAfterAddBackend() { + const { currentPath, navigate } = useNavigation(); + return React.useCallback(() => { + if (/^\/automations\/[^/]+/.test(currentPath)) navigate("/automations"); + else if (/^\/conversations\/[^/]+/.test(currentPath)) + navigate("/conversations"); + }, [currentPath, navigate]); +} + +interface BackendConnectionOptionsProps { + onConnected: ( + payload: BackendFormSubmitPayload, + connectionMethod: BackendConnectionMethod, + metadata?: BackendConnectionTestMetadata, + ) => void; + testIdRoot?: string; + initialManualBackend?: Partial< + Pick + >; + requireManualApiKey?: boolean; + manualSubmitLabel?: React.ReactNode; + manualSubmittingLabel?: React.ReactNode; + manualSubmitTestId?: string; + analyticsSource?: CloudConnectionSource; +} + +/** + * Manual agent-server connection plus OpenHands Cloud OAuth login. + * Used by both the Add Backend modal and the onboarding backend step so + * supported backend choices stay consistent across first-run and settings UI. + */ +export function BackendConnectionOptions({ + onConnected, + testIdRoot = "add-backend", + initialManualBackend, + requireManualApiKey = false, + manualSubmitLabel, + manualSubmittingLabel, + manualSubmitTestId, + analyticsSource, +}: BackendConnectionOptionsProps) { + const { t } = useTranslation("openhands"); + const lockedCloudHost = getLockedCloudHost(); + + if (lockedCloudHost) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+
+ +
+ +
+ +
+
+ ); +} + +interface ManualConnectionColumnProps { + onConnected: ( + payload: BackendFormSubmitPayload, + connectionMethod: BackendConnectionMethod, + metadata?: BackendConnectionTestMetadata, + ) => void; + testIdRoot: string; + initialBackend?: Partial< + Pick + >; + requireApiKey: boolean; + submitLabel: React.ReactNode; + submittingLabel: React.ReactNode; + submitTestId?: string; + fixedKind?: BackendKind; + showKindSelector?: boolean; +} + +/** + * Manual connection via Host + API Key. Designed for self-hosted agent servers + * and self-hosted OpenHands Cloud with API key auth. + */ +function ManualConnectionColumn({ + onConnected, + testIdRoot, + initialBackend, + requireApiKey, + submitLabel, + submittingLabel, + submitTestId, + fixedKind, + showKindSelector = true, +}: ManualConnectionColumnProps) { + const { t } = useTranslation("openhands"); + + const { + name, + setName, + host, + setHost, + apiKey, + setApiKey, + connectionError, + setConnectionError, + isSubmitting, + kind, + setKind, + canSubmit, + handleSubmit, + } = useBackendForm({ + initialName: initialBackend?.name ?? "", + initialHost: initialBackend?.host ?? "", + initialApiKey: initialBackend?.apiKey ?? "", + onTestConnection: testBackendConnection, + onSuccess: (metadata) => { + onConnected( + { + name: name.trim(), + host: normalizeHost(host), + apiKey: apiKey.trim(), + kind, + }, + "manual", + metadata, + ); + }, + requireApiKey, + fixedKind, + }); + + return ( +
+ { + setName(value); + setConnectionError(null); + }} + // eslint-disable-next-line i18next/no-literal-string -- example placeholder, not user-facing copy + placeholder="e.g. My Server" + className="w-full" + /> + + { + setHost(value); + setConnectionError(null); + }} + // eslint-disable-next-line i18next/no-literal-string -- example value, not translatable + placeholder="http://localhost:8000" + className="w-full" + /> + + {showKindSelector ? ( +
+ {t(I18nKey.BACKEND$KIND_LABEL)} + + value={kind} + options={[ + { value: "local", label: t(I18nKey.BACKEND$KIND_LOCAL) }, + { value: "cloud", label: t(I18nKey.BACKEND$KIND_CLOUD) }, + ]} + onChange={(value) => setKind(value)} + ariaLabel={t(I18nKey.BACKEND$KIND_LABEL)} + testId={`${testIdRoot}-kind`} + /> +
+ ) : null} + + { + setApiKey(value); + setConnectionError(null); + }} + // eslint-disable-next-line i18next/no-literal-string -- example value, not translatable + placeholder="sk-••••••••••" + className="w-full" + /> + + {connectionError ? ( +
+ {connectionError} +
+ ) : null} + + + {isSubmitting ? submittingLabel : submitLabel} + + + ); +} + +interface CloudLoginColumnProps { + onConnected: ( + payload: BackendFormSubmitPayload, + connectionMethod: BackendConnectionMethod, + metadata?: BackendConnectionTestMetadata, + ) => void; + testIdRoot: string; + lockedHost?: string; + analyticsSource?: CloudConnectionSource; + /** Omit repeated branding when a surrounding chooser already names Cloud. */ + showBranding?: boolean; +} + +/** + * One-click OAuth login with OpenHands Cloud. Includes an "Advanced" + * disclosure for users who self-host OpenHands Cloud and need to override the + * host. + */ +function CloudLoginColumn({ + onConnected, + testIdRoot, + lockedHost, + analyticsSource, + showBranding = true, +}: CloudLoginColumnProps) { + const { t } = useTranslation("openhands"); + + const [advancedOpen, setAdvancedOpen] = React.useState(false); + const [customHost, setCustomHost] = React.useState(""); + const advancedPanelId = `${testIdRoot}-advanced-panel`; + + const effectiveHost = + lockedHost ?? (customHost.trim() || DEFAULT_OPENHANDS_CLOUD_HOST); + + const handleLoginSuccess = (apiKey: string) => { + onConnected( + { + name: "OpenHands Cloud", + host: normalizeHost(effectiveHost), + apiKey, + kind: "cloud", + }, + "cloud_login", + ); + }; + + return ( +
+ {showBranding ? ( +
+ + +

+ {t(I18nKey.BACKEND$CLOUD_TITLE)} +

+
+ ) : null} + + + {t(I18nKey.BACKEND$CLOUD_DESCRIPTION)} +

+ } + // Host overriding only matters before the flow starts, so it rides the + // same idle-only slot as the description instead of sitting under the + // authorization status. + idleFooter={ + lockedHost ? null : ( + // Keep the Advanced disclosure at the original compact width even + // though the surrounding cloud panel spans the full chooser. +
+ + {/* Height animates through `grid-template-rows` (0fr ↔ 1fr) so no + max-height guess is needed; the inner clip is what makes `0fr` + collapse. Content stays mounted so a typed host survives, and + `inert` keeps the collapsed field out of the tab order. */} +
+
+
+ +

+ {t(I18nKey.BACKEND$LOGIN_CLOUD_HINT)} +

+
+
+
+
+ ) + } + /> +
+ ); +} + +interface BackendOptionTabProps { + value: AddBackendOption; + selectedValue: AddBackendOption; + title: string; + description: string; + icon: React.ReactNode; + onSelect: (value: AddBackendOption) => void; + panelId: string; + testId: string; +} + +/** + * Presents a connection-method tab: icon, title, and a one-line subtitle. + * + * The shared tablist owns the outer border, so these buttons meet cleanly at + * the center. Selection uses a bottom bar instead of recoloring the full + * outline, preserving the group as one visual control. + */ +function BackendOptionTab({ + value, + selectedValue, + title, + description, + icon, + onSelect, + panelId, + testId, +}: BackendOptionTabProps) { + const isSelected = value === selectedValue; + const tabId = `${testId}-tab`; + + return ( + + ); +} + +interface AnimatedPanelHeightProps { + children: React.ReactNode; +} + +/** + * Animates a panel between content-driven heights. + * + * A ResizeObserver keeps the wrapper synchronized as tabs or nested + * disclosures change. The transition class remains mounted before the + * observer publishes a new height; adding it in the same render as the height + * would give the browser no previous painted value to interpolate from. + */ +function AnimatedPanelHeight({ children }: AnimatedPanelHeightProps) { + const contentRef = React.useRef(null); + const [height, setHeight] = React.useState(); + + React.useLayoutEffect(() => { + const content = contentRef.current; + if (!content) return undefined; + + const measure = () => { + const nextHeight = content.getBoundingClientRect().height; + // jsdom reports zero-sized layout boxes; leaving height automatic there + // keeps component tests representative without changing browser behavior. + setHeight(nextHeight > 0 ? nextHeight : undefined); + }; + + measure(); + if (typeof ResizeObserver === "undefined") return undefined; + + const observer = new ResizeObserver(measure); + observer.observe(content); + return () => observer.disconnect(); + }, []); + + return ( +
+
{children}
+
+ ); +} + +/** + * Collects every setup instruction for the selected location into one + * collapsible note. + * + * All of the guidance lives here — the intro, the example command (local) or + * the host formats to use with a tunnel (remote), and the docs link — so users + * never have to leave the modal to find out what belongs in the host field. + * It starts collapsed so the connection form remains compact; users can + * reveal the complete instructions without leaving the modal. + * + * Open/close uses a `grid-template-rows` transition (`0fr` ↔ `1fr`) so the + * height animates without a fixed `max-height` guess. Content stays mounted + * so the toggle does not remount the docs link on every expand. + */ +function AgentServerGuidance({ location }: { location: AgentServerLocation }) { + const { t } = useTranslation("openhands"); + const [isExpanded, setIsExpanded] = React.useState(false); + const isRemote = location === "remote"; + const title = isRemote + ? t(I18nKey.BACKEND$REMOTE_SETUP_TITLE) + : t(I18nKey.BACKEND$BEFORE_CONNECT_TITLE); + const description = isRemote + ? t(I18nKey.BACKEND$REMOTE_SETUP_DESCRIPTION) + : t(I18nKey.BACKEND$LOCAL_SETUP_DESCRIPTION); + const docsHref = isRemote + ? REMOTE_AGENT_SERVER_DOCS_URL + : LOCAL_AGENT_SERVER_DOCS_URL; + const docsLabel = isRemote + ? t(I18nKey.BACKEND$REMOTE_SETUP_DOCS) + : t(I18nKey.BACKEND$LOCAL_SETUP_DOCS); + const testIdRoot = isRemote ? "add-backend-remote" : "add-backend-local"; + const toggleId = `${testIdRoot}-guidance-toggle`; + const bodyId = `${testIdRoot}-guidance-body`; + + return ( + + ); +} + +function AddBackendChooser({ + onConnected, + source, +}: { + onConnected: ( + payload: BackendFormSubmitPayload, + connectionMethod: BackendConnectionMethod, + metadata?: BackendConnectionTestMetadata, + ) => void; + source: BackendAddedSource; +}) { + const { t } = useTranslation("openhands"); + const [selectedOption, setSelectedOption] = + React.useState("cloud"); + const [agentServerLocation, setAgentServerLocation] = + React.useState("local"); + const panelId = "add-backend-selected-panel"; + const selectedTabId = `add-backend-option-${selectedOption}-tab`; + const isCloudSelected = selectedOption === "cloud"; + + return ( +
+
+ + } + onSelect={setSelectedOption} + panelId={panelId} + testId="add-backend-option-cloud" + /> + } + onSelect={setSelectedOption} + panelId={panelId} + testId="add-backend-option-agent-server" + /> +
+ +
+ +
+ {isCloudSelected ? ( + /* Padding keeps the CTA and Advanced host field from hugging the + border; when Advanced expands the panel grows with the content + instead of squeezing it into the reserved min-height. */ +
+ +
+ ) : ( +
+ {/* Rules on either side center the toggle and read as a + divider between the chooser and the connection form. */} +
+ + + value={agentServerLocation} + options={[ + { + value: "local", + label: t(I18nKey.BACKEND$KIND_LOCAL), + icon: , + }, + { + value: "remote", + label: t(I18nKey.BACKEND$KIND_REMOTE), + icon: , + }, + ]} + onChange={setAgentServerLocation} + ariaLabel={t(I18nKey.BACKEND$AGENT_SERVER_LOCATION)} + testId="add-backend-location" + /> + +
+ +
+ + +
+
+ )} +
+
+
+
+ ); +} + +function AddBackendConnectionOptions({ + onClose, + source, +}: { + onClose: () => void; + source: BackendAddedSource; +}) { + const { addBackend } = useActiveBackendContext(); + const redirectAfterAdd = useRedirectAfterAddBackend(); + const { trackBackendAdded } = useTracking(); + const lockedCloudHost = getLockedCloudHost(); + + const handleConnected = React.useCallback( + ( + payload: BackendFormSubmitPayload, + connectionMethod: BackendConnectionMethod, + metadata?: BackendConnectionTestMetadata, + ) => { + addBackend(payload); + trackBackendAdded({ + backendKind: payload.kind, + connectionMethod, + hasApiKey: Boolean(payload.apiKey), + source, + agentServerVersion: metadata?.agentServerVersion, + }); + redirectAfterAdd(); + onClose(); + }, + [addBackend, redirectAfterAdd, onClose, trackBackendAdded, source], + ); + + if (lockedCloudHost) { + return ( + + ); + } + + return ; +} + +// ── Modal wrappers ────────────────────────────────────────────────── + +/** + * Modal wrapper. In **add** mode it renders a two-column layout + * (manual connection | OR | Cloud login). In **edit** mode it wraps + * the standard `BackendForm`. + */ +export function BackendFormModal({ + mode, + backend, + onClose, + source = "add_backend_modal", + hideCloseButton = false, +}: BackendFormModalProps) { + const { t } = useTranslation("openhands"); + + if (mode === "add") { + return ( + +
+ {hideCloseButton ? null : ( + + )} + {hideCloseButton ? null : ( +
+

+ {t(I18nKey.BACKEND$CHOOSER_TITLE)} +

+

+ {t(I18nKey.BACKEND$CHOOSER_DESCRIPTION)}{" "} + + {t(I18nKey.CTA$LEARN_MORE)} + + +

+
+ )} + +
+ +
+
+
+ ); + } + + // Edit mode — single-column form (unchanged) + const testIdRoot = "edit-backend"; + return ( + +
+ +

+ {t(I18nKey.BACKEND$EDIT_TITLE)} +

+ +
+
+ ); +} diff --git a/src/components/features/backends/backend-row.tsx b/src/components/features/backends/backend-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53b57d5805165effb915cf1bd0d288b1077d40a3 --- /dev/null +++ b/src/components/features/backends/backend-row.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from "react-i18next"; +import { Pencil, RefreshCw, Trash2 } from "lucide-react"; + +import { type Backend } from "#/api/backend-registry/types"; +import { + isCloudBackendLoggedOutHealthError, + isInvalidBackendApiKeyHealthError, + type BackendHealth, +} from "#/hooks/query/use-backends-health"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { BackendStatusDot } from "./backend-status-dot"; +import { BackendVersion } from "./backend-version"; +import { DeviceFlowAuth } from "./device-flow-auth"; +import { getBackendStatusLabel } from "./backend-status-label"; +import { getLockedCloudHost } from "#/api/agent-server-config"; + +const ROW_ACTION_BUTTON_CLASS = + "inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-white"; + +interface BackendRowProps { + backend: Backend; + health: BackendHealth | undefined; + orgLabel?: string; + onSelect: () => void; + onEdit: () => void; + onRemove: () => void; + onLogin?: (apiKey: string) => void; +} + +export function BackendRow({ + backend, + health, + orgLabel, + onSelect, + onEdit, + onRemove, + onLogin, +}: BackendRowProps) { + const { t } = useTranslation("openhands"); + const isInvalidApiKey = isInvalidBackendApiKeyHealthError(health?.lastError); + const isCloudLoggedOut = + backend.kind === "cloud" && + isCloudBackendLoggedOutHealthError(health?.lastError); + const statusDetail = + !isInvalidApiKey && + !isCloudLoggedOut && + health?.isConnected === false && + health.lastError + ? health.lastError + : null; + const statusLabel = isCloudLoggedOut + ? t(I18nKey.BACKEND$LOGGED_OUT) + : getBackendStatusLabel(t, backend, health); + const statusClassName = + health?.isConnected === true + ? "text-green-300" + : health?.isConnected === false + ? "text-red-300" + : "text-[var(--oh-muted)]"; + const dotStatus = isInvalidApiKey ? false : (health?.isConnected ?? null); + const canSelect = health?.isConnected === true && !isInvalidApiKey; + const lockedCloudHost = getLockedCloudHost(); + + return ( +
  • + +
    + {isCloudLoggedOut && onLogin ? ( + + } + className="w-auto" + buttonVariant="unstyled" + buttonClassName={ROW_ACTION_BUTTON_CLASS} + statusDisplay="modal" + analyticsSource="manage_backends_modal" + /> + ) : null} + {!lockedCloudHost && ( + + )} + {!lockedCloudHost && ( + + )} +
    +
  • + ); +} diff --git a/src/components/features/backends/backend-selector.tsx b/src/components/features/backends/backend-selector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c83be59bade42aa5fa9032a688b1c3ef0c98ce66 --- /dev/null +++ b/src/components/features/backends/backend-selector.tsx @@ -0,0 +1,474 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { useMatch, useNavigate } from "react-router"; +import { Plus, Settings } from "lucide-react"; +import { Dropdown } from "#/ui/dropdown/dropdown"; +import { DropdownOption } from "#/ui/dropdown/types"; +import { getLockedCloudHost } from "#/api/agent-server-config"; +import { isNoBackend } from "#/api/backend-registry/active-store"; +import { useActiveBackendContext } from "#/contexts/active-backend-context"; +import { useAllCloudOrganizations } from "#/hooks/query/use-cloud-organizations"; +import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id"; +import { + useBackendsHealth, + type BackendHealth, +} from "#/hooks/query/use-backends-health"; +import { I18nKey } from "#/i18n/declaration"; +import type { Backend } from "#/api/backend-registry/types"; +// Import the trigger helpers from the lightweight store, not the overlay +// component, so the eagerly-mounted sidebar/backend-selector graph does not +// pull in the overlay's render code (the overlay is lazy-loaded from +// `routes/root-layout.tsx`). +import { + ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS, + triggerEnvironmentSwitch, +} from "#/components/features/backends/environment-switch-store"; +import { NavigationLink } from "#/components/shared/navigation-link"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { useConversationStore } from "#/stores/conversation-store"; +import { AddBackendModal } from "./add-backend-modal"; +import { BackendStatusDot } from "./backend-status-dot"; +import { ManageBackendsModal } from "./manage-backends-modal"; +import { cn } from "#/utils/utils"; +import { formControlTransitionClassName } from "#/utils/form-control-classes"; +import { + dropdownFooterActionClassName, + dropdownMenuListClassName, + dropdownMenuRowIconWrapperClassName, +} from "#/utils/dropdown-classes"; + +const VALUE_SEPARATOR = "::"; + +function makeOptionValue(backendId: string, orgId: string | null): string { + return orgId ? `${backendId}${VALUE_SEPARATOR}${orgId}` : backendId; +} + +function parseOptionValue(value: string): { + backendId: string; + orgId: string | null; +} { + const [backendId, orgId] = value.split(VALUE_SEPARATOR); + return { backendId, orgId: orgId ?? null }; +} + +function buildStatusPrefix(health: BackendHealth | undefined) { + return ; +} + +function buildNoBackendPrefix() { + return ; +} + +function buildOptions( + registered: Backend[], + personalWorkspaceLabel: string, + cloudOrgs: ReturnType, + currentUserIds: ReturnType, + healthByBackendId: Record, +): DropdownOption[] { + const options: DropdownOption[] = []; + + const locals = registered.filter((b) => b.kind === "local"); + const clouds = registered.filter((b) => b.kind === "cloud"); + + for (const b of locals) { + options.push({ + value: makeOptionValue(b.id, null), + label: b.name, + prefix: buildStatusPrefix(healthByBackendId[b.id]), + }); + } + + for (const b of clouds) { + const entry = cloudOrgs[b.id]; + const prefix = buildStatusPrefix(healthByBackendId[b.id]); + if (!entry || entry.orgs.length === 0) { + options.push({ + value: makeOptionValue(b.id, null), + label: b.name, + prefix, + }); + } else { + // Personal-workspace rule (per the cloud contract): the org whose + // id matches the calling user's id is the user's personal + // workspace. We resolve `user_id` once per backend (via /me on any + // one org) and apply it across all orgs of that backend. + const userIdForBackend = currentUserIds[b.id]?.userId ?? null; + + for (const org of entry.orgs) { + const isPersonal = !!userIdForBackend && userIdForBackend === org.id; + const orgLabel = isPersonal ? personalWorkspaceLabel : org.name; + options.push({ + value: makeOptionValue(b.id, org.id), + label: `${b.name} – ${orgLabel}`, + // All org rows for the same cloud backend share that backend's + // single connectivity verdict — there is no per-org probe. + prefix, + }); + } + } + } + + return options; +} + +interface BackendSelectorProps { + /** Render the menu above the trigger (e.g. when pinned to bottom of sidebar). */ + openUpward?: boolean; + /** Hide the selector input trigger and only render the dropdown menu. */ + hideTrigger?: boolean; + /** Whether the dropdown menu should start open on mount. */ + defaultOpen?: boolean; + /** Callback fired after selecting a backend/org option. */ + onSelectOption?: () => void; + /** + * Override the internal Add Backend modal handling. When provided, + * clicking "Add Backend" calls this instead of opening BackendSelector's + * own modal. Useful when the selector is mounted inside an ephemeral + * container (e.g. the collapsed-sidebar popover) and the modal must + * survive the parent unmounting. + */ + onOpenAddBackend?: () => void; + /** Same as onOpenAddBackend but for the Manage Backends modal. */ + onOpenManageBackends?: () => void; + /** + * Whether the surrounding sidebar rail is in its collapsed variant. Passed + * down from `SidebarRailBody` so the mobile drawer (which always renders + * the expanded rail) can override the persisted desktop value. + */ + sidebarCollapsed?: boolean; +} + +export function BackendSelector({ + openUpward = false, + hideTrigger = false, + defaultOpen = false, + onSelectOption, + onOpenAddBackend, + onOpenManageBackends, + sidebarCollapsed = false, +}: BackendSelectorProps = {}) { + const { t } = useTranslation("openhands"); + const { backends, active, setActive } = useActiveBackendContext(); + const cloudOrgs = useAllCloudOrganizations(); + const currentUserIds = useCloudCurrentUserId(); + // Probe each registered backend every 10s. + const healthByBackendId = useBackendsHealth(backends); + const navigate = useNavigate(); + const settingsMatch = useMatch("/settings"); + const settingsSubrouteMatch = useMatch("/settings/*"); + const conversationMatch = useMatch("/conversations/:conversationId"); + const automationDetailMatch = useMatch("/automations/:automationId"); + const [addBackendModalOpen, setAddBackendModalOpen] = React.useState(false); + const [manageBackendsModalOpen, setManageBackendsModalOpen] = + React.useState(false); + + const personalWorkspaceLabel = t(I18nKey.BACKEND$PERSONAL_WORKSPACE); + + const options = React.useMemo( + () => + buildOptions( + backends, + personalWorkspaceLabel, + cloudOrgs, + currentUserIds, + healthByBackendId, + ), + [ + backends, + personalWorkspaceLabel, + cloudOrgs, + currentUserIds, + healthByBackendId, + ], + ); + + const noBackendSelected = isNoBackend(active.backend); + const noBackendLabel = t(I18nKey.BACKEND$NO_BACKEND_AVAILABLE); + const activeValue = makeOptionValue(active.backend.id, active.orgId); + const activeOption = noBackendSelected + ? undefined + : options.find((o) => o.value === activeValue); + const isSettingsActive = Boolean(settingsMatch || settingsSubrouteMatch); + const settingsLabel = t(I18nKey.SIDEBAR$SETTINGS); + // `org` is consumed by the cloud settings loader so the page opens on the + // org that is active here instead of the cloud's last-used org. + const cloudSettingsOrgQuery = active.orgId + ? `?org=${encodeURIComponent(active.orgId)}` + : ""; + const isRightPanelShown = useConversationStore( + (state) => state.isRightPanelShown, + ); + // When the sidebar rail is expanded, `placement="left"` hugs the main + // canvas and reads awkwardly; prefer above the control. When the rail is + // collapsed, keep left except on active conversation + open right drawer. + const settingsTooltipPlacement = + !sidebarCollapsed || (conversationMatch && isRightPanelShown) + ? "top" + : "left"; + + const someCloudLoading = Object.values(cloudOrgs).some((c) => c.isLoading); + + // Self-heal a malformed `(cloudBackendId, null)` selection. + // + // Once a cloud backend's orgs resolve, the dropdown only renders + // per-org rows for it — the `(backendId, null)` row disappears, so + // selecting that shape would drift from what the dropdown can render + // (UI says "Local", APIs hit cloud). When we detect the drift, snap + // the selection onto Cloud's current org first, then fall back to the + // personal workspace (or, lacking a /me result, the first org). The + // selection is recorded locally only; the cloud request scope follows + // from the X-Org-Id header sent by `callCloudProxy`, so the cloud UI's + // org choice is never mutated as a side effect. + React.useEffect(() => { + if (noBackendSelected || active.backend.kind !== "cloud" || active.orgId) + return; + const { backend } = active; + const entry = cloudOrgs[backend.id]; + if (!entry || entry.orgs.length === 0) return; + + const currentOrg = entry.currentOrgId + ? entry.orgs.find((o) => o.id === entry.currentOrgId) + : undefined; + const userId = currentUserIds[backend.id]?.userId ?? null; + const personal = userId + ? entry.orgs.find((o) => o.id === userId) + : undefined; + const target = currentOrg ?? personal ?? entry.orgs[0]; + if (target) { + setActive(backend.id, target.id); + } + }, [active, cloudOrgs, currentUserIds, setActive, noBackendSelected]); + + const openAddBackendModal = React.useCallback(() => { + if (onOpenAddBackend) { + onOpenAddBackend(); + onSelectOption?.(); + return; + } + setAddBackendModalOpen(true); + }, [onOpenAddBackend, onSelectOption]); + + const openManageBackendsModal = React.useCallback(() => { + if (onOpenManageBackends) { + onOpenManageBackends(); + onSelectOption?.(); + return; + } + setManageBackendsModalOpen(true); + }, [onOpenManageBackends, onSelectOption]); + + const isLockedToCloud = getLockedCloudHost() !== null; + // A cookie-auth (OHE-hosted) Canvas has nothing to add, manage, or + // reconnect — its single locked backend is owned by the main-app session. + const hideBackendFooter = + isLockedToCloud && active.backend.authMode === "cookie"; + + const preventDropdownMenuClose = React.useCallback( + (event: React.SyntheticEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, + [], + ); + + const handleAddBackendClick = React.useCallback( + (event: React.MouseEvent) => { + preventDropdownMenuClose(event); + openAddBackendModal(); + }, + [openAddBackendModal, preventDropdownMenuClose], + ); + + const handleAddBackendTouchEnd = React.useCallback( + (event: React.TouchEvent) => { + preventDropdownMenuClose(event); + openAddBackendModal(); + }, + [openAddBackendModal, preventDropdownMenuClose], + ); + + const handleManageBackendsClick = React.useCallback( + (event: React.MouseEvent) => { + preventDropdownMenuClose(event); + openManageBackendsModal(); + }, + [openManageBackendsModal, preventDropdownMenuClose], + ); + + const handleManageBackendsTouchEnd = React.useCallback( + (event: React.TouchEvent) => { + preventDropdownMenuClose(event); + openManageBackendsModal(); + }, + [openManageBackendsModal, preventDropdownMenuClose], + ); + + const addBackendFooter = hideBackendFooter ? undefined : ( +
    + {isLockedToCloud ? null : ( + + )} + +
    + ); + + const handleSelectBackend = React.useCallback( + async (value: string) => { + if (value === activeValue) return; + + const { backendId, orgId } = parseOptionValue(value); + const target = backends.find((b) => b.id === backendId); + if (!target) return; + + triggerEnvironmentSwitch( + options.find((option) => option.value === value)?.label ?? target.name, + ); + await new Promise((resolve) => { + setTimeout(resolve, ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS); + }); + + // @spec BM-002 — Switching backends keeps the user on the same page + if (conversationMatch) navigate("/conversations"); + else if (automationDetailMatch) navigate("/automations"); + + setActive(target.id, orgId); + onSelectOption?.(); + }, + [ + activeValue, + backends, + conversationMatch, + automationDetailMatch, + navigate, + options, + setActive, + t, + onSelectOption, + ], + ); + + return ( + <> +
    +
    + { + if (!item) return; + void handleSelectBackend(item.value); + }} + placeholder={ + noBackendSelected ? noBackendLabel : active.backend.name + } + loading={someCloudLoading} + options={options} + 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" + /> +
    + {!hideTrigger ? ( + + {active.backend.kind === "cloud" ? ( + + + + ) : ( + + + + )} + + ) : null} +
    + {addBackendModalOpen ? ( + setAddBackendModalOpen(false)} /> + ) : null} + {manageBackendsModalOpen ? ( + setManageBackendsModalOpen(false)} + /> + ) : null} + + ); +} diff --git a/src/components/features/backends/backend-status-dot.tsx b/src/components/features/backends/backend-status-dot.tsx new file mode 100644 index 0000000000000000000000000000000000000000..abaa955cc1277e0d6ad45af1aec4584f4c2b6662 --- /dev/null +++ b/src/components/features/backends/backend-status-dot.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { cn } from "#/utils/utils"; + +interface BackendStatusDotProps { + /** `null` while the first probe is in flight. */ + isConnected: boolean | null | "unavailable"; + className?: string; +} + +/** + * Small colored dot that reflects backend reachability: + * - green when connected + * - red when disconnected + * - dim gray while the first probe is in flight + */ +export function BackendStatusDot({ + isConnected, + className, +}: BackendStatusDotProps) { + let color: string; + let label: string; + let status: string; + if (isConnected === "unavailable") { + color = "bg-[var(--oh-text-tertiary)]"; + label = "No Backend Available"; + status = "unavailable"; + } else if (isConnected === true) { + color = "bg-[var(--oh-status-success)]"; + label = "Connected"; + status = "connected"; + } else if (isConnected === false) { + color = "bg-red-500"; + label = "Disconnected"; + status = "disconnected"; + } else { + color = "bg-[var(--oh-interactive-selected)]"; + label = "Checking connection"; + status = "checking"; + } + + return ( + + ); +} diff --git a/src/components/features/backends/backend-status-label.test.ts b/src/components/features/backends/backend-status-label.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5820706e78414f2fdb295e6db31c286f6315bd7 --- /dev/null +++ b/src/components/features/backends/backend-status-label.test.ts @@ -0,0 +1,101 @@ +import type { TFunction } from "i18next"; +import { describe, expect, it } from "vitest"; +import { + INVALID_BACKEND_API_KEY_ERROR, + MISSING_BACKEND_API_KEY_ERROR, +} from "#/hooks/query/use-backends-health"; +import { I18nKey } from "#/i18n/declaration"; +import { CORS_OR_NETWORK_ERROR_MESSAGE } from "#/utils/user-facing-error"; +import { getBackendStatusLabel } from "./backend-status-label"; + +const t = ((key: string) => key) as TFunction<"openhands">; + +describe("getBackendStatusLabel", () => { + it("prefers add API key for Cloud backends with a blank API key", () => { + expect( + getBackendStatusLabel( + t, + { kind: "cloud", apiKey: "" }, + { isConnected: false, lastError: CORS_OR_NETWORK_ERROR_MESSAGE }, + ), + ).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_ADD_API_KEY); + }); + + it("maps Cloud browser-network failures to API key or network guidance", () => { + expect( + getBackendStatusLabel( + t, + { kind: "cloud", apiKey: "oh-cloud-key" }, + { isConnected: false, lastError: CORS_OR_NETWORK_ERROR_MESSAGE }, + ), + ).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_CLOUD_ACCESS); + }); + + it("keeps local browser-network failures generic", () => { + expect( + getBackendStatusLabel( + t, + { kind: "local", apiKey: "" }, + { isConnected: false, lastError: CORS_OR_NETWORK_ERROR_MESSAGE }, + ), + ).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_URL_OR_NETWORK); + }); + + it("returns connected when the health probe is connected", () => { + expect( + getBackendStatusLabel( + t, + { kind: "local", apiKey: "" }, + { isConnected: true, lastError: null }, + ), + ).toBe(I18nKey.ONBOARDING$BACKEND_STATUS_CONNECTED); + }); + + it("returns disconnected for failed probes without a more specific reason", () => { + expect( + getBackendStatusLabel( + t, + { kind: "local", apiKey: "" }, + { isConnected: false, lastError: "Unexpected failure" }, + ), + ).toBe(I18nKey.ONBOARDING$BACKEND_STATUS_DISCONNECTED); + }); + + it("returns checking while the health probe is unresolved", () => { + expect( + getBackendStatusLabel( + t, + { kind: "local", apiKey: "" }, + { isConnected: null, lastError: null }, + ), + ).toBe(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING); + }); + + it("maps request timeouts to tunnel guidance", () => { + expect( + getBackendStatusLabel( + t, + { kind: "local", apiKey: "" }, + { isConnected: false, lastError: "Backend request timed out" }, + ), + ).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_TUNNEL); + }); + + it("maps missing and invalid API key health errors to credential guidance", () => { + expect( + getBackendStatusLabel( + t, + { kind: "cloud", apiKey: "oh-cloud-key" }, + { isConnected: false, lastError: MISSING_BACKEND_API_KEY_ERROR }, + ), + ).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_ADD_API_KEY); + + expect( + getBackendStatusLabel( + t, + { kind: "cloud", apiKey: "oh-cloud-key" }, + { isConnected: false, lastError: INVALID_BACKEND_API_KEY_ERROR }, + ), + ).toBe(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_API_KEY); + }); +}); diff --git a/src/components/features/backends/backend-status-label.ts b/src/components/features/backends/backend-status-label.ts new file mode 100644 index 0000000000000000000000000000000000000000..709f8d389b6f86503d2f2dc69df28df1a6a1d777 --- /dev/null +++ b/src/components/features/backends/backend-status-label.ts @@ -0,0 +1,77 @@ +import type { TFunction } from "i18next"; +import { + isCloudBackendApiKeyOrNetworkHealthError, + isCloudBackendLoggedOutHealthError, + isInvalidBackendApiKeyHealthError, + isMissingBackendApiKeyHealthError, +} from "#/hooks/query/use-backends-health"; +import { I18nKey } from "#/i18n/declaration"; +import { + isBackendRequestTimeoutMessage, + isCorsOrNetworkErrorMessage, +} from "#/utils/user-facing-error"; + +interface BackendStatusLabelHealth { + isConnected?: boolean | null; + lastError?: string | null; +} + +export function getBackendStatusLabel( + t: TFunction<"openhands">, + backend: + | { + kind?: "local" | "cloud"; + apiKey?: string | null; + } + | undefined, + health: BackendStatusLabelHealth | undefined, +): string { + const lastError = health?.lastError ?? null; + const isCloud = backend?.kind === "cloud"; + + if (isCloud && !backend?.apiKey?.trim()) { + return t(I18nKey.BACKEND$STATUS_DISCONNECTED_ADD_API_KEY); + } + + if (isMissingBackendApiKeyHealthError(lastError)) { + return t(I18nKey.BACKEND$STATUS_DISCONNECTED_ADD_API_KEY); + } + + if (isInvalidBackendApiKeyHealthError(lastError)) { + return t(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_API_KEY); + } + + if (isCloudBackendLoggedOutHealthError(lastError)) { + return t(I18nKey.BACKEND$LOGGED_OUT); + } + + if (health?.isConnected === true) { + return t(I18nKey.ONBOARDING$BACKEND_STATUS_CONNECTED); + } + + if ( + isCloud && + health?.isConnected === false && + (isCloudBackendApiKeyOrNetworkHealthError(lastError) || + isCorsOrNetworkErrorMessage(lastError)) + ) { + return t(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_CLOUD_ACCESS); + } + + if ( + health?.isConnected === false && + isBackendRequestTimeoutMessage(lastError) + ) { + return t(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_TUNNEL); + } + + if (health?.isConnected === false && isCorsOrNetworkErrorMessage(lastError)) { + return t(I18nKey.BACKEND$STATUS_DISCONNECTED_CHECK_URL_OR_NETWORK); + } + + if (health?.isConnected === false) { + return t(I18nKey.ONBOARDING$BACKEND_STATUS_DISCONNECTED); + } + + return t(I18nKey.ONBOARDING$BACKEND_STATUS_CHECKING); +} diff --git a/src/components/features/backends/backend-version.tsx b/src/components/features/backends/backend-version.tsx new file mode 100644 index 0000000000000000000000000000000000000000..308ff394357821596f4da0ac819dd701c738a2f5 --- /dev/null +++ b/src/components/features/backends/backend-version.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; + +import { ServerClient } from "@openhands/typescript-client/clients"; +import { type Backend } from "#/api/backend-registry/types"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { getDisplayAgentServerVersion } from "#/api/agent-server-compatibility"; +import { I18nKey } from "#/i18n/declaration"; + +export function BackendVersion({ backend }: { backend: Backend }) { + const { t } = useTranslation("openhands"); + const { data: version } = useQuery({ + queryKey: ["backend-version", backend.host, backend.apiKey], + queryFn: async () => { + const info = await new ServerClient( + getAgentServerClientOptions({ + host: backend.host, + sessionApiKey: backend.apiKey || null, + timeout: 5000, + }), + ).getServerInfo(); + return getDisplayAgentServerVersion(info); + }, + retry: false, + staleTime: 60_000, + enabled: backend.kind === "local", + }); + + if (!version) return null; + + return ( + + {t(I18nKey.BACKEND$VERSION_LABEL, { version })} + + ); +} diff --git a/src/components/features/backends/device-flow-auth.tsx b/src/components/features/backends/device-flow-auth.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eb79588969963294c1305eeb546f74f409017761 --- /dev/null +++ b/src/components/features/backends/device-flow-auth.tsx @@ -0,0 +1,367 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { useDeviceFlow, type DeviceFlowStatus } from "#/hooks/use-device-flow"; +import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; + +type DeviceFlowButtonVariant = + | "primary" + | "secondary" + | "tertiary" + | "unstyled"; +type DeviceFlowStatusDisplay = "inline" | "modal"; + +interface DeviceFlowAuthProps { + /** The host URL for the cloud backend */ + host: string; + /** Callback when authentication succeeds with the API key */ + onSuccess: (apiKey: string) => void; + /** Test ID prefix for the component */ + testIdRoot: string; + /** Whether the login button should be disabled (e.g., when no host is entered) */ + isDisabled?: boolean; + /** Override for the idle button label and icon-only accessible name. */ + idleButtonLabel?: string; + /** Optional visible content for the idle button. Defaults to the idle label. */ + idleButtonContent?: React.ReactNode; + /** Optional content shown only before authentication starts. */ + idleDescription?: React.ReactNode; + /** + * Optional content rendered under the idle button and, like + * `idleDescription`, only before authentication starts — so secondary + * controls step aside once the flow takes over the surface. + */ + idleFooter?: React.ReactNode; + /** Optional classes for the root wrapper. */ + className?: string; + /** Optional classes for the idle button. */ + buttonClassName?: string; + /** Visual variant for the idle button. */ + buttonVariant?: DeviceFlowButtonVariant; + /** Whether in-progress auth content should render inline or in a modal. */ + statusDisplay?: DeviceFlowStatusDisplay; + /** Product surface that initiated this authorization attempt. */ + analyticsSource?: CloudConnectionSource; +} + +/** + * Device Flow authentication UI component. + * + * Shows a "Connect to OpenHands" button that initiates OAuth 2.0 Device Flow + * authentication. Displays status during the auth process and auto-opens + * the browser for user authorization. + */ +/** + * Validate that a URL is safe to open in a popup. + * Prevents XSS via javascript: URLs or other malicious schemes. + */ +function isValidVerificationUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "https:"; + } catch { + return false; + } +} + +export function DeviceFlowAuth({ + host, + onSuccess, + testIdRoot, + isDisabled = false, + idleButtonLabel, + idleButtonContent, + idleDescription, + idleFooter, + className, + buttonClassName, + buttonVariant = "primary", + statusDisplay = "inline", + analyticsSource, +}: DeviceFlowAuthProps) { + const { t } = useTranslation("openhands"); + const deviceFlow = useDeviceFlow(); + const popupRef = React.useRef(null); + + // Close popup on unmount or when auth completes/errors + React.useEffect(() => { + return () => { + popupRef.current?.close(); + }; + }, []); + + // Update popup URL when verification URL becomes available + React.useEffect(() => { + if ( + deviceFlow.status === "awaiting_authorization" && + deviceFlow.verificationUrl && + popupRef.current && + !popupRef.current.closed + ) { + // Validate URL before assigning to prevent XSS + if (!isValidVerificationUrl(deviceFlow.verificationUrl)) { + console.error("Invalid verification URL protocol"); + return; + } + try { + popupRef.current.location.href = deviceFlow.verificationUrl; + } catch { + // Cross-origin error - popup was navigated away + // Open a new one as fallback + popupRef.current = window.open( + deviceFlow.verificationUrl, + "_blank", + "noopener,noreferrer", + ); + } + } + }, [deviceFlow.status, deviceFlow.verificationUrl]); + + // Call onSuccess when authentication completes + React.useEffect(() => { + if (deviceFlow.status === "success" && deviceFlow.apiKey) { + try { + onSuccess(deviceFlow.apiKey); + } finally { + deviceFlow.reset(); + popupRef.current?.close(); + } + } + }, [deviceFlow.status, deviceFlow.apiKey, deviceFlow.reset, onSuccess]); + + const handleStartAuth = () => { + // Normalize and validate the host URL + const normalizedHost = host.trim().replace(/\/+$/, ""); + const fullHost = /^https?:\/\//i.test(normalizedHost) + ? normalizedHost + : `https://${normalizedHost}`; + + // Validate URL before proceeding + try { + const url = new URL(fullHost); + // Check for URL manipulation attacks + if (url.username || url.password) { + throw new Error("Invalid URL format"); + } + } catch { + return; // Invalid URL, don't proceed + } + + // Open popup immediately on user click to avoid popup blocker + // Start with about:blank and update URL once we have verification URL + // Note: We intentionally don't use "noopener" here because we need to + // maintain a reference to update the popup's location when the + // verification URL becomes available + popupRef.current = window.open("about:blank", "_blank"); + + if (!popupRef.current) { + // Popup was blocked - flow will still work, user can click manual link + console.warn("Popup blocked - user will need to use manual link"); + } + + deviceFlow.start(fullHost, analyticsSource); + }; + + const handleCancel = () => { + deviceFlow.cancel(); + popupRef.current?.close(); + }; + + const statusContent = ( + + ); + const showStatusModal = + statusDisplay === "modal" && deviceFlow.status !== "idle"; + const idleLabel = idleButtonLabel ?? t(I18nKey.BACKEND$LOGIN_WITH_OPENHANDS); + + return ( +
    + {deviceFlow.status === "idle" ? idleDescription : null} + + {deviceFlow.status === "idle" && buttonVariant === "unstyled" && ( + + )} + + {deviceFlow.status === "idle" && buttonVariant !== "unstyled" && ( + + {idleButtonContent ?? idleLabel} + + )} + + {deviceFlow.status === "idle" ? idleFooter : null} + + {statusDisplay === "inline" ? statusContent : null} + + {showStatusModal ? ( + + + {statusContent} + + + ) : null} +
    + ); +} + +interface DeviceFlowStatusContentProps { + status: DeviceFlowStatus; + error: string | null; + verificationUrl: string | null; + testIdRoot: string; + onCancel: () => void; + onRetry: () => void; +} + +function DeviceFlowStatusContent({ + status, + error, + verificationUrl, + testIdRoot, + onCancel, + onRetry, +}: DeviceFlowStatusContentProps) { + const { t } = useTranslation("openhands"); + + if (status === "idle" || status === "success") return null; + + if (status === "starting") { + return ( +
    + + + {t(I18nKey.BACKEND$AUTH_STARTING)} + +
    + ); + } + + if (status === "awaiting_authorization") { + const validVerificationUrl = + verificationUrl && isValidVerificationUrl(verificationUrl) + ? verificationUrl + : null; + + return ( +
    +
    + + + {t(I18nKey.BACKEND$AUTH_AWAITING)} + +
    +
    +

    + {validVerificationUrl + ? `${t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)} ${t(I18nKey.BACKEND$AUTH_OPEN_MANUALLY)}` + : t(I18nKey.BACKEND$AUTH_BROWSER_OPENED)} +

    + {validVerificationUrl ? ( + + {validVerificationUrl} + + ) : null} +
    + + {t(I18nKey.BACKEND$AUTH_CANCEL)} + +
    + ); + } + + return ( +
    +

    {error}

    + + {t(I18nKey.BACKEND$AUTH_RETRY)} + +
    + ); +} + +function LoadingSpinner() { + return ( + + ); +} diff --git a/src/components/features/backends/environment-switch-overlay.tsx b/src/components/features/backends/environment-switch-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b4b50f489a1a30750ccc7855f14d962bdb54a305 --- /dev/null +++ b/src/components/features/backends/environment-switch-overlay.tsx @@ -0,0 +1,172 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { + getEnvironmentSwitchSnapshot, + subscribeEnvironmentSwitch, +} from "./environment-switch-store"; + +// Re-export the store API so existing call sites and tests that import from +// this module keep working unchanged. +export { + ENVIRONMENT_SWITCH_DURATION_MS, + ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS, + triggerEnvironmentSwitch, + dismissEnvironmentSwitch, + __resetEnvironmentSwitchOverlayForTests, +} from "./environment-switch-store"; + +function EnvironmentSwitchIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + ); +} + +export function EnvironmentSwitchOverlay() { + const { t } = useTranslation("openhands"); + const { visible, target } = React.useSyncExternalStore( + subscribeEnvironmentSwitch, + getEnvironmentSwitchSnapshot, + getEnvironmentSwitchSnapshot, + ); + + if (!visible || typeof document === "undefined") return null; + + return createPortal( +
    +
    + +

    + {t(I18nKey.BACKEND$SWITCHING_TO, { environment: target })} +

    +
    +
    , + document.body, + ); +} + +// React.lazy() expects a default export. +export default EnvironmentSwitchOverlay; diff --git a/src/components/features/backends/environment-switch-store.ts b/src/components/features/backends/environment-switch-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..d58129bb25ea171a53e7fe0da2fe2fa069e7a250 --- /dev/null +++ b/src/components/features/backends/environment-switch-store.ts @@ -0,0 +1,78 @@ +// Module-level store for the environment-switch overlay. +// +// This file is intentionally separate from `environment-switch-overlay.tsx` +// (which contains the React component and an inline SVG icon) so that the +// always-mounted sidebar can import `triggerEnvironmentSwitch` / +// `dismissEnvironmentSwitch` without dragging the overlay's render code +// into the eager graph. The overlay component is loaded lazily from +// `routes/root-layout.tsx`. +// +// The overlay state must survive the unmount of the component that triggers a +// switch — the user-context menu remounts (`menuResetCount` key flip in +// user-actions.tsx) the moment the dropdown's portaled option list is +// clicked, because that click registers as outside the menu's +// `useClickOutsideElement` ref. If state lived inside BackendSelector, the +// trigger would fire and immediately get torn down before React paints the +// overlay. + +export const ENVIRONMENT_SWITCH_DURATION_MS = 980; +export const ENVIRONMENT_SWITCH_SETACTIVE_DELAY_MS = 400; + +export interface EnvironmentSwitchSnapshot { + visible: boolean; + target: string; +} + +let snapshot: EnvironmentSwitchSnapshot = { visible: false, target: "" }; +const listeners = new Set<() => void>(); +let hideTimeoutId: ReturnType | null = null; + +function setSnapshot(next: EnvironmentSwitchSnapshot) { + snapshot = next; + if (typeof document !== "undefined") { + if (next.visible) { + document.body.setAttribute("data-environment-switching", "true"); + } else { + document.body.removeAttribute("data-environment-switching"); + } + } + listeners.forEach((listener) => listener()); +} + +export function triggerEnvironmentSwitch(target: string) { + setSnapshot({ visible: true, target }); + if (hideTimeoutId) clearTimeout(hideTimeoutId); + hideTimeoutId = setTimeout(() => { + setSnapshot({ visible: false, target: "" }); + hideTimeoutId = null; + }, ENVIRONMENT_SWITCH_DURATION_MS); +} + +export function dismissEnvironmentSwitch() { + if (hideTimeoutId) { + clearTimeout(hideTimeoutId); + hideTimeoutId = null; + } + setSnapshot({ visible: false, target: "" }); +} + +export function subscribeEnvironmentSwitch(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getEnvironmentSwitchSnapshot() { + return snapshot; +} + +/** Test-only: clear the pending hide timer and reset the snapshot. */ + +export function __resetEnvironmentSwitchOverlayForTests() { + if (hideTimeoutId) { + clearTimeout(hideTimeoutId); + hideTimeoutId = null; + } + setSnapshot({ visible: false, target: "" }); +} diff --git a/src/components/features/backends/manage-backends-modal.tsx b/src/components/features/backends/manage-backends-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a70ed006dfd21b5a2ed0bdb94fd0f22c7b464e3 --- /dev/null +++ b/src/components/features/backends/manage-backends-modal.tsx @@ -0,0 +1,263 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Plus } from "lucide-react"; + +import { getLockedCloudHost } from "#/api/agent-server-config"; +import { type Backend } from "#/api/backend-registry/types"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { ConfirmationModal } from "#/components/shared/modals/confirmation-modal"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { + MODAL_MAX_WIDTH_VIEWPORT, + modalWidthClassName, +} from "#/components/shared/modals/modal-body"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { useActiveBackendContext } from "#/contexts/active-backend-context"; +import { useBackendsHealth } from "#/hooks/query/use-backends-health"; +import { useAllCloudOrganizations } from "#/hooks/query/use-cloud-organizations"; +import { useCloudCurrentUserId } from "#/hooks/query/use-cloud-current-user-id"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { modalTitleLgClassName } from "#/utils/modal-classes"; +import { BackendFormModal } from "./backend-form-modal"; +import { BackendRow } from "./backend-row"; +import { DeviceFlowAuth } from "./device-flow-auth"; + +interface ManageBackendsModalProps { + onClose: () => void; + /** + * Recovery mode is used by the root unavailable-backend gate. There is no + * app shell behind the modal, so dismiss controls would be misleading. + */ + recoveryMode?: boolean; +} + +interface PendingRemoval { + id: string; + name: string; +} + +/** + * Resolve the connected organization label for a backend row, mirroring the + * per-org labelling used by the backend selector. Cloud API keys are bound to + * a single org (legacy keys may expose several, joined here); local backends + * and unresolved/errored lookups yield no label. + */ +function resolveBackendOrgLabel( + backend: Backend, + cloudOrgs: ReturnType, + currentUserIds: ReturnType, + personalWorkspaceLabel: string, +): string | undefined { + if (backend.kind !== "cloud") return undefined; + const entry = cloudOrgs[backend.id]; + if (!entry || entry.orgs.length === 0) return undefined; + const userId = currentUserIds[backend.id]?.userId ?? null; + return entry.orgs + .map((org) => + !!userId && userId === org.id ? personalWorkspaceLabel : org.name, + ) + .join(", "); +} + +export function ManageBackendsModal({ + onClose, + recoveryMode = false, +}: ManageBackendsModalProps) { + const { t } = useTranslation("openhands"); + const { backends, active, removeBackend, setActive, updateBackend } = + useActiveBackendContext(); + const healthByBackendId = useBackendsHealth(backends, { + probeDisabledOnce: true, + }); + const cloudOrgs = useAllCloudOrganizations(); + const currentUserIds = useCloudCurrentUserId(); + const personalWorkspaceLabel = t(I18nKey.BACKEND$PERSONAL_WORKSPACE); + const lockedCloudHost = getLockedCloudHost(); + const isLockedToCloud = lockedCloudHost !== null; + // Cookie-auth (OHE-hosted) backends have no API key to refresh — re-auth is + // the main-app login, so the device-flow reconnect controls don't apply. + const isCookieAuthBackend = active.backend.authMode === "cookie"; + const lockedCloudBackend = + isLockedToCloud && !isCookieAuthBackend && active.backend.kind === "cloud" + ? active.backend + : null; + const lockedCloudReconnectHost = + lockedCloudHost ?? lockedCloudBackend?.host ?? ""; + const modalTitle = + isLockedToCloud && !isCookieAuthBackend + ? t(I18nKey.BACKEND$RECONNECT_CLOUD_TITLE) + : t(I18nKey.BACKEND$MANAGE_TITLE); + const [pendingRemoval, setPendingRemoval] = + React.useState(null); + const [editingBackend, setEditingBackend] = React.useState( + null, + ); + const [showAddForm, setShowAddForm] = React.useState(false); + + const handleConfirmRemoval = () => { + if (!pendingRemoval) return; + removeBackend(pendingRemoval.id); + setPendingRemoval(null); + }; + + const handleSelectBackend = React.useCallback( + (backend: Backend) => { + if (active.backend.id !== backend.id || active.orgId !== null) { + setActive(backend.id); + } + onClose(); + }, + [active.backend.id, active.orgId, onClose, setActive], + ); + + const handleCloudLogin = React.useCallback( + (backend: Backend, apiKey: string) => { + updateBackend(backend.id, { apiKey }); + }, + [updateBackend], + ); + + const handleLockedCloudReconnect = React.useCallback( + (apiKey: string) => { + if (!lockedCloudBackend) return; + updateBackend(lockedCloudBackend.id, { apiKey }); + }, + [lockedCloudBackend, updateBackend], + ); + + return ( + <> + +
    + {recoveryMode ? null : ( + + )} +
    +

    {modalTitle}

    +
    + +
    +
    + {backends.length === 0 ? ( +

    + {t(I18nKey.BACKEND$MANAGE_EMPTY)} +

    + ) : ( +
      + {backends.map((backend) => ( + handleSelectBackend(backend)} + onEdit={() => setEditingBackend(backend)} + onRemove={() => + setPendingRemoval({ + id: backend.id, + name: backend.name, + }) + } + onLogin={ + backend.authMode === "cookie" + ? undefined + : (apiKey) => handleCloudLogin(backend, apiKey) + } + /> + ))} +
    + )} +
    +
    + +
    + {isLockedToCloud ? ( + lockedCloudBackend ? ( + + ) : null + ) : ( + setShowAddForm(true)} + testId="manage-backends-add" + startContent={} + > + {t(I18nKey.BACKEND$ADD)} + + )} + {recoveryMode ? null : ( + + {t(I18nKey.HOME$DONE)} + + )} +
    +
    +
    + + {showAddForm ? ( + setShowAddForm(false)} + /> + ) : null} + + {editingBackend ? ( + setEditingBackend(null)} + /> + ) : null} + + {pendingRemoval ? ( + setPendingRemoval(null)} + /> + ) : null} + + ); +} diff --git a/src/components/features/browser/browser-chrome-bar.tsx b/src/components/features/browser/browser-chrome-bar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..63a2d7ee1bd7fbf3a0a571b27f952b1f92207a2c --- /dev/null +++ b/src/components/features/browser/browser-chrome-bar.tsx @@ -0,0 +1,68 @@ +import { ExternalLink } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; + +type BrowserChromeBarProps = { + url: string; + hasPage: boolean; +}; + +export function BrowserChromeBar({ url, hasPage }: BrowserChromeBarProps) { + const { t } = useTranslation("openhands"); + + const disabledButtonClassName = cn( + "shrink-0 inline-flex items-center justify-center w-6 h-6 rounded-md", + "text-[var(--oh-text-tertiary)] opacity-40 cursor-not-allowed", + ); + + const iconClassName = "w-3.5 h-3.5"; + + return ( +
    +
    + + {url || t(I18nKey.BROWSER$URL_PLACEHOLDER)} + +
    + + {hasPage && url ? ( + + + + ) : ( + + )} +
    + ); +} diff --git a/src/components/features/browser/browser-snapshot.tsx b/src/components/features/browser/browser-snapshot.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6678ab15f78027f9d2fad377c80cee8f9abb9f50 --- /dev/null +++ b/src/components/features/browser/browser-snapshot.tsx @@ -0,0 +1,18 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + +interface BrowserSnaphsotProps { + src: string; +} + +export function BrowserSnapshot({ src }: BrowserSnaphsotProps) { + const { t } = useTranslation("openhands"); + + return ( + {t(I18nKey.BROWSER$SCREENSHOT_ALT)} + ); +} diff --git a/src/components/features/browser/browser.tsx b/src/components/features/browser/browser.tsx new file mode 100644 index 0000000000000000000000000000000000000000..47a21fdc57fc37d098df03858c89fd0713bc5db0 --- /dev/null +++ b/src/components/features/browser/browser.tsx @@ -0,0 +1,26 @@ +import { BrowserSnapshot } from "./browser-snapshot"; +import { BrowserChromeBar } from "./browser-chrome-bar"; +import { EmptyBrowserMessage } from "./empty-browser-message"; +import { useBrowserStore } from "#/stores/browser-store"; + +export function BrowserPanel() { + const { url, screenshotSrc } = useBrowserStore(); + const hasPage = Boolean(screenshotSrc); + + const imgSrc = screenshotSrc?.startsWith("data:image/png;base64,") + ? screenshotSrc + : `data:image/png;base64,${screenshotSrc ?? ""}`; + + return ( +
    + +
    + {screenshotSrc ? ( + + ) : ( + + )} +
    +
    + ); +} diff --git a/src/components/features/browser/empty-browser-message.tsx b/src/components/features/browser/empty-browser-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..efd325e8f15179ac91c0327a73443fd9958a7759 --- /dev/null +++ b/src/components/features/browser/empty-browser-message.tsx @@ -0,0 +1,14 @@ +import { useTranslation } from "react-i18next"; +import { IoIosGlobe } from "react-icons/io"; +import { I18nKey } from "#/i18n/declaration"; +import { ConversationTabEmptyState } from "#/components/features/conversation/conversation-tab-empty-state"; + +export function EmptyBrowserMessage() { + const { t } = useTranslation("openhands"); + + return ( + }> + {t(I18nKey.BROWSER$NO_PAGE_LOADED)} + + ); +} diff --git a/src/components/features/canvas-extensions/add-canvas-extension-modal.tsx b/src/components/features/canvas-extensions/add-canvas-extension-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..49dee3f138d0c37c329997e402baaab2115b62d6 --- /dev/null +++ b/src/components/features/canvas-extensions/add-canvas-extension-modal.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { useInstallCanvasExtension } from "#/hooks/mutation/use-manage-canvas-extensions"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { modalTitleLgClassName } from "#/utils/modal-classes"; + +interface AddCanvasExtensionModalProps { + onClose: () => void; +} + +export function AddCanvasExtensionModal({ + onClose, +}: AddCanvasExtensionModalProps) { + const { t } = useTranslation("openhands"); + const install = useInstallCanvasExtension(); + const [source, setSource] = React.useState(""); + const [ref, setRef] = React.useState(""); + const [repoPath, setRepoPath] = React.useState(""); + + const trimmedSource = source.trim(); + const canSubmit = trimmedSource.length > 0 && !install.isPending; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + install.mutate( + { + source: trimmedSource, + ref: ref.trim() || null, + repo_path: repoPath.trim() || null, + }, + { onSuccess: onClose }, + ); + }; + + return ( + +
    + +
    +

    + {t(I18nKey.SETTINGS$APPS_ADD_BUTTON)} +

    +

    + {t(I18nKey.SETTINGS$APPS_ADD_MODAL_INTRO)} +

    +
    +
    + + + +
    +
    + + {t(I18nKey.BUTTON$CLOSE)} + + + {t( + install.isPending + ? I18nKey.SETTINGS$PLUGINS_INSTALLING + : I18nKey.SETTINGS$PLUGINS_INSTALL, + )} + +
    + +
    + ); +} diff --git a/src/components/features/canvas-extensions/canvas-extension-card.tsx b/src/components/features/canvas-extensions/canvas-extension-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..33210990dbdc53baf0ef8c37908e46d957d863e8 --- /dev/null +++ b/src/components/features/canvas-extensions/canvas-extension-card.tsx @@ -0,0 +1,124 @@ +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { ToggleSwitch } from "#/ui/toggle-switch"; +import { I18nKey } from "#/i18n/declaration"; +import type { InstalledCanvasExtensionInfo } from "#/types/canvas-extension"; +import { + extensionModuleCardPillClassName, + extensionModuleCardSurfaceClassName, +} from "#/utils/extension-module-card-classes"; + +interface CanvasExtensionCardProps { + extension: InstalledCanvasExtensionInfo; + isBusy: boolean; + onToggle: () => void; + onUninstall: () => void; +} + +export function CanvasExtensionCard({ + extension, + isBusy, + onToggle, + onUninstall, +}: CanvasExtensionCardProps) { + const { t } = useTranslation("openhands"); + const pages = extension.manifest?.contributes?.pages ?? []; + const displayName = extension.manifest?.display_name || extension.name; + + return ( +
    +
    +
    +

    + {displayName} +

    +

    + {extension.source} +

    +
    + +
    + + {extension.description || extension.manifest?.description ? ( +

    + {extension.description || extension.manifest?.description} +

    + ) : null} + +
    + + {extension.enabled + ? t(I18nKey.SETTINGS$SKILLS_ENABLED) + : t(I18nKey.SETTINGS$SKILLS_DISABLED)} + + + {t(I18nKey.SETTINGS$SKILLS_VERSION, { + version: extension.version, + })} + + {pages.length ? ( + + {t(I18nKey.SETTINGS$APPS_PAGES)}: {pages.length} + + ) : null} +
    + +
    + {extension.resolved_ref ? ( + <> +
    + {t(I18nKey.SETTINGS$PLUGINS_REF_LABEL)} +
    +
    + {extension.resolved_ref} +
    + + ) : null} + {extension.repo_path ? ( + <> +
    + {t(I18nKey.SETTINGS$PLUGINS_REPO_PATH_LABEL)} +
    +
    + {extension.repo_path} +
    + + ) : null} +
    + + {pages.length ? ( +
    +
      + {pages.map((page) => ( +
    • + {page.title} +
    • + ))} +
    +
    + ) : null} + +
    + + {t(I18nKey.SETTINGS$PLUGINS_UNINSTALL)} + +
    +
    + ); +} diff --git a/src/components/features/canvas-extensions/canvas-extensions-runtime.test.tsx b/src/components/features/canvas-extensions/canvas-extensions-runtime.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d8b3a64a42ddde60a1407b19594f802446705d7 --- /dev/null +++ b/src/components/features/canvas-extensions/canvas-extensions-runtime.test.tsx @@ -0,0 +1,170 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CanvasExtensionsService from "#/api/canvas-extensions-service"; +import { + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import { ActiveBackendProvider } from "#/contexts/active-backend-context"; +import type { + CanvasExtensionHost, + InstalledCanvasExtensionInfo, +} from "#/types/canvas-extension"; +import { + CanvasExtensionsRuntimeProvider, + useCanvasExtensionsRuntime, +} from "./canvas-extensions-runtime"; + +const backend: Backend = { + id: "extension-backend", + name: "Extension backend", + host: "http://127.0.0.1:8000", + apiKey: "test-key", + kind: "local", +}; + +const extension: InstalledCanvasExtensionInfo = { + name: "demo-extension", + version: "0.1.0", + enabled: true, + source: "github:example/demo", + resolved_ref: "abc123", + installed_at: "2026-08-01T00:00:00Z", + install_path: "/tmp/demo-extension", + manifest: { + schema_version: 1, + name: "demo-extension", + display_name: "Demo extension", + version: "0.1.0", + entrypoint: "dist/extension.js", + contributes: { + pages: [ + { + id: "dashboard", + title: "Dashboard", + path: "/dashboard", + nav_label: "Demo dashboard", + }, + ], + }, + }, +}; + +function RuntimeProbe() { + const runtime = useCanvasExtensionsRuntime(); + return ( +
    + {runtime.pages.length} + {runtime.pages[0]?.href} + + {runtime.errors.get(extension.name)} + +
    + ); +} + +function renderRuntime( + moduleLoader: (source: string) => Promise<{ + activate: (host: CanvasExtensionHost) => void | (() => void); + }>, +) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + + + + , + ); +} + +describe("CanvasExtensionsRuntimeProvider", () => { + beforeEach(() => { + setRegisteredBackends([backend]); + setActiveSelection({ backendId: backend.id }); + vi.spyOn(CanvasExtensionsService, "listInstalled").mockResolvedValue([ + extension, + ]); + vi.spyOn(CanvasExtensionsService, "fetchBundle").mockResolvedValue( + "fixture source", + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setActiveSelection(null); + setRegisteredBackends([]); + }); + + it("activates enabled extensions and admits declared page registrations", async () => { + const disposeActivation = vi.fn(); + const moduleLoader = vi.fn().mockResolvedValue({ + activate: (host: CanvasExtensionHost) => { + host.registerPage("dashboard", () => undefined); + return disposeActivation; + }, + }); + + const rendered = renderRuntime(moduleLoader); + + await waitFor(() => + expect(screen.getByTestId("page-count")).toHaveTextContent("1"), + ); + expect(screen.getByTestId("page-href")).toHaveTextContent( + "/extensions/demo-extension/dashboard", + ); + expect(CanvasExtensionsService.fetchBundle).toHaveBeenCalledWith( + extension.name, + expect.objectContaining({ id: backend.id }), + ); + + rendered.unmount(); + expect(disposeActivation).toHaveBeenCalledTimes(1); + }); + + it("rejects registrations that were not declared in the manifest", async () => { + const moduleLoader = vi.fn().mockResolvedValue({ + activate: (host: CanvasExtensionHost) => { + host.registerPage("surprise", () => undefined); + }, + }); + + renderRuntime(moduleLoader); + + await waitFor(() => + expect(screen.getByTestId("runtime-error")).toHaveTextContent( + 'registered undeclared page "surprise"', + ), + ); + expect(screen.getByTestId("page-count")).toHaveTextContent("0"); + }); + + it("degrades gracefully when the backend response has no manifest", async () => { + vi.mocked(CanvasExtensionsService.listInstalled).mockResolvedValue([ + { ...extension, manifest: null }, + ]); + const moduleLoader = vi.fn().mockResolvedValue({ + activate: (host: CanvasExtensionHost) => { + host.registerPage("dashboard", () => undefined); + }, + }); + + renderRuntime(moduleLoader); + + await waitFor(() => + expect(screen.getByTestId("runtime-error")).toHaveTextContent( + 'registered undeclared page "dashboard"', + ), + ); + expect(screen.getByTestId("page-count")).toHaveTextContent("0"); + }); +}); diff --git a/src/components/features/canvas-extensions/canvas-extensions-runtime.tsx b/src/components/features/canvas-extensions/canvas-extensions-runtime.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21565d43b78691bfa743aa170f9a2ba3b2d30b58 --- /dev/null +++ b/src/components/features/canvas-extensions/canvas-extensions-runtime.tsx @@ -0,0 +1,265 @@ +import React from "react"; +import { useNavigate } from "react-router"; +import CanvasExtensionsService from "#/api/canvas-extensions-service"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { loadCanvasExtensionModule } from "#/extensions/canvas-extension-module-loader"; +import { useCanvasExtensions } from "#/hooks/query/use-canvas-extensions"; +import { + CANVAS_EXTENSION_HOST_API_VERSION, + type CanvasExtensionDispose, + type CanvasExtensionHost, + type CanvasExtensionModule, + type CanvasExtensionPageContribution, + type CanvasExtensionPageMount, + type InstalledCanvasExtensionInfo, +} from "#/types/canvas-extension"; + +export interface RegisteredCanvasExtensionPage { + extension: InstalledCanvasExtensionInfo; + contribution: CanvasExtensionPageContribution; + mount: CanvasExtensionPageMount; + href: string; +} + +interface CanvasExtensionsRuntimeValue { + pages: RegisteredCanvasExtensionPage[]; + activating: boolean; + errors: ReadonlyMap; +} + +const EMPTY_RUNTIME: CanvasExtensionsRuntimeValue = { + pages: [], + activating: false, + errors: new Map(), +}; + +const CanvasExtensionsRuntimeContext = + React.createContext(EMPTY_RUNTIME); + +export function useCanvasExtensionsRuntime(): CanvasExtensionsRuntimeValue { + return React.useContext(CanvasExtensionsRuntimeContext); +} + +function isValidSegment(value: string): boolean { + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value); +} + +export function buildCanvasExtensionPageHref( + extensionName: string, + contributionPath: string, +): string { + return `/extensions/${encodeURIComponent(extensionName)}/${contributionPath + .split("/") + .map(encodeURIComponent) + .join("/")}`; +} + +function getDeclaredPage( + extension: InstalledCanvasExtensionInfo, + contributionId: string, +): CanvasExtensionPageContribution { + const contribution = extension.manifest?.contributes?.pages?.find( + (page) => page.id === contributionId, + ); + if (!contribution) { + throw new Error( + `Extension ${extension.name} registered undeclared page "${contributionId}".`, + ); + } + // The backend declares page paths as absolute routes (e.g. "/dashboard"); + // normalize to the relative form used for hrefs and route matching. + const normalizedPath = contribution.path.replace(/^\/+/, ""); + if ( + !isValidSegment(extension.name) || + !isValidSegment(contribution.id) || + !normalizedPath.split("/").every(isValidSegment) + ) { + throw new Error( + `Extension ${extension.name} has an invalid page name, id, or path.`, + ); + } + return { ...contribution, path: normalizedPath }; +} + +type CanvasExtensionModuleLoader = ( + source: string, +) => Promise; + +interface CanvasExtensionsRuntimeProviderProps { + children: React.ReactNode; + /** Test seam for environments that cannot import browser Blob URLs. */ + moduleLoader?: CanvasExtensionModuleLoader; +} + +export function CanvasExtensionsRuntimeProvider({ + children, + moduleLoader = loadCanvasExtensionModule, +}: CanvasExtensionsRuntimeProviderProps) { + const active = useActiveBackend(); + const navigate = useNavigate(); + const query = useCanvasExtensions(); + const [pages, setPages] = React.useState([]); + const [errors, setErrors] = React.useState>( + new Map(), + ); + const [activating, setActivating] = React.useState(false); + + const enabledExtensions = React.useMemo( + () => (query.data ?? []).filter((extension) => extension.enabled), + [query.data], + ); + + // `useActiveBackend` can synthesize a fresh `backend` object on every render + // (e.g. when mounted without an ), and refetches + // produce new extension arrays with identical content. The activation effect + // therefore keys on this value signature — backend identity plus the enabled + // inventory — and reads the current objects from refs, so referential churn + // never tears down and re-activates extensions. + const activationSignature = React.useMemo( + () => + JSON.stringify({ + backendId: active.backend.id, + backendKind: active.backend.kind, + connectionRevision: active.backend.connectionRevision ?? 0, + orgId: active.orgId, + extensions: enabledExtensions.map((extension) => ({ + name: extension.name, + version: extension.version, + resolvedRef: extension.resolved_ref ?? null, + pages: extension.manifest?.contributes?.pages ?? [], + })), + }), + [ + active.backend.id, + active.backend.kind, + active.backend.connectionRevision, + active.orgId, + enabledExtensions, + ], + ); + const activeRef = React.useRef(active); + activeRef.current = active; + const enabledExtensionsRef = React.useRef(enabledExtensions); + enabledExtensionsRef.current = enabledExtensions; + + React.useEffect(() => { + let cancelled = false; + const disposers: CanvasExtensionDispose[] = []; + const { backend, orgId } = activeRef.current; + const extensionsToActivate = enabledExtensionsRef.current; + setPages([]); + setErrors(new Map()); + setActivating(extensionsToActivate.length > 0); + + const activateExtension = async ( + extension: InstalledCanvasExtensionInfo, + ) => { + const registeredPages = new Map(); + const registrationDisposers: CanvasExtensionDispose[] = []; + try { + const source = await CanvasExtensionsService.fetchBundle( + extension.name, + backend, + ); + if (cancelled) return; + const extensionModule = await moduleLoader(source); + if (cancelled) return; + + const host: CanvasExtensionHost = { + apiVersion: CANVAS_EXTENSION_HOST_API_VERSION, + extension: Object.freeze({ + name: extension.name, + version: extension.version, + resolvedRef: extension.resolved_ref ?? null, + }), + backend: Object.freeze({ + id: backend.id, + kind: backend.kind, + orgId, + }), + registerPage: (contributionId, mount) => { + if (registeredPages.has(contributionId)) { + throw new Error( + `Extension ${extension.name} registered page "${contributionId}" more than once.`, + ); + } + const contribution = getDeclaredPage(extension, contributionId); + const page: RegisteredCanvasExtensionPage = { + extension, + contribution, + mount, + href: buildCanvasExtensionPageHref( + extension.name, + contribution.path, + ), + }; + registeredPages.set(contributionId, page); + const unregister = () => registeredPages.delete(contributionId); + registrationDisposers.push(unregister); + return unregister; + }, + navigate: (path) => navigate(path), + agentServer: { + request: (request) => + CanvasExtensionsService.requestAgentServer(request, backend), + }, + }; + + const disposeActivation = await extensionModule.activate(host); + if (cancelled) { + if (typeof disposeActivation === "function") disposeActivation(); + return; + } + if (typeof disposeActivation === "function") { + disposers.push(disposeActivation); + } + disposers.push(...registrationDisposers); + setPages((current) => [ + ...current.filter((page) => page.extension.name !== extension.name), + ...registeredPages.values(), + ]); + } catch (error) { + registrationDisposers.forEach((dispose) => dispose()); + if (cancelled) return; + const message = + error instanceof Error + ? error.message + : "Extension activation failed."; + setErrors((current) => { + const next = new Map(current); + next.set(extension.name, message); + return next; + }); + } + }; + + void Promise.all(extensionsToActivate.map(activateExtension)).finally( + () => { + if (!cancelled) setActivating(false); + }, + ); + + return () => { + cancelled = true; + setPages([]); + for (const dispose of disposers.reverse()) { + try { + dispose(); + } catch (error) { + console.error("Canvas Extension cleanup failed", error); + } + } + }; + }, [activationSignature, moduleLoader, navigate]); + + const value = React.useMemo( + () => ({ pages, activating, errors }), + [pages, activating, errors], + ); + + return ( + + {children} + + ); +} diff --git a/src/components/features/chat/btw-messages.tsx b/src/components/features/chat/btw-messages.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a5f6272eb2654ece6d596af3662c6e04bdebfde --- /dev/null +++ b/src/components/features/chat/btw-messages.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useBtwStore } from "#/stores/btw-store"; +import { GenericEventMessage } from "./generic-event-message"; +import { GotItButton } from "./got-it-button"; + +export interface BtwMessagesProps { + conversationId: string | null | undefined; +} + +export function BtwMessages({ conversationId }: BtwMessagesProps) { + const { t } = useTranslation("openhands"); + const entriesById = useBtwStore((s) => s.entriesByConversation); + const dismiss = useBtwStore((s) => s.dismiss); + const entries = conversationId ? (entriesById[conversationId] ?? []) : []; + + if (!conversationId || entries.length === 0) return null; + + return ( +
    + {entries.map((entry) => { + const isPending = entry.status === "pending"; + return ( + + + {t(I18nKey.CHAT_INTERFACE$BTW_PREFIX)} + + {entry.question} + {isPending && ( + + )} + + } + details={ + isPending + ? t(I18nKey.CHAT_INTERFACE$BTW_WAITING_FOR_ANSWER) + : (entry.response ?? "") + } + initiallyExpanded={!isPending} + chevronPosition="before" + titleTrailing={ + !isPending && ( + dismiss(conversationId, entry.id)} + /> + ) + } + /> + ); + })} +
    + ); +} diff --git a/src/components/features/chat/change-agent-button.tsx b/src/components/features/chat/change-agent-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fa9ee97e091d00e26433ac806517aeacffaa0634 --- /dev/null +++ b/src/components/features/chat/change-agent-button.tsx @@ -0,0 +1,217 @@ +import React, { useMemo, useEffect, useState, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { Typography } from "#/ui/typography"; +import { I18nKey } from "#/i18n/declaration"; +import { ComboboxCaretInline } from "#/ui/combobox-caret"; +import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; +import { CodePillIcon } from "#/icons/code-pill"; +import { useConversationStore } from "#/stores/conversation-store"; +import { ChangeAgentContextMenu } from "./change-agent-context-menu"; +import { cn } from "#/utils/utils"; +import { + formControlMutedHoverClassName, + formControlTransitionClassName, +} from "#/utils/form-control-classes"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { AgentState } from "#/types/agent-state"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; +import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; +import { useHandlePlanClick } from "#/hooks/use-handle-plan-click"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; + +export function ChangeAgentButton() { + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + const { conversationMode, setConversationMode, subConversationTaskId } = + useConversationStore(); + + const { conversationId } = useOptionalConversationId(); + + const isHomePage = !conversationId; + + const webSocketStatus = useUnifiedWebSocketStatus(); + + const isWebSocketConnected = webSocketStatus === "OPEN"; + + const { curAgentState } = useAgentState(); + + const { t } = useTranslation("openhands"); + + const isAgentRunning = curAgentState === AgentState.RUNNING; + + const { data: conversation } = useActiveConversation(); + + const queryClient = useQueryClient(); + + // Track the last invalidated task ID to prevent duplicate invalidations + const lastInvalidatedTaskIdRef = useRef(null); + + // Poll sub-conversation task status + const { taskStatus, subConversationId } = useSubConversationTaskPolling( + subConversationTaskId, + conversation?.id || null, + ); + + // Invalidate parent conversation cache when task is ready (only once per task) + useEffect(() => { + if ( + taskStatus === "READY" && + subConversationId && + conversation?.id && + subConversationTaskId && + lastInvalidatedTaskIdRef.current !== subConversationTaskId + ) { + // Mark this task as invalidated to prevent duplicate calls + lastInvalidatedTaskIdRef.current = subConversationTaskId; + // Invalidate the parent conversation to refetch with updated sub_conversation_ids + queryClient.invalidateQueries({ + queryKey: ["user", "conversation", conversation.id], + }); + } + }, [ + taskStatus, + subConversationId, + conversation?.id, + subConversationTaskId, + queryClient, + ]); + + // Get handlePlanClick and isCreatingConversation from custom hook + const { handlePlanClick, isCreatingConversation } = useHandlePlanClick(); + + // Close context menu when agent starts running + useEffect(() => { + if ((isAgentRunning || !isWebSocketConnected) && contextMenuOpen) { + setContextMenuOpen(false); + } + }, [isAgentRunning, contextMenuOpen, isWebSocketConnected]); + + const isButtonDisabled = + isHomePage || + isAgentRunning || + isCreatingConversation || + !isWebSocketConnected; + + // Handle Shift + Tab keyboard shortcut to cycle through modes + useEffect(() => { + if (isButtonDisabled) { + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent) => { + // Check for Shift + Tab combination + if (event.shiftKey && event.key === "Tab") { + // Prevent default tab navigation behavior + event.preventDefault(); + event.stopPropagation(); + + // Cycle between modes: code -> plan -> code + const nextMode = conversationMode === "code" ? "plan" : "code"; + if (nextMode === "plan") { + handlePlanClick(event); + } else { + setConversationMode(nextMode); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [ + isButtonDisabled, + conversationMode, + setConversationMode, + handlePlanClick, + ]); + + const handleButtonClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setContextMenuOpen(!contextMenuOpen); + }; + + const handleCodeClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConversationMode("code"); + }; + + const isExecutionAgent = conversationMode === "code"; + + const buttonLabel = useMemo(() => { + if (isExecutionAgent) { + return t(I18nKey.COMMON$CODE); + } + return t(I18nKey.COMMON$PLAN); + }, [isExecutionAgent, t]); + + const buttonIcon = useMemo(() => { + if (isExecutionAgent) { + return ; + } + return ; + }, [isExecutionAgent]); + + const button = ( +
    + + {contextMenuOpen && ( + setContextMenuOpen(false)} + onCodeClick={handleCodeClick} + onPlanClick={handlePlanClick} + /> + )} +
    + ); + + if (isHomePage) { + return ( + + {button} + + ); + } + + return button; +} diff --git a/src/components/features/chat/change-agent-context-menu.tsx b/src/components/features/chat/change-agent-context-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dfc0da464877f86322c8708756602b311a35c8cb --- /dev/null +++ b/src/components/features/chat/change-agent-context-menu.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import CodeTagIcon from "#/icons/code-tag.svg?react"; +import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; +import { ContextMenu } from "#/ui/context-menu"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { ContextMenuIconTextWithDescription } from "../context-menu/context-menu-icon-text-with-description"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { cn } from "#/utils/utils"; +import type { ConversationMode } from "#/stores/conversation-store"; + +interface ChangeAgentContextMenuProps { + activeMode: ConversationMode; + onClose: () => void; + onCodeClick?: (event: React.MouseEvent) => void; + onPlanClick?: (event: React.MouseEvent) => void; +} + +export function ChangeAgentContextMenu({ + activeMode, + onClose, + onCodeClick, + onPlanClick, +}: ChangeAgentContextMenuProps) { + const { t } = useTranslation("openhands"); + const menuRef = useClickOutsideElement(onClose); + + const handleCodeClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onCodeClick?.(event); + onClose(); + }; + + const handlePlanClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onPlanClick?.(event); + onClose(); + }; + + return ( + + + + + + + + + ); +} diff --git a/src/components/features/chat/chat-action-tooltip.tsx b/src/components/features/chat/chat-action-tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2216fd2f0b95553b3b4c29dbe3eeb121287f502c --- /dev/null +++ b/src/components/features/chat/chat-action-tooltip.tsx @@ -0,0 +1,23 @@ +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; + +interface ChatActionTooltipProps { + children: React.ReactNode; + tooltip: string | React.ReactNode; + ariaLabel?: string; +} + +export function ChatActionTooltip({ + children, + tooltip, + ariaLabel, +}: ChatActionTooltipProps) { + return ( + + {children} + + ); +} diff --git a/src/components/features/chat/chat-add-file-button.tsx b/src/components/features/chat/chat-add-file-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5c4c9bc32f6bb3ca92b55fceb87cf8e9b66f3f2 --- /dev/null +++ b/src/components/features/chat/chat-add-file-button.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { Paperclip, Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { chatInputIconButtonClassName } from "#/utils/form-control-classes"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu"; +import { ToolsContextMenu } from "#/components/features/controls/tools-context-menu"; +import { SystemMessageModal } from "#/components/features/conversation-panel/system-message-modal"; +import { SkillsModal } from "#/components/features/conversation-panel/skills-modal"; +import { PluginsModal } from "#/components/features/conversation-panel/plugins-modal"; +import { HooksModal } from "#/components/features/conversation-panel/hooks-modal"; + +export interface ChatAddFileButtonProps { + handleFileIconClick: () => void; + disabled?: boolean; + /** + * Offer the "Switch agent profile" submenu. Computed by ChatInputActions: + * only while starting a new conversation (home or a blank conversation) and + * only when the backend has agent profiles (OSS-5735 — once a conversation + * starts, the agent profile is locked). + */ + showAgentProfileSwitch?: boolean; +} + +export function ChatAddFileButton({ + handleFileIconClick, + disabled = false, + showAgentProfileSwitch = false, +}: ChatAddFileButtonProps) { + const { t } = useTranslation("openhands"); + const { conversationId } = useOptionalConversationId(); + const { data: conversation } = useActiveConversation(); + const [menuOpen, setMenuOpen] = React.useState(false); + + const { + handleShowAgentTools, + handleShowSkills, + handleShowPlugins, + handleShowHooks, + systemModalVisible, + setSystemModalVisible, + skillsModalVisible, + setSkillsModalVisible, + pluginsModalVisible, + setPluginsModalVisible, + hooksModalVisible, + setHooksModalVisible, + systemMessage, + shouldShowAgentTools, + shouldShowHooks, + shouldShowPlugins, + } = useConversationNameContextMenu({ + conversationId: conversationId ?? undefined, + executionStatus: conversation?.execution_status, + showOptions: true, + onContextMenuToggle: setMenuOpen, + }); + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (disabled) return; + setMenuOpen((open) => !open); + }; + + return ( +
    + + + {menuOpen && ( + setMenuOpen(false)} + showAgentProfileSwitch={showAgentProfileSwitch} + onShowSkills={handleShowSkills} + onShowPlugins={handleShowPlugins} + onShowHooks={handleShowHooks} + onShowAgentTools={handleShowAgentTools} + shouldShowAgentTools={shouldShowAgentTools} + shouldShowHooks={shouldShowHooks} + shouldShowPlugins={shouldShowPlugins} + footerAction={{ + testId: "add-files-and-images-button", + icon: ( + + ), + label: t(I18nKey.CHAT_INTERFACE$ADD_FILES_AND_IMAGES), + onClick: handleFileIconClick, + }} + /> + )} + + setSystemModalVisible(false)} + systemMessage={systemMessage || null} + /> + {skillsModalVisible && ( + setSkillsModalVisible(false)} /> + )} + {pluginsModalVisible && ( + setPluginsModalVisible(false)} /> + )} + {hooksModalVisible && ( + setHooksModalVisible(false)} /> + )} +
    + ); +} diff --git a/src/components/features/chat/chat-interface.tsx b/src/components/features/chat/chat-interface.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff6b46ccc496ff623973fdeba949bbebc8242ca1 --- /dev/null +++ b/src/components/features/chat/chat-interface.tsx @@ -0,0 +1,677 @@ +import React from "react"; +import { useNavigate } from "react-router"; +import { useTracking } from "#/hooks/use-tracking"; +import { useTranslation } from "react-i18next"; +import { isAcpAuthErrorCode } from "#/utils/acp-error-codes"; +import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; +import { createChatMessage } from "#/services/chat-service"; +import { BtwMessages } from "./btw-messages"; +import { GoalStatusBanner } from "./goal-status-banner"; +import { ModelMessages } from "./model-messages"; +import { useModelStore } from "#/stores/model-store"; +import { useGoalStore } from "#/stores/goal-store"; +import { InteractiveChatBox } from "./interactive-chat-box"; +import { AgentState } from "#/types/agent-state"; +import { useFilteredEvents } from "#/hooks/use-filtered-events"; +import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; +import { useLoadOlderEvents } from "#/hooks/use-load-older-events"; +import { useAutoRefreshFilesOnEdit } from "#/hooks/use-auto-refresh-files-on-edit"; +import { WorkspaceFilesForChatProvider } from "./chat-markdown-path-code"; +import { TypingIndicator } from "./typing-indicator"; +import { ChatSuggestions } from "./chat-suggestions"; +import { ScrollProvider } from "#/context/scroll-context"; +import { useInitialQueryStore } from "#/stores/initial-query-store"; +import { useSendMessage } from "#/hooks/use-send-message"; +import { useAgentState, usePlanningAgentState } from "#/hooks/use-agent-state"; +import { useIsArchivedConversation } from "#/hooks/use-is-archived-conversation"; +import { useHandleBuildPlanClick } from "#/hooks/use-handle-build-plan-click"; + +import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ChatMessagesSkeleton } from "./chat-messages-skeleton"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { useErrorMessageStore } from "#/stores/error-message-store"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { SERVER_CONNECTION_ERROR_MESSAGE } from "#/constants/server-connection-error"; +import { ErrorMessageBanner } from "./error-message-banner"; +import { SkillInstallRestartBanner } from "./skill-install-restart-banner"; +import { LlmNotConfiguredBanner } from "#/components/features/home/llm-not-configured-banner"; +import { useLlmConfigured } from "#/hooks/use-llm-configured"; +import { Messages } from "#/components/conversation-events/chat/messages"; +import { PendingUserMessages } from "./pending-user-messages"; +import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; +import { validateFiles } from "#/utils/file-validation"; +import { useConversationStore } from "#/stores/conversation-store"; +import ConfirmationModeEnabled from "./confirmation-mode-enabled"; +import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import { matchesPendingConversationId } from "#/utils/pending-task-message-link"; +import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; +import ChatStatusIndicator from "./chat-status-indicator"; +import { getStatusColor, getStatusText } from "#/utils/utils"; +import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { I18nKey } from "#/i18n/declaration"; +import { hasConversationStarted } from "./components/resolve-picker-kind"; + +function getEntryPoint( + hasRepository: boolean | null, + hasReplayJson: boolean | null, +): string { + if (hasRepository) return "github"; + if (hasReplayJson) return "replay"; + return "direct"; +} + +export function ChatInterface() { + useAutoRefreshFilesOnEdit(); + + const { trackInitialQuerySubmitted, trackUserMessageSent } = useTracking(); + const { setMessageToSend, conversationMode, planContent } = + useConversationStore(); + const { + errorMessage, + errorCode, + errorClassification, + removeErrorMessage, + setErrorMessage, + } = useErrorMessageStore(); + const navigate = useNavigate(); + const { isTask, taskStatus, taskDetail } = useTaskPolling(); + // Hide empty-state chrome for the entire `/conversations/task-{uuid}` route, + // including the brief READY window before redirect completes. + const isProvisioningTask = isTask; + const conversationWebSocket = useConversationWebSocket(); + const { send } = useSendMessage(); + const { + renderableEvents, + allConversationEvents, + totalEvents, + hasSubstantiveAgentActions, + userEventsExist, + } = useFilteredEvents(); + const enqueuePendingMessage = useOptimisticUserMessageStore( + (state) => state.enqueuePendingMessage, + ); + const markPendingMessageError = useOptimisticUserMessageStore( + (state) => state.markPendingMessageError, + ); + const pendingMessages = useOptimisticUserMessageStore( + (state) => state.pendingMessages, + ); + const { t } = useTranslation("openhands"); + const scrollRef = React.useRef(null); + const { + scrollDomToBottom, + onChatBodyScroll, + hitBottom, + autoScroll, + setAutoScroll, + setHitBottom, + } = useScrollToBottom(scrollRef); + const { + mutate: newConversationCommand, + isPending: isNewConversationPending, + } = useNewConversationCommand(); + + const { curAgentState } = useAgentState(); + const { isPlanningAgentRunning } = usePlanningAgentState(); + const { handleBuildPlanClick } = useHandleBuildPlanClick(); + + // Cloud conversations whose sandbox is MISSING or ERROR are read-only: + // the sandbox is gone and cannot be resumed, so we hide the chat input + // and show an explanatory banner. For local backends sandbox_status is + // always null, so this is effectively a no-op for non-cloud use. + const { data: activeConversation } = useActiveConversation(); + const sandboxStatus = activeConversation?.sandbox_status ?? null; + const isArchivedConversation = useIsArchivedConversation(); + + // Block sending in a resumed conversation that has no usable LLM, and show + // the same setup banner as the home screen so the dead end is explained. + const { isConfigured: isLlmConfigured, isLoading: isLlmConfigLoading } = + useLlmConfigured(); + const llmBlocked = !isLlmConfigLoading && !isLlmConfigured; + + // Disable Build button while agent is running (streaming) + const isAgentRunning = + curAgentState === AgentState.RUNNING || + curAgentState === AgentState.LOADING; + + // Global keyboard shortcut for Build button (Cmd+Enter / Ctrl+Enter) + // This is placed here instead of PlanPreview to avoid duplicate listeners + // when multiple PlanPreview components exist in the chat. + // Gated on the same conditions as the Build button (ConversationTabs' + // `isBuildDisabled`) so it cannot fire outside the plan flow. + React.useEffect(() => { + if (isAgentRunning || conversationMode !== "plan" || !planContent) { + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent) => { + // Check for Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + handleBuildPlanClick(event); + scrollDomToBottom(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [ + isAgentRunning, + conversationMode, + planContent, + handleBuildPlanClick, + scrollDomToBottom, + ]); + + const { selectedRepository, replayJson } = useInitialQueryStore(); + const { conversationId } = useOptionalConversationId(); + + // The live goal banner renders in the scroll stream but advances via store + // updates (in-progress goal events are filtered out of `renderableEvents`), + // so the bottom-following effect would not react to it. This key changes as + // an active loop appears and advances each round; feeding it into that effect + // keeps the banner in view when the user is pinned to the bottom. + const activeGoalScrollKey = useGoalStore((s) => { + const goal = conversationId + ? s.statusByConversation[conversationId] + : undefined; + return goal?.active ? `${goal.iteration}:${goal.status}` : null; + }); + const { mutateAsync: uploadFiles } = useUnifiedUploadFiles(); + + // Lazy "scroll up to load older events" backfill. Initial REST fetch only + // returns the most recent page; this hook paginates older events into the + // store on demand so the chat doesn't load (potentially) thousands of + // events on first render. + const { + isLoading: isLoadingOlderEvents, + hasMore: hasMoreOlderEvents, + loadOlder, + } = useLoadOlderEvents(conversationId); + + // Trigger `loadOlder` and preserve the visual scroll position once the + // older page is merged in (otherwise prepending events would jump the + // chat far down). We fire from three places to cover the cases the + // browser's scroll event misses: + // + // - `onScroll`: normal "user scrolled near the top" path. + // - `onWheel`: user is already pinned at scrollTop=0 and tries to + // wheel further up — no scroll event fires past 0. + // - effect: content is shorter than the viewport (no scrollbar + // at all), so the user has nothing to scroll. Re-runs + // as more pages arrive until there's overflow or the + // server runs out of older events. + const SCROLL_TOP_THRESHOLD_PX = 80; + const preserveScrollPosition = React.useRef<{ + scrollHeight: number; + scrollTop: number; + } | null>(null); + const maybeLoadOlder = React.useCallback( + (target: HTMLElement) => { + if (isProvisioningTask || isLoadingOlderEvents || !hasMoreOlderEvents) { + return; + } + + const atTop = target.scrollTop <= SCROLL_TOP_THRESHOLD_PX; + const noOverflow = + target.scrollHeight <= target.clientHeight + SCROLL_TOP_THRESHOLD_PX; + if (!atTop && !noOverflow) return; + + preserveScrollPosition.current = { + scrollHeight: target.scrollHeight, + scrollTop: target.scrollTop, + }; + loadOlder().catch((error) => { + preserveScrollPosition.current = null; + const message = + error instanceof Error && error.message + ? error.message + : t(I18nKey.ERROR$GENERIC); + setErrorMessage(message); + }); + }, + [ + hasMoreOlderEvents, + isLoadingOlderEvents, + isProvisioningTask, + loadOlder, + setErrorMessage, + t, + ], + ); + + const handleWheelForPagination = React.useCallback( + (e: React.WheelEvent) => { + // Browsers don't dispatch a scroll event when scrollTop is already + // 0 and the user wheels upward, so onScroll alone misses this case. + if (e.deltaY < 0 && e.currentTarget.scrollTop <= 0) { + maybeLoadOlder(e.currentTarget); + } + }, + [maybeLoadOlder], + ); + + const hasPendingUserMessages = React.useMemo( + () => + conversationId + ? pendingMessages.some((message) => + matchesPendingConversationId( + conversationId, + message.conversationId, + ), + ) + : false, + [pendingMessages, conversationId], + ); + + const hasModelEntries = useModelStore((s) => + conversationId + ? (s.entriesByConversation[conversationId]?.length ?? 0) > 0 + : false, + ); + const hasStartedConversation = hasConversationStarted({ + isLoadingHistory: conversationWebSocket?.isLoadingHistory === true, + hasUserEvents: userEventsExist, + hasPendingUserMessages, + hasSubstantiveAgentActions, + hasModelEntries, + }); + + // Show V1 messages immediately if events exist in store (e.g., remount), + // if the user already has a locally-tracked pending bubble (home-page cloud + // submit while history/WS catch up), or once loading completes. This + // replaces the old transition-observation pattern (useState + useEffect + // watching loading→loaded) which always showed skeleton on remount because + // local state initialized to false. + const showConversationMessages = + allConversationEvents.length > 0 || + hasPendingUserMessages || + !conversationWebSocket?.isLoadingHistory; + + const isReturningToConversation = !!conversationId; + // Only show loading skeleton when genuinely loading AND no events in store yet. + // If events exist (e.g., remount after data was already fetched), skip skeleton. + const isHistoryLoading = !showConversationMessages; + const isChatLoading = isHistoryLoading && !isTask; + + const handleSendMessage = async ( + content: string, + originalImages: File[], + originalFiles: File[], + ) => { + // Handle /new command for V1 conversations + if (content.trim() === "/new") { + if (!conversationId) { + displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_NO_ID)); + return; + } + if (totalEvents === 0) { + displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_EMPTY)); + return; + } + if (isNewConversationPending) { + return; + } + newConversationCommand(); + return; + } + + // Create mutable copies of the arrays + const images = [...originalImages]; + const files = [...originalFiles]; + if (totalEvents === 0) { + trackInitialQuerySubmitted({ + entryPoint: getEntryPoint( + selectedRepository !== null, + replayJson !== null, + ), + queryCharacterLength: content.length, + replayJsonSize: replayJson?.length, + }); + } else { + trackUserMessageSent({ + sessionMessageCount: totalEvents, + currentMessageLength: content.length, + }); + } + + // Validate file sizes before any processing + const allFiles = [...images, ...files]; + const validation = validateFiles(allFiles); + + if (!validation.isValid) { + displayErrorToast(`Error: ${validation.errorMessage}`); + return; // Stop processing if validation fails + } + + const promises = images.map((image) => convertImageToBase64(image)); + const imageUrls = await Promise.all(promises); + + const timestamp = new Date().toISOString(); + + const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } = + files.length > 0 + ? await uploadFiles({ conversationId: conversationId!, files }) + : { skipped_files: [], uploaded_files: [] }; + + skippedFiles.forEach((f) => displayErrorToast(f.reason)); + + const filePrompt = `${t(I18nKey.CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE)}: ${uploadedFiles.join("\n\n")}`; + const prompt = + uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content; + + // Enqueue the message into the local pending queue with status "sending" + // so the user immediately sees it in the chat with a faded treatment. The + // entry is removed when the WebSocket echoes back the corresponding + // `UserMessageEvent`. If the API call to send the message fails, the entry + // is flipped to "error" with a retry link. + const pendingId = enqueuePendingMessage({ + conversationId: conversationId!, + // `text` is what the user sees in the bubble; `content` is what we + // actually hand to the server (the prompt may include an appended + // "Files uploaded: …" block) and is what the echo will be matched + // against. They're different when there are file attachments. + text: content, + content: prompt, + imageUrls, + fileUrls: uploadedFiles, + timestamp, + }); + // Submitting a new prompt should always pull the chat back to the + // latest message even if the user had scrolled up. This also re-arms + // autoScroll so the streamed agent reply auto-follows. + scrollDomToBottom(); + setMessageToSend(""); + + try { + await send( + createChatMessage(prompt, imageUrls, uploadedFiles, timestamp), + ); + } catch (sendError) { + const sendErrorMessage = + sendError instanceof Error + ? sendError.message + : t(I18nKey.CHAT_INTERFACE$FAILED_TO_SEND_MESSAGE); + markPendingMessageError(pendingId, sendErrorMessage); + } + }; + + // Auto-scroll to bottom when new messages arrive — but only if the user is + // already pinned to the bottom. Scrolling up to load older events also + // grows `renderableEvents`, and we don't want to yank the user back to the + // bottom in that case. + React.useEffect(() => { + // If a "load older" was just triggered, restore the scroll position so + // the conversation appears to extend upward instead of jumping. + if (preserveScrollPosition.current && scrollRef.current) { + const { scrollHeight: prevHeight, scrollTop: prevTop } = + preserveScrollPosition.current; + const dom = scrollRef.current; + const delta = dom.scrollHeight - prevHeight; + if (delta > 0) { + dom.scrollTop = prevTop + delta; + } + preserveScrollPosition.current = null; + return; + } + + if (autoScroll) { + scrollDomToBottom(); + } + // Note: We intentionally exclude autoScroll from deps because we only want + // to scroll when message content changes, not when autoScroll state changes. + }, [ + renderableEvents.length, + hasPendingUserMessages, + activeGoalScrollKey, + scrollDomToBottom, + ]); + + // Auto-load older events when the chat content doesn't overflow the + // scroll area (no scrollbar to drag, no wheel events past 0). We + // re-run only when the rendered list grows or `hasMore` flips, NOT + // when `maybeLoadOlder` re-creates: the underlying hook's `loadOlder` + // ref changes whenever its internal `isLoading` toggles, so depending + // on `maybeLoadOlder` would re-fire the effect on every failed page + // and tight-loop until the server recovered. Driving off + // `renderableEvents.length` instead means a successful page (events + // grow) chains the next request, while a failed page (events + // unchanged) waits for the user to retry. + const maybeLoadOlderRef = React.useRef(maybeLoadOlder); + React.useEffect(() => { + maybeLoadOlderRef.current = maybeLoadOlder; + }); + React.useEffect(() => { + const target = scrollRef.current; + if (!target) return; + maybeLoadOlderRef.current(target); + }, [renderableEvents.length, hasMoreOlderEvents]); + + // Create a ScrollProvider with the scroll hook values + const scrollProviderValue = { + scrollRef, + autoScroll, + setAutoScroll, + scrollDomToBottom, + hitBottom, + setHitBottom, + onChatBodyScroll, + }; + + // Get server status indicator props + const isStartingStatus = + curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT; + const isStopStatus = curAgentState === AgentState.STOPPED; + const isPausing = curAgentState === AgentState.PAUSED; + const serverStatusColor = getStatusColor({ + isPausing, + isTask, + taskStatus, + isStartingStatus, + isStopStatus, + curAgentState, + }); + const serverStatusText = getStatusText({ + isPausing, + isTask, + taskStatus, + taskDetail, + isStartingStatus, + isStopStatus, + curAgentState, + errorMessage, + t, + }); + + return ( + + +
    + {!hasSubstantiveAgentActions && + !hasPendingUserMessages && + !userEventsExist && + !hasModelEntries && + !isChatLoading && + !isProvisioningTask && + totalEvents === 0 && + !isArchivedConversation && + // With no usable LLM the suggestions can't be acted on (the input is + // disabled). They're also a `pointer-events-auto` overlay that would + // sit over the LlmNotConfiguredBanner below and swallow clicks on its + // setup button — so hide them and let the banner be the lone CTA. + !llmBlocked && ( + setMessageToSend(message)} + /> + )} + {/* Note: We only hide chat suggestions when there's a user message */} + +
    { + onChatBodyScroll(e.currentTarget); + maybeLoadOlder(e.currentTarget); + }} + onWheel={handleWheelForPagination} + className="custom-scrollbar-always flex min-h-0 grow flex-col gap-2 overflow-x-hidden overflow-y-auto px-0 pt-4 pb-8 md:px-4" + > + {isChatLoading && isReturningToConversation && ( + + )} + + {isChatLoading && !isReturningToConversation && ( +
    + +
    + )} + + {isLoadingOlderEvents && ( +
    + + {t(I18nKey.CHAT_INTERFACE$FETCHING_OLDER_MESSAGES)} +
    + )} + + {/* + * Render whenever there's anything to display. Previously this + * was gated on `conversationUserEventsExist`, but with the lazy + * "50 most recent" REST fetch the initial window may not include + * any `source: "user"` events (long agent runs between user + * turns). That left the chat blank, leaving the user nothing to + * scroll — which is why "scroll up to load older" appeared + * broken. The empty-state ChatSuggestions block above still + * keeps its own gate (`!userEventsExist && !hasSubstantiveAgentActions`) + * so brand-new conversations show suggestions, not an empty chat. + */} + {/* /model entries created before any event is rendered are + anchored to `null` and live above the message list. */} + + + {showConversationMessages && renderableEvents.length > 0 && ( + + )} + + {/* + Render the local pending-message queue independently so messages + the user just submitted show up immediately (with a faded "sending" + treatment) even before any real conversation event has come back + from the server. Entries drain (FIFO) when the matching + UserMessageEvent echoes back over the WebSocket, so this never + double-renders alongside the real event list. + */} + + + {/* Goal-loop status sits at the end of the message flow — above the + composer and its typing indicator — so progress stays in view. */} + +
    + +
    + + + {errorMessage && ( + conversationWebSocket?.reconnect() + : undefined + } + onReauth={ + isAcpAuthErrorCode(errorCode) + ? () => navigate("/settings/agents") + : undefined + } + /> + )} + + {llmBlocked && !isArchivedConversation && ( + + )} + + {isArchivedConversation ? ( + // Archived / sandbox-error: show a read-only notice in place of + // the chat input. The conversation history above is still visible. +
    +

    + {sandboxStatus === "ERROR" + ? t(I18nKey.CHAT_INTERFACE$ERROR_SANDBOX_TITLE) + : t(I18nKey.CHAT_INTERFACE$ARCHIVED_SANDBOX_TITLE)} +

    +

    + {sandboxStatus === "ERROR" + ? t(I18nKey.CHAT_INTERFACE$ERROR_SANDBOX_DESCRIPTION) + : t(I18nKey.CHAT_INTERFACE$ARCHIVED_SANDBOX_DESCRIPTION)} +

    +
    + ) : ( +
    +
    +
    +
    + + {isStartingStatus && ( + + )} +
    + + {!hitBottom ? ( +
    + +
    + ) : ( + (curAgentState === AgentState.RUNNING || + isPlanningAgentRunning) && ( +
    + +
    + ) + )} +
    +
    + + +
    + )} +
    +
    +
    +
    + ); +} diff --git a/src/components/features/chat/chat-markdown-path-code.tsx b/src/components/features/chat/chat-markdown-path-code.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bfafd3e7e8e731f28bdf4a061843e138f8df60f9 --- /dev/null +++ b/src/components/features/chat/chat-markdown-path-code.tsx @@ -0,0 +1,161 @@ +import React, { createContext, useContext } from "react"; +import type { ExtraProps } from "react-markdown"; +import ConversationService from "#/api/conversation-service/conversation-service.api"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { useWorkspaceFiles } from "#/hooks/query/use-workspace-files"; +import { openWorkspaceFile } from "#/services/canvas-ui"; +import { looksLikeWorkspaceFilePath, toFilesTabPath } from "#/utils/path-utils"; +import { cn } from "#/utils/utils"; +import { code as defaultCode } from "../markdown/code"; +import { anchor as defaultAnchor } from "../markdown/anchor"; + +/** True while rendering Markdown descendants of an existing ``. */ +const InMarkdownLinkContext = createContext(false); + +/** + * Workspace paths available for chat Markdown linking. Absent outside + * ChatInterface (e.g. unit tests) so ChatCode never requires QueryClient. + */ +export const WorkspaceFilesForChatContext = createContext( + undefined, +); + +/** Fetches workspace files once for the chat tree and exposes them to path links. */ +export function WorkspaceFilesForChatProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { data } = useWorkspaceFiles(); + return ( + + {children} + + ); +} + +type CodeProps = React.ClassAttributes & + React.HTMLAttributes & + ExtraProps; + +type AnchorProps = React.ClassAttributes & + React.AnchorHTMLAttributes & + ExtraProps; + +type StrongProps = React.ClassAttributes & + React.HTMLAttributes & + ExtraProps; + +/** Flatten React children to plain text, or null if mixed nodes. */ +function getPlainText(children: React.ReactNode): string | null { + if (children == null || typeof children === "boolean") return ""; + if (typeof children === "string" || typeof children === "number") { + return String(children); + } + if (Array.isArray(children)) { + const parts = children.map(getPlainText); + if (parts.some((part) => part === null)) return null; + return parts.join(""); + } + return null; +} + +/** + * Only link paths that currently exist in the conversation workspace. + * workingDir comes from ConversationService — same source as navigate_to_file. + */ +function useExistingWorkspacePath(candidate: string): string | null { + const files = useContext(WorkspaceFilesForChatContext); + if (!files?.length || !looksLikeWorkspaceFilePath(candidate)) return null; + + const workingDir = + ConversationService.getCurrentConversation()?.workspace?.working_dir; + const normalized = toFilesTabPath(candidate, workingDir); + if (!normalized) return null; + return files.includes(normalized) ? normalized : null; +} + +function ChatMarkdownPathLink({ + path, + className, + children, +}: { + path: string; + className?: string; + children: React.ReactNode; +}) { + const { conversationId } = useOptionalConversationId(); + + return ( + + ); +} + +/** + * Chat-only inline code: existing workspace paths become Files-drawer buttons. + * Nested under an existing Markdown link → stay plain ``. + */ +export function ChatCode(props: CodeProps) { + const { children, className } = props; + const inLink = useContext(InMarkdownLinkContext); + const match = /language-(\w+)/.exec(className || ""); + const codeString = String(children).replace(/\n$/, ""); + const isMultiline = String(children).includes("\n"); + const existingPath = useExistingWorkspacePath( + !match && !isMultiline && !inLink ? codeString : "", + ); + + if (existingPath) { + return ( + + {children} + + ); + } + + return defaultCode(props); +} + +/** + * Agents often emphasize paths with `**file.md**` instead of backticks. + * Link those only when the whole strong span is an existing workspace file. + */ +export function ChatStrong(props: StrongProps) { + const { children } = props; + const inLink = useContext(InMarkdownLinkContext); + const text = getPlainText(children)?.trim() ?? ""; + const existingPath = useExistingWorkspacePath(!inLink ? text : ""); + + if (existingPath) { + return ( + + {children} + + ); + } + + return {children}; +} + +/** Marks descendants so nested path tokens are not turned into a button. */ +export function ChatAnchor(props: AnchorProps) { + return ( + + {defaultAnchor(props)} + + ); +} diff --git a/src/components/features/chat/chat-message.tsx b/src/components/features/chat/chat-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e6a1b4a70721d26212befd9c54c4a91f6f5095f1 --- /dev/null +++ b/src/components/features/chat/chat-message.tsx @@ -0,0 +1,314 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "#/utils/utils"; +import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button"; +import type { SourceType } from "#/types/agent-server/core/base/common"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { I18nKey } from "#/i18n/declaration"; +import { TextShimmer } from "#/components/shared/text-shimmer"; +import { formatEventTimestamp } from "#/utils/format-event-timestamp"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; +import { PendingStopIcon } from "./pending-stop-icon"; +import { + UserMessageBody, + chatBubbleMarkdownComponents, + USER_MESSAGE_LINE_HEIGHT_PX, +} from "./user-message-body"; + +export type ChatMessagePendingStatus = "sending" | "error"; + +interface ChatMessageProps { + type: SourceType; + message: string; + actions?: Array<{ + icon: React.ReactNode; + onClick: () => void; + tooltip?: string; + }>; + isFromPlanningAgent?: boolean; + pendingStatus?: ChatMessagePendingStatus; + onRetry?: () => void; + onDismiss?: () => void; + onStop?: () => void; + timestamp?: string; +} + +export function ChatMessage({ + type, + message, + children, + actions, + isFromPlanningAgent = false, + pendingStatus, + onRetry, + onDismiss, + onStop, + timestamp, +}: React.PropsWithChildren) { + const { t, i18n } = useTranslation("openhands"); + const [isHovering, setIsHovering] = React.useState(false); + const [isCopy, setIsCopy] = React.useState(false); + const [isExpanded, setIsExpanded] = React.useState(false); + const [isTruncatable, setIsTruncatable] = React.useState(false); + const [isSingleLinePendingMessage, setIsSingleLinePendingMessage] = + React.useState(true); + const pendingMessageContentRef = React.useRef(null); + const timestampLabel = formatEventTimestamp(timestamp, i18n?.language); + + React.useEffect(() => { + setIsExpanded(false); + }, [message]); + + const handleCopyToClipboard = async () => { + await navigator.clipboard.writeText(message); + setIsCopy(true); + }; + + React.useEffect(() => { + let timeout: NodeJS.Timeout; + + if (isCopy) { + timeout = setTimeout(() => { + setIsCopy(false); + }, 2000); + } + + return () => { + clearTimeout(timeout); + }; + }, [isCopy]); + + const isPendingUserMessage = + type === "user" && + (pendingStatus === "error" || pendingStatus === "sending"); + const canStopPendingMessage = pendingStatus === "sending" && onStop != null; + const showStopButton = canStopPendingMessage && isHovering; + const useTruncatedUserBody = type === "user" && pendingStatus == null; + const isCollapsed = useTruncatedUserBody && isTruncatable && !isExpanded; + const hasBubbleChildren = React.Children.count(children) > 0; + + React.useLayoutEffect(() => { + if (!canStopPendingMessage || useTruncatedUserBody) { + return undefined; + } + + const content = pendingMessageContentRef.current; + if (!content) { + return undefined; + } + + const measure = () => { + const lineHeight = Number.parseFloat( + getComputedStyle(content).lineHeight, + ); + const linePx = + Number.isFinite(lineHeight) && lineHeight > 0 + ? lineHeight + : USER_MESSAGE_LINE_HEIGHT_PX; + + setIsSingleLinePendingMessage(content.scrollHeight <= linePx + 1); + }; + + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(content); + + return () => observer.disconnect(); + }, [message, canStopPendingMessage, useTruncatedUserBody]); + + const messageContent = useTruncatedUserBody ? ( + + ) : ( +
    + + {message} + +
    + ); + + const renderedMessageContent = messageContent; + + const messageBubble = ( +
    setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className={cn( + "rounded-xl relative w-fit max-w-full flex flex-col", + hasBubbleChildren && "gap-2", + type === "user" && "mt-6 bg-tertiary self-end px-4 py-2.5", + type === "agent" && "mt-6 w-full max-w-full bg-transparent", + isFromPlanningAgent && + type === "agent" && + "border border-[#597ff4] bg-tertiary p-4 mt-2", + pendingStatus === "error" && + "border border-[var(--oh-status-error)]/40", + !isPendingUserMessage && "last:mb-4", + )} + > +
    event.stopPropagation()} + > + {actions?.map((action, index) => + action.tooltip ? ( + + + + ) : ( + + ), + )} + + +
    + + {renderedMessageContent} + + {canStopPendingMessage ? ( + + ) : null} + + {isCollapsed ? ( +
    + ); + + const messageBubbleWithTimestamp = timestampLabel ? ( + {timestampLabel}} + placement="top" + isOpen={isHovering} + > + {messageBubble} + + ) : ( + messageBubble + ); + + if (type === "user" && pendingStatus === "error") { + return ( +
    + {messageBubbleWithTimestamp} +
    + {t(I18nKey.CHAT_INTERFACE$MESSAGE_SEND_FAILED)} + {onRetry ? ( + + ) : null} + {onDismiss ? ( + + ) : null} +
    +
    + ); + } + + if (type === "user" && pendingStatus === "sending") { + return ( +
    + {messageBubbleWithTimestamp} +
    + + {t(I18nKey.CHAT_INTERFACE$MESSAGE_SENDING)} + +
    +
    + ); + } + + return messageBubbleWithTimestamp; +} diff --git a/src/components/features/chat/chat-messages-skeleton.tsx b/src/components/features/chat/chat-messages-skeleton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc23f8b4322af299d1fb9b911b4206770741f923 --- /dev/null +++ b/src/components/features/chat/chat-messages-skeleton.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; + +const SKELETON_PATTERN = [ + { width: "w-[25%]", height: "h-4", align: "justify-end" }, + { width: "w-[60%]", height: "h-4", align: "justify-start" }, + { width: "w-[45%]", height: "h-4", align: "justify-start" }, + { width: "w-[65%]", height: "h-20", align: "justify-start" }, + { width: "w-[35%]", height: "h-4", align: "justify-end" }, + { width: "w-[50%]", height: "h-4", align: "justify-start" }, + { width: "w-[30%]", height: "h-4", align: "justify-end" }, + { width: "w-[75%]", height: "h-4", align: "justify-start" }, + { width: "w-[55%]", height: "h-4", align: "justify-start" }, +]; + +function SkeletonBlock({ width, height }: { width: string; height: string }) { + return ( +
    + ); +} + +export function ChatMessagesSkeleton() { + const { t } = useTranslation("openhands"); + + return ( +
    + {SKELETON_PATTERN.map((item, i) => ( +
    + +
    + ))} +
    + ); +} diff --git a/src/components/features/chat/chat-play-button.tsx b/src/components/features/chat/chat-play-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c68615c4d88073673c96ef1429b87c55fc64a873 --- /dev/null +++ b/src/components/features/chat/chat-play-button.tsx @@ -0,0 +1,24 @@ +import PlayIcon from "#/icons/play-solid.svg?react"; +import { cn } from "#/utils/utils"; + +export interface ChatResumeAgentButtonProps { + onAgentResumed: () => void; + disabled?: boolean; +} + +export function ChatResumeAgentButton({ + onAgentResumed, + disabled = false, +}: ChatResumeAgentButtonProps) { + return ( + + ); +} diff --git a/src/components/features/chat/chat-send-button.tsx b/src/components/features/chat/chat-send-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0b895796e7d4f29a788962795634659b33c385b3 --- /dev/null +++ b/src/components/features/chat/chat-send-button.tsx @@ -0,0 +1,36 @@ +import { ArrowUp } from "lucide-react"; +import { cn } from "#/utils/utils"; + +export interface ChatSendButtonProps { + buttonClassName: string; + handleSubmit: () => void; + disabled: boolean; +} + +export function ChatSendButton({ + buttonClassName, + handleSubmit, + disabled, +}: ChatSendButtonProps) { + return ( + + ); +} diff --git a/src/components/features/chat/chat-status-indicator.tsx b/src/components/features/chat/chat-status-indicator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf1de033d7c9459727681905f8ba02afb425d6c6 --- /dev/null +++ b/src/components/features/chat/chat-status-indicator.tsx @@ -0,0 +1,50 @@ +import { cn } from "@heroui/react"; +import { motion, AnimatePresence } from "framer-motion"; +import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react"; + +interface ChatStatusIndicatorProps { + status: string; + statusColor: string; +} + +function ChatStatusIndicator({ + status, + statusColor, +}: ChatStatusIndicatorProps) { + return ( +
    + + {/* Dot */} + + + + + {/* Text */} + + {status} + + +
    + ); +} + +export default ChatStatusIndicator; diff --git a/src/components/features/chat/chat-stop-button.tsx b/src/components/features/chat/chat-stop-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d591e4ff8e67e583fd954cb7e51fb50bb0b3cdd --- /dev/null +++ b/src/components/features/chat/chat-stop-button.tsx @@ -0,0 +1,18 @@ +import PauseIcon from "#/icons/pause.svg?react"; + +export interface ChatStopButtonProps { + handleStop: () => void; +} + +export function ChatStopButton({ handleStop }: ChatStopButtonProps) { + return ( + + ); +} diff --git a/src/components/features/chat/chat-suggestions.tsx b/src/components/features/chat/chat-suggestions.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c8c8de7e64f7da66d2dd4bc40af3dd3517e0f358 --- /dev/null +++ b/src/components/features/chat/chat-suggestions.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { motion, AnimatePresence } from "framer-motion"; +import { Suggestions } from "#/components/features/suggestions/suggestions"; +import { I18nKey } from "#/i18n/declaration"; +import { SUGGESTIONS } from "#/utils/suggestions"; +import { useConversationStore } from "#/stores/conversation-store"; + +interface ChatSuggestionsProps { + onSuggestionsClick: (value: string) => void; +} + +export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) { + const { t } = useTranslation("openhands"); + const { shouldHideSuggestions } = useConversationStore(); + + return ( + + {!shouldHideSuggestions && ( + +
    + + {t(I18nKey.LANDING$TITLE)} + +
    + ({ + label, + value, + }))} + onSuggestionClick={onSuggestionsClick} + /> +
    + )} +
    + ); +} diff --git a/src/components/features/chat/confirmation-mode-enabled.tsx b/src/components/features/chat/confirmation-mode-enabled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b5bcb6f0ab9e26da36801d1a9095ddd7e07b120 --- /dev/null +++ b/src/components/features/chat/confirmation-mode-enabled.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import LockIcon from "#/icons/lock.svg?react"; +import { useSettings } from "#/hooks/query/use-settings"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; + +function ConfirmationModeEnabled() { + const { t } = useTranslation("openhands"); + + const { data: settings } = useSettings(); + + if (!settings?.confirmation_mode) { + return null; + } + + return ( + +
    + +
    +
    + ); +} + +export default ConfirmationModeEnabled; diff --git a/src/components/features/chat/custom-chat-input.tsx b/src/components/features/chat/custom-chat-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ae879522a821f6299d8ba03a5639dc145660dc5b --- /dev/null +++ b/src/components/features/chat/custom-chat-input.tsx @@ -0,0 +1,226 @@ +import React, { useEffect, useRef } from "react"; +import { useChatInputLogic } from "#/hooks/chat/use-chat-input-logic"; +import { useFileHandling } from "#/hooks/chat/use-file-handling"; +import { useGripResize } from "#/hooks/chat/use-grip-resize"; +import { useChatInputEvents } from "#/hooks/chat/use-chat-input-events"; +import { useChatSubmission } from "#/hooks/chat/use-chat-submission"; +import { useSlashCommand } from "#/hooks/chat/use-slash-command"; +import { ChatInputGrip } from "./components/chat-input-grip"; +import { ChatInputContainer } from "./components/chat-input-container"; +import { HiddenFileInput } from "./components/hidden-file-input"; +import { useConversationStore } from "#/stores/conversation-store"; +import { cn } from "#/utils/utils"; + +export interface CustomChatInputProps { + disabled?: boolean; + isNewConversationPending?: boolean; + hasStartedConversation?: boolean; + showButton?: boolean; + onSubmit: (message: string) => void; + onFocus?: () => void; + onBlur?: () => void; + onFilesPaste?: ( + files: File[], + options?: import("#/hooks/chat/use-chat-attachment-upload").ChatAttachmentUploadOptions, + ) => void; + className?: React.HTMLAttributes["className"]; + buttonClassName?: React.HTMLAttributes["className"]; +} + +export function CustomChatInput({ + disabled = false, + isNewConversationPending = false, + hasStartedConversation, + showButton = true, + onSubmit, + onFocus, + onBlur, + onFilesPaste, + className = "", + buttonClassName = "", +}: CustomChatInputProps) { + const [canSubmit, setCanSubmit] = React.useState(false); + const { + submittedMessage, + clearAllFiles, + setShouldHideSuggestions, + setSubmittedMessage, + images, + files, + } = useConversationStore(); + + // Note: we intentionally do NOT disable the input when the conversation is + // in an ERROR/STUCK execution state. Users should be able to send a follow-up + // message to recover the conversation; the message will be delivered + // immediately via the WebSocket if connected, or queued via REST otherwise. + const isDisabled = disabled; + + // Always call the latest `onSubmit` without making the effect re-run when + // its identity changes. `onSubmit` (typically `handleSendMessage`) is a + // fresh function on every parent render, and the parent re-renders + // whenever the pending-message queue updates synchronously inside + // `onSubmit` itself. Listing it in the dep array caused the effect to + // fire twice — once for the original submit and again from the + // mid-submit re-render, before `setSubmittedMessage(null)` was applied — + // producing a duplicate "Sending…" bubble. + const onSubmitRef = useRef(onSubmit); + useEffect(() => { + onSubmitRef.current = onSubmit; + }, [onSubmit]); + + // Listen to submittedMessage state changes + useEffect(() => { + if (!submittedMessage || disabled) { + return; + } + onSubmitRef.current(submittedMessage); + setSubmittedMessage(null); + }, [submittedMessage, disabled, setSubmittedMessage]); + + // Custom hooks + const { + chatInputRef, + messageToSend, + checkIsContentEmpty, + clearEmptyContentHandler, + saveDraft, + } = useChatInputLogic(); + + const syncCanSubmit = React.useCallback(() => { + const text = chatInputRef.current?.innerText ?? ""; + const hasAttachments = images.length > 0 || files.length > 0; + setCanSubmit(text.trim().length > 0 || hasAttachments); + }, [chatInputRef, images, files]); + + const { + fileInputRef, + chatContainerRef, + isDragOver, + handleFileIconClick, + handleFileInputChange, + handleDragOver, + handleDragLeave, + handleDrop, + } = useFileHandling(onFilesPaste); + + const { + gripRef, + isGripVisible, + isGripDragging, + canResize, + handleTopEdgeClick, + smartResize, + handleGripMouseDown, + handleGripTouchStart, + increaseHeightForEmptyContent, + resetManualResize, + } = useGripResize( + chatInputRef as React.RefObject, + messageToSend, + ); + + const { handleSubmit } = useChatSubmission( + chatInputRef as React.RefObject, + fileInputRef as React.RefObject, + smartResize, + onSubmit, + resetManualResize, + ); + const handleSubmitAndSync = React.useCallback(() => { + handleSubmit(); + syncCanSubmit(); + }, [handleSubmit, syncCanSubmit]); + + const { handleInput, handlePaste, handleKeyDown, handleBlur, handleFocus } = + useChatInputEvents( + chatInputRef as React.RefObject, + smartResize, + increaseHeightForEmptyContent, + checkIsContentEmpty, + clearEmptyContentHandler, + onFocus, + onBlur, + ); + + const { + isMenuOpen: isSlashMenuOpen, + filteredItems: slashItems, + selectedIndex: slashSelectedIndex, + updateSlashMenu, + selectItem: selectSlashItem, + handleSlashKeyDown, + closeMenu: closeSlashMenu, + } = useSlashCommand(chatInputRef as React.RefObject); + + // Cleanup: reset suggestions visibility when component unmounts + useEffect( + () => () => { + setShouldHideSuggestions(false); + clearAllFiles(); + }, + [setShouldHideSuggestions, clearAllFiles], + ); + useEffect(() => { + syncCanSubmit(); + }, [syncCanSubmit, images.length, files.length]); + return ( +
    + {/* Hidden file input */} + + + {/* Container with grip */} +
    + + + { + handleInput(); + updateSlashMenu(); + saveDraft(); + syncCanSubmit(); + }} + onPaste={handlePaste} + onKeyDown={(e) => { + if (handleSlashKeyDown(e)) return; + handleKeyDown(e, isDisabled, handleSubmitAndSync); + }} + onFocus={handleFocus} + onBlur={() => { + handleBlur(); + closeSlashMenu(); + syncCanSubmit(); + }} + isSlashMenuOpen={isSlashMenuOpen} + slashItems={slashItems} + slashSelectedIndex={slashSelectedIndex} + onSlashSelect={selectSlashItem} + /> +
    +
    + ); +} diff --git a/src/components/features/chat/drag-over.tsx b/src/components/features/chat/drag-over.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60695ad2cf5edece5e740f0435305e46a6f918f3 --- /dev/null +++ b/src/components/features/chat/drag-over.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from "react-i18next"; +import ImageIcon from "#/icons/image.svg?react"; +import ArrowDownCurveIcon from "#/icons/arrow-down-curve.svg?react"; +import { I18nKey } from "#/i18n/declaration"; + +export function DragOver() { + const { t } = useTranslation("openhands"); + + return ( +
    +
    +
    + + +
    +
    +

    {t(I18nKey.COMMON$DROP_YOUR_FILES_HERE)}

    +
    +
    +
    + ); +} diff --git a/src/components/features/chat/error-message-banner.tsx b/src/components/features/chat/error-message-banner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cff1972000d6e07748d791c556495b99e3952800 --- /dev/null +++ b/src/components/features/chat/error-message-banner.tsx @@ -0,0 +1,211 @@ +import React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Check, CircleAlert, CircleX, Copy, X } from "lucide-react"; +import { OH_STATUS_ERROR_COLOR } from "#/constants/status-colors"; +import { I18nKey } from "#/i18n/declaration"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { getAcpErrorHeaderKey } from "#/utils/acp-error-codes"; +import { cn } from "#/utils/utils"; +import type { ErrorClassification } from "@openhands/typescript-client"; + +interface ErrorMessageBannerProps { + message: string; + /** Structured error code (e.g. "ACPAuthRequired") used to pick a header. */ + code?: string | null; + onDismiss?: () => void; + onRetry?: () => void; + /** Recovery action (e.g. re-authenticate) shown for credential failures. */ + onReauth?: () => void; + classification?: ErrorClassification | null; +} + +const DEFAULT_MAX_COLLAPSED_CHARS = 220; + +export function ErrorMessageBanner({ + message, + code, + onDismiss, + onRetry, + onReauth, + classification, +}: ErrorMessageBannerProps) { + const { t, i18n } = useTranslation("openhands"); + const headerKey = getAcpErrorHeaderKey(code); + const [isExpanded, setIsExpanded] = React.useState(false); + const [isMultiLine, setIsMultiLine] = React.useState(false); + const [isCopied, setIsCopied] = React.useState(false); + const contentRef = React.useRef(null); + + const isI18nKey = i18n.exists(message, { ns: "openhands" }); + const displayTextForLength = isI18nKey ? String(t(message)) : message; + const shouldShowToggle = + displayTextForLength.length > DEFAULT_MAX_COLLAPSED_CHARS; + + const isCollapsed = shouldShowToggle && !isExpanded; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(displayTextForLength); + setIsCopied(true); + } catch { + displayErrorToast(t(I18nKey.CHAT_INTERFACE$CHAT_MESSAGE_COPY_FAILED)); + } + }; + + React.useEffect(() => { + if (!isCopied) return undefined; + + const timeout = setTimeout(() => { + setIsCopied(false); + }, 2000); + + return () => clearTimeout(timeout); + }, [isCopied]); + + React.useEffect(() => { + setIsCopied(false); + }, [displayTextForLength]); + + React.useLayoutEffect(() => { + const content = contentRef.current; + if (!content) { + return undefined; + } + + const updateIsMultiLine = () => { + const lineHeight = Number.parseFloat( + getComputedStyle(content).lineHeight, + ); + if (!Number.isFinite(lineHeight) || lineHeight <= 0) { + setIsMultiLine(false); + return; + } + + setIsMultiLine(content.getBoundingClientRect().height > lineHeight * 1.5); + }; + + updateIsMultiLine(); + + const observer = new ResizeObserver(updateIsMultiLine); + observer.observe(content); + + return () => observer.disconnect(); + }, [displayTextForLength, isCollapsed, isExpanded, message]); + + return ( +
    + {classification != null && + classification.kind !== "internal" && + classification.kind !== "unknown" ? ( + + ) : ( + + )} +
    + {headerKey && ( +
    + {t(headerKey)} +
    + )} +
    + {isI18nKey ? : message} +
    + + {onReauth && ( + + )} + + {shouldShowToggle && ( + + )} +
    + +
    + {onRetry && ( + + )} + + + + {onDismiss && ( + + )} +
    +
    + ); +} diff --git a/src/components/features/chat/error-message.tsx b/src/components/features/chat/error-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7364e5c60fa91fd10576709a09f0afefdbcdf464 --- /dev/null +++ b/src/components/features/chat/error-message.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import ArrowDown from "#/icons/angle-down-solid.svg?react"; +import ArrowUp from "#/icons/angle-up-solid.svg?react"; +import i18n from "#/i18n"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; + +interface ErrorMessageProps { + errorId?: string; + defaultMessage: string; +} + +export function ErrorMessage({ errorId, defaultMessage }: ErrorMessageProps) { + const { t } = useTranslation("openhands"); + const [showDetails, setShowDetails] = React.useState(false); + + const hasValidTranslationId = !!errorId && i18n.exists(errorId); + const errorKey = hasValidTranslationId + ? errorId + : "CHAT_INTERFACE$AGENT_ERROR_MESSAGE"; + + return ( +
    +
    + {t(errorKey)} + +
    + + {showDetails && {defaultMessage}} +
    + ); +} diff --git a/src/components/features/chat/generic-event-message.tsx b/src/components/features/chat/generic-event-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c93f7990467417e74a57cbfc1ceaf773defcddc --- /dev/null +++ b/src/components/features/chat/generic-event-message.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import ArrowDown from "#/icons/angle-down-solid.svg?react"; +import ArrowUp from "#/icons/angle-up-solid.svg?react"; +import { SuccessIndicator } from "./success-indicator"; +import { ObservationResultStatus } from "#/components/conversation-events/chat/event-content-helpers/get-observation-result"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { formatEventTimestamp } from "#/utils/format-event-timestamp"; + +interface GenericEventMessageProps { + title: React.ReactNode; + details: string | React.ReactNode; + success?: ObservationResultStatus; + initiallyExpanded?: boolean; + /** Where to place the expand/collapse chevron relative to the title. */ + chevronPosition?: "before" | "after"; + /** Extra content rendered at the end of the title row (right side). */ + titleTrailing?: React.ReactNode; + /** Optional icon rendered before the title text. */ + titleIcon?: React.ReactNode; + timestamp?: string; +} + +export function GenericEventMessage({ + title, + details, + success, + initiallyExpanded = false, + chevronPosition = "after", + titleTrailing, + titleIcon, + timestamp, +}: GenericEventMessageProps) { + const { t, i18n } = useTranslation("openhands"); + const [showDetails, setShowDetails] = React.useState(initiallyExpanded); + const [isHovered, setIsHovered] = React.useState(false); + const [hasFocusWithin, setHasFocusWithin] = React.useState(false); + const timestampLabel = formatEventTimestamp(timestamp, i18n?.language); + + const chevron = details ? ( + + ) : null; + + const titleContent = ( +
    setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onFocusCapture={() => setHasFocusWithin(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setHasFocusWithin(false); + } + }} + > + {chevronPosition === "before" && chevron} + {titleIcon} + {/* Wrap the title in a span so any whitespace inside Trans-rendered + fragments (e.g. "Editing ...") is preserved by + normal inline flow instead of being collapsed between + anonymous flex items. */} + {title} + {chevronPosition === "after" && chevron} +
    + ); + + const titleContentWithTimestamp = timestampLabel ? ( + {timestampLabel}} + placement="top" + isOpen={isHovered || hasFocusWithin} + > + {titleContent} + + ) : ( + titleContent + ); + + const titleRow = ( +
    + {titleContentWithTimestamp} +
    + {titleTrailing} + {success && } +
    +
    + ); + + return ( +
    + {titleRow} + {showDetails && + (typeof details === "string" ? ( + {details} + ) : ( + details + ))} +
    + ); +} diff --git a/src/components/features/chat/git-control-bar-branch-button.tsx b/src/components/features/chat/git-control-bar-branch-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0b171ff6cf086ab6f78a379e98896a0e11b6619e --- /dev/null +++ b/src/components/features/chat/git-control-bar-branch-button.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from "react-i18next"; +import BranchIcon from "#/icons/u-code-branch.svg?react"; +import { constructBranchUrl, cn } from "#/utils/utils"; +import { Provider } from "#/types/settings"; +import { I18nKey } from "#/i18n/declaration"; +import { GitExternalLinkIcon } from "./git-external-link-icon"; +import { useSettings } from "#/hooks/query/use-settings"; + +interface GitControlBarBranchButtonProps { + selectedBranch: string | null | undefined; + selectedRepository: string | null | undefined; + gitProvider: Provider | null | undefined; +} + +export function GitControlBarBranchButton({ + selectedBranch, + selectedRepository, + gitProvider, +}: GitControlBarBranchButtonProps) { + const { t } = useTranslation("openhands"); + const { data: settings } = useSettings(); + + const providerHost = gitProvider + ? settings?.provider_tokens_set[gitProvider] + : null; + + // Render the linked styling only when we have enough info to build a URL. + // Local-workspace fallbacks may give us a branch name without a known + // provider — show the branch text in that case but skip the external link. + const hasLinkableBranch = + !!selectedBranch && !!selectedRepository && !!gitProvider; + const branchUrl = hasLinkableBranch + ? constructBranchUrl( + gitProvider, + selectedRepository, + selectedBranch, + providerHost, + ) + : undefined; + + const buttonText = selectedBranch || t(I18nKey.COMMON$NO_BRANCH); + + return ( +
    +
    + +
    +
    + {buttonText} +
    + {hasLinkableBranch && } +
    + ); +} diff --git a/src/components/features/chat/git-control-bar-pr-button.tsx b/src/components/features/chat/git-control-bar-pr-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aaec63b1744bb9ae0145cb280fc9b4697664ce6a --- /dev/null +++ b/src/components/features/chat/git-control-bar-pr-button.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import PRIcon from "#/icons/u-pr.svg?react"; +import { cn, getCreatePRPrompt } from "#/utils/utils"; +import { + gitControlBarActionButtonClassName, + gitControlBarActionIconColor, + gitControlBarActionLabelClassName, +} from "#/utils/git-control-bar-classes"; +import { I18nKey } from "#/i18n/declaration"; +import { Provider } from "#/types/settings"; +import { useTracking } from "#/hooks/use-tracking"; + +interface GitControlBarPrButtonProps { + onSuggestionsClick: (value: string) => void; + hasRepository: boolean; + providerTokensReady: boolean; + currentGitProvider: Provider; + isConversationReady?: boolean; +} + +export function GitControlBarPrButton({ + onSuggestionsClick, + hasRepository, + providerTokensReady, + currentGitProvider, + isConversationReady = true, +}: GitControlBarPrButtonProps) { + const { t } = useTranslation("openhands"); + const { trackCreatePrButtonClick } = useTracking(); + + const isButtonEnabled = + providerTokensReady && hasRepository && isConversationReady; + + const handlePrClick = () => { + trackCreatePrButtonClick(); + onSuggestionsClick(getCreatePRPrompt(currentGitProvider)); + }; + + return ( + + ); +} diff --git a/src/components/features/chat/git-control-bar-pull-button.tsx b/src/components/features/chat/git-control-bar-pull-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7efb7ae18885aca121287157c3a24c23701b1197 --- /dev/null +++ b/src/components/features/chat/git-control-bar-pull-button.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import ArrowDownIcon from "#/icons/u-arrow-down.svg?react"; +import { cn, getGitPullPrompt } from "#/utils/utils"; +import { + gitControlBarActionButtonClassName, + gitControlBarActionIconColor, + gitControlBarActionLabelClassName, +} from "#/utils/git-control-bar-classes"; +import { I18nKey } from "#/i18n/declaration"; +import { useTracking } from "#/hooks/use-tracking"; + +interface GitControlBarPullButtonProps { + onSuggestionsClick: (value: string) => void; + hasRepository: boolean; + providerTokensReady: boolean; + isConversationReady?: boolean; +} + +export function GitControlBarPullButton({ + onSuggestionsClick, + hasRepository, + providerTokensReady, + isConversationReady = true, +}: GitControlBarPullButtonProps) { + const { t } = useTranslation("openhands"); + const { trackPullButtonClick } = useTracking(); + + const isButtonEnabled = + providerTokensReady && hasRepository && isConversationReady; + + const handlePullClick = () => { + trackPullButtonClick(); + onSuggestionsClick(getGitPullPrompt()); + }; + + return ( + + ); +} diff --git a/src/components/features/chat/git-control-bar-push-button.tsx b/src/components/features/chat/git-control-bar-push-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2bb59592b9d1eacf38053d83c5a12f82bc4c489c --- /dev/null +++ b/src/components/features/chat/git-control-bar-push-button.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from "react-i18next"; +import ArrowUpIcon from "#/icons/u-arrow-up.svg?react"; +import { cn, getGitPushPrompt } from "#/utils/utils"; +import { + gitControlBarActionButtonClassName, + gitControlBarActionIconColor, + gitControlBarActionLabelClassName, +} from "#/utils/git-control-bar-classes"; +import { I18nKey } from "#/i18n/declaration"; +import { Provider } from "#/types/settings"; +import { useTracking } from "#/hooks/use-tracking"; + +interface GitControlBarPushButtonProps { + onSuggestionsClick: (value: string) => void; + hasRepository: boolean; + providerTokensReady: boolean; + currentGitProvider: Provider; + isConversationReady?: boolean; +} + +export function GitControlBarPushButton({ + onSuggestionsClick, + hasRepository, + providerTokensReady, + currentGitProvider, + isConversationReady = true, +}: GitControlBarPushButtonProps) { + const { t } = useTranslation("openhands"); + const { trackPushButtonClick } = useTracking(); + + const isButtonEnabled = + providerTokensReady && hasRepository && isConversationReady; + + const handlePushClick = () => { + trackPushButtonClick(); + onSuggestionsClick(getGitPushPrompt(currentGitProvider)); + }; + + return ( + + ); +} diff --git a/src/components/features/chat/git-control-bar-repo-button.tsx b/src/components/features/chat/git-control-bar-repo-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..79a97054e6a8d961d456be8438f9676636e1fd48 --- /dev/null +++ b/src/components/features/chat/git-control-bar-repo-button.tsx @@ -0,0 +1,111 @@ +import { FolderOpen } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { constructRepositoryUrl, cn } from "#/utils/utils"; +import { Provider } from "#/types/settings"; +import { I18nKey } from "#/i18n/declaration"; +import { GitProviderIcon } from "#/components/shared/git-provider-icon"; +import { GitExternalLinkIcon } from "./git-external-link-icon"; +import RepoForkedIcon from "#/icons/repo-forked.svg?react"; +import { useSettings } from "#/hooks/query/use-settings"; + +interface GitControlBarRepoButtonProps { + selectedRepository: string | null | undefined; + gitProvider: Provider | null | undefined; + workspaceName?: string | null; + emptyStateLabel?: string; + onClick?: () => void; + disabled?: boolean; +} + +export function GitControlBarRepoButton({ + selectedRepository, + gitProvider, + workspaceName, + emptyStateLabel: _emptyStateLabel, + onClick, + disabled, +}: GitControlBarRepoButtonProps) { + const { t } = useTranslation("openhands"); + const { data: settings } = useSettings(); + + // Render as an external link only when we know both the repo and the + // provider (so the URL is well-defined). Local-workspace fallbacks may + // populate `selectedRepository` without a provider; in that case we still + // show the repo name but keep the button non-link styling. + const hasLinkableRepo = !!selectedRepository && !!gitProvider; + + // Get the host for the current provider from settings + const providerHost = gitProvider + ? settings?.provider_tokens_set[gitProvider] + : null; + + const repositoryUrl = hasLinkableRepo + ? constructRepositoryUrl(gitProvider, selectedRepository, providerHost) + : undefined; + + const showConnectRepoCta = !selectedRepository && !workspaceName; + const buttonText = + selectedRepository || workspaceName || t(I18nKey.COMMON$CONNECT_REPO); + + if (hasLinkableRepo) { + return ( + +
    + +
    +
    + {buttonText} +
    + +
    + ); + } + + return ( + + ); +} diff --git a/src/components/features/chat/git-control-bar-tooltip-wrapper.tsx b/src/components/features/chat/git-control-bar-tooltip-wrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd4b09ef8a49606e7c89d177635b05839528a718 --- /dev/null +++ b/src/components/features/chat/git-control-bar-tooltip-wrapper.tsx @@ -0,0 +1,32 @@ +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; + +interface GitControlBarTooltipWrapperProps { + tooltipMessage: string; + testId: string; + children: React.ReactNode; + shouldShowTooltip: boolean; +} + +export function GitControlBarTooltipWrapper({ + children, + tooltipMessage, + testId, + shouldShowTooltip, +}: GitControlBarTooltipWrapperProps) { + if (!shouldShowTooltip) { + return children; + } + + return ( + + + {children} + + + ); +} diff --git a/src/components/features/chat/git-control-bar.tsx b/src/components/features/chat/git-control-bar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cda4b3fda3e49304f9c5cbbad84fe4afca5cbbb1 --- /dev/null +++ b/src/components/features/chat/git-control-bar.tsx @@ -0,0 +1,300 @@ +import { useState, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { GitControlBarRepoButton } from "./git-control-bar-repo-button"; +import { GitControlBarBranchButton } from "./git-control-bar-branch-button"; +import { GitControlBarPullButton } from "./git-control-bar-pull-button"; +import { GitControlBarPushButton } from "./git-control-bar-push-button"; +import { GitControlBarPrButton } from "./git-control-bar-pr-button"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useLocalGitInfo } from "#/hooks/query/use-local-git-info"; +import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; +import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; +import { useSendMessage } from "#/hooks/use-send-message"; +import { useUpdateConversationRepository } from "#/hooks/mutation/use-update-conversation-repository"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { Provider } from "#/types/settings"; +import { Branch, GitRepository } from "#/types/git"; +import { I18nKey } from "#/i18n/declaration"; +import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper"; +import { OpenRepositoryModal } from "./open-repository-modal"; +import { useConversationId } from "#/hooks/use-conversation-id"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { useHomeStore } from "#/stores/home-store"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useUserProviders } from "#/hooks/use-user-providers"; +import { useOptionalScrollContext } from "#/context/scroll-context"; + +interface GitControlBarProps { + onSuggestionsClick: (value: string) => void; +} + +export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) { + const { t } = useTranslation("openhands"); + const { conversationId } = useConversationId(); + const [isOpenRepoModalOpen, setIsOpenRepoModalOpen] = useState(false); + const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false); + const workspaceMenuContainerRef = useRef(null); + const { addRecentRepository } = useHomeStore(); + const enqueuePendingMessage = useOptimisticUserMessageStore( + (state) => state.enqueuePendingMessage, + ); + const markPendingMessageError = useOptimisticUserMessageStore( + (state) => state.markPendingMessageError, + ); + const { backend } = useActiveBackend(); + const isLocalBackend = backend.kind === "local"; + const { providers } = useUserProviders(); + const providerTokensReady = isLocalBackend || providers.length > 0; + + const { data: conversation } = useActiveConversation(); + const { repositoryInfo } = useTaskPolling(); + const { data: localGitInfo } = useLocalGitInfo(); + const webSocketStatus = useUnifiedWebSocketStatus(); + const conversationWebSocket = useConversationWebSocket(); + const isLoadingHistory = conversationWebSocket?.isLoadingHistory ?? false; + const webSocketStatusRef = useRef(webSocketStatus); + useEffect(() => { + webSocketStatusRef.current = webSocketStatus; + }, [webSocketStatus]); + const { send } = useSendMessage(); + const sendRef = useRef(send); + useEffect(() => { + sendRef.current = send; + }, [send]); + const scrollContext = useOptionalScrollContext(); + const { mutate: updateRepository } = useUpdateConversationRepository(); + const { mutate: _createConversation, isPending: _isCreatingConversation } = + useCreateConversation(); + + // Priority: conversation data > task data > locally-detected git info. + // The local fallback runs `git remote get-url origin` / `git rev-parse --abbrev-ref HEAD` + // in the conversation's working dir so local-workspace conversations can + // still display a repo and branch in the control bar. + const conversationRepository = + conversation?.selected_repository || repositoryInfo?.selectedRepository; + const conversationProvider = (conversation?.git_provider || + repositoryInfo?.gitProvider) as Provider | undefined; + const conversationBranch = + conversation?.selected_branch || repositoryInfo?.selectedBranch; + + const selectedRepository = + conversationRepository || localGitInfo?.repository || undefined; + const gitProvider = (conversationProvider || + localGitInfo?.provider) as Provider; + const selectedBranch = + conversationBranch || localGitInfo?.branch || undefined; + + // For folder-only conversations (no remote repo), surface the basename of + // the originally attached workspace path so the button reads e.g. "test" + // rather than "No Repo Connected". `selected_workspace` is recorded at + // conversation creation; we prefer it over `workspace.working_dir` because + // the latter may point at a worktree subdir. + const storedMetadata = conversation?.id + ? getStoredConversationMetadata(conversation.id) + : null; + const workspacePath = storedMetadata?.selected_workspace ?? null; + const workspaceName = workspacePath + ? workspacePath.replace(/\/+$/, "").split("/").pop() || null + : null; + + // Enable git actions whenever a repository and provider are known, including + // local conversations where repo metadata is inferred from git remotes. + const hasRepository = !!selectedRepository && !!gitProvider; + + // Enable buttons only when conversation exists, WS is connected, and the + // initial history preload has finished (matches chat-interface loading gate). + const isConversationReady = + !!conversation && webSocketStatus === "OPEN" && !isLoadingHistory; + + useEffect(() => { + if (!isWorkspaceMenuOpen) return undefined; + const onMouseDown = (event: MouseEvent) => { + if ( + workspaceMenuContainerRef.current && + !workspaceMenuContainerRef.current.contains(event.target as Node) + ) { + setIsWorkspaceMenuOpen(false); + } + }; + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [isWorkspaceMenuOpen]); + + useEffect(() => { + if (!isWorkspaceMenuOpen) return undefined; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsWorkspaceMenuOpen(false); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [isWorkspaceMenuOpen]); + + const handleLaunchRepository = ( + repository: GitRepository, + branch: Branch, + ) => { + if (!conversationId) return; + + // Persist to recent repositories list (matches home page behavior) + addRecentRepository(repository); + + // Note: We update repository metadata first, then send clone command. + // The clone command is sent to the agent via WebSocket (fire-and-forget). + // If cloning fails, the agent will report the error in the chat, + // and the user can retry or change the repository. + // This is a trade-off: immediate UI feedback vs. strict atomicity. + updateRepository( + { + conversationId, + repository: repository.full_name, + branch: branch.name, + gitProvider: repository.git_provider, + }, + { + onSuccess: () => { + // Use ref to read the latest WebSocket status (avoids stale closure) + if (webSocketStatusRef.current !== "OPEN") { + displayErrorToast( + t(I18nKey.CONVERSATION$CLONE_COMMAND_FAILED_DISCONNECTED), + ); + return; + } + + // Send clone command to agent after metadata is updated + // Use ref to always call the latest send function (avoids stale closure + // where V1 sendMessage holds a reference to a now-closed WebSocket) + // Include git provider in prompt so agent clones from correct source + const providerName = + repository.git_provider.charAt(0).toUpperCase() + + repository.git_provider.slice(1); + const clonePrompt = `Clone ${repository.full_name} from ${providerName} and checkout branch ${branch.name}.`; + const pendingId = conversationId + ? enqueuePendingMessage({ conversationId, text: clonePrompt }) + : null; + // Pull chat back to the bottom so the optimistic "Clone …" bubble + // is visible even if the user had scrolled up. + scrollContext?.scrollDomToBottom(); + // `send` returns a Promise; surface a failed send by flipping the + // matching pending entry to "error" so the user gets the retry link + // rather than a perpetual "Sending…" bubble. + Promise.resolve( + sendRef.current({ + action: "message", + args: { + content: clonePrompt, + timestamp: new Date().toISOString(), + }, + }), + ).catch((error) => { + if (!pendingId) return; + const errorMessage = + error instanceof Error + ? error.message + : t(I18nKey.CHAT_INTERFACE$FAILED_TO_SEND_MESSAGE); + markPendingMessageError(pendingId, errorMessage); + }); + }, + }, + ); + }; + + // Local backends never use the remote-repo "Connect Repo" CTA, so suppress the + // empty-state button there. A repo or workspace label inferred from local git + // metadata is still informational and stays visible. + const showRepoButton = + !isLocalBackend || !!selectedRepository || !!workspaceName; + // On a local backend the informational pill (e.g. workspace name, or a repo + // detected without a recognized provider) should not open the remote-repo + // modal — that flow is cloud-only. Disable the button in that case so the + // click is a no-op. Linkable repos render as and ignore `disabled`. + const isRepoButtonInert = isLocalBackend && !hasRepository; + + // True when the bar will render at least one chip (cloud always shows + // "Open Repository"; local needs a repo or a workspace name; selected + // branch or push/pull/PR also count). When false, the bar has nothing to + // show — return null so the wrapper above collapses to its natural padding + // instead of leaving an empty DOM node below the chat input. + const hasAnyContent = showRepoButton || !!selectedBranch || hasRepository; + if (!hasAnyContent) return null; + + return ( +
    +
    + {showRepoButton ? ( + setIsOpenRepoModalOpen(true)} + disabled={!isConversationReady || isRepoButtonInert} + /> + ) : null} + + {selectedBranch ? ( + + ) : null} + + {hasRepository ? ( + <> + + + + + + + + + + + + + ) : null} +
    + + setIsOpenRepoModalOpen(false)} + onLaunch={handleLaunchRepository} + defaultProvider={gitProvider} + /> +
    + ); +} diff --git a/src/components/features/chat/git-external-link-icon.tsx b/src/components/features/chat/git-external-link-icon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c6d4ed2ea378a45402c159e3698b019ed331af58 --- /dev/null +++ b/src/components/features/chat/git-external-link-icon.tsx @@ -0,0 +1,19 @@ +import LinkExternalIcon from "#/icons/link-external.svg?react"; +import { cn } from "#/utils/utils"; + +interface GitExternalLinkIconProps { + className?: string; +} + +export function GitExternalLinkIcon({ className }: GitExternalLinkIconProps) { + return ( +
    + +
    + ); +} diff --git a/src/components/features/chat/goal-status-banner.tsx b/src/components/features/chat/goal-status-banner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ce2c978cd4b7151c648ab0c9d5f497631530893 --- /dev/null +++ b/src/components/features/chat/goal-status-banner.tsx @@ -0,0 +1,24 @@ +import { useGoalStore } from "#/stores/goal-store"; +import { GoalStatusContent } from "./goal-status-content"; + +export interface GoalStatusBannerProps { + conversationId: string | null | undefined; +} + +/** + * Live, bottom-pinned banner for an *active* `/goal` loop, fed by goal + * ConversationStateUpdateEvents (see useGoalStore). Once the loop ends it hides: + * the final status is rendered inline in the message timeline instead (see + * event-message.tsx + should-render-event.ts), so the finished goal settles into + * the conversation and later messages append below it. + */ +export function GoalStatusBanner({ conversationId }: GoalStatusBannerProps) { + const statusByConversation = useGoalStore((s) => s.statusByConversation); + const status = conversationId + ? statusByConversation[conversationId] + : undefined; + + if (!status?.active) return null; + + return ; +} diff --git a/src/components/features/chat/goal-status-content.tsx b/src/components/features/chat/goal-status-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dea4701bc390bc1bc9e0c3244018aa2377657399 --- /dev/null +++ b/src/components/features/chat/goal-status-content.tsx @@ -0,0 +1,169 @@ +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import CheckCircle from "#/icons/check-circle-solid.svg?react"; +import XCircle from "#/icons/x-circle-solid.svg?react"; +import { I18nKey } from "#/i18n/declaration"; +import { GoalStatus } from "#/types/agent-server/core/events/conversation-state-event"; +import { useGoalStore } from "#/stores/goal-store"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { + pauseConversation, + resumeGoal, + stopGoal, +} from "#/hooks/mutation/conversation-mutation-utils"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { cn } from "#/utils/utils"; +import { + chatInputPillButtonClassName, + formControlDisabledClassName, +} from "#/utils/form-control-classes"; +import { GenericEventMessage } from "./generic-event-message"; + +const STATUS_LABEL_KEY: Record = { + running: I18nKey.GOAL$STATUS_RUNNING, + complete: I18nKey.GOAL$STATUS_COMPLETE, + capped: I18nKey.GOAL$STATUS_CAPPED, + interrupted: I18nKey.GOAL$STATUS_INTERRUPTED, +}; + +/** + * Goal-status row: objective, round count, status word, the judge's score, the + * judge's "missing" note (expandable), and an indicator — spinner while running, + * green check when complete, muted cross when it ends without completing + * (capped/interrupted). + * + * Also exposes the loop controls at the end of the row: a Stop button while the + * loop is active and a Resume button once it is interrupted. Stop both cancels + * the loop (`stopGoal`) and interrupts the conversation, because the backend's + * stop deliberately leaves the in-flight agent turn running. + * + * Used in two places: the live bottom banner (GoalStatusBanner) while a loop is + * active, and inline in the message timeline for the terminal status, so a + * finished `/goal` settles into the conversation. Because the inline copy mounts + * fresh once terminal, `initiallyExpanded={!active}` expands the note there + * without any re-mount trickery. + */ +export function GoalStatusContent({ status }: { status: GoalStatus }) { + const { t } = useTranslation("openhands"); + const { conversationId } = useOptionalConversationId(); + // Whether *some* goal loop is currently live for this conversation. Used to + // hide a stale inline Resume button once the user has resumed (or started a + // new goal): resuming again would just 409 on the backend. + const goalActive = useGoalStore((s) => + conversationId + ? Boolean(s.statusByConversation[conversationId]?.active) + : false, + ); + const [pending, setPending] = useState(false); + const { + active, + objective, + iteration, + max_iterations: maxIterations, + verdict, + } = status; + const scorePct = verdict ? Math.round(verdict.score * 100) : null; + const details = verdict?.missing + ? t(I18nKey.GOAL$MISSING, { missing: verdict.missing }) + : ""; + + const runAction = async ( + action: (id: string) => Promise, + failKey: I18nKey, + ) => { + if (!conversationId || pending) return; + setPending(true); + try { + await action(conversationId); + } catch (err) { + displayErrorToast( + err instanceof Error && err.message ? err.message : t(failKey), + ); + } finally { + setPending(false); + } + }; + + // stopGoal only cancels the loop; the backend leaves the current agent turn + // running, so interrupt the conversation too to actually halt it. + const handleStop = () => + runAction(async (id) => { + await stopGoal(id); + await pauseConversation(id); + }, I18nKey.GOAL$STOP_FAILED); + + const handleResume = () => runAction(resumeGoal, I18nKey.GOAL$RESUME_FAILED); + + let actionButton: ReactNode = null; + if (conversationId && active) { + actionButton = ( + + ); + } else if (conversationId && status.status === "interrupted" && !goalActive) { + actionButton = ( + + ); + } + + return ( +
    + + {t(I18nKey.GOAL$PREFIX)} + {objective} + + {t(I18nKey.GOAL$ROUND, { iteration, max: maxIterations })} + + {t(STATUS_LABEL_KEY[status.status])} + {scorePct !== null && ( + + {t(I18nKey.GOAL$SCORE, { score: scorePct })} + + )} + {active ? ( + + ) : status.status === "complete" ? ( + + + + ) : ( + + + + )} + + } + titleTrailing={actionButton} + details={details} + initiallyExpanded={!active} + chevronPosition="before" + /> +
    + ); +} diff --git a/src/components/features/chat/got-it-button.tsx b/src/components/features/chat/got-it-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..524ab6f1e6fca47985bb5c879a7636bdcd104f1a --- /dev/null +++ b/src/components/features/chat/got-it-button.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next"; +import CheckCircle from "#/icons/check-circle-solid.svg?react"; +import { I18nKey } from "#/i18n/declaration"; + +export function GotItButton({ onClick }: { onClick: () => void }) { + const { t } = useTranslation("openhands"); + return ( + + ); +} diff --git a/src/components/features/chat/interactive-chat-box.tsx b/src/components/features/chat/interactive-chat-box.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1fbff59f2bfa95f165784250b4c15a63c0d96852 --- /dev/null +++ b/src/components/features/chat/interactive-chat-box.tsx @@ -0,0 +1,90 @@ +import { CustomChatInput } from "./custom-chat-input"; +import { useBtwInterceptor } from "#/hooks/chat/use-btw-interceptor"; +import { useGoalInterceptor } from "#/hooks/chat/use-goal-interceptor"; +import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; +import { usePlanModeInterceptor } from "#/hooks/chat/use-plan-mode-interceptor"; +import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; +import { AgentState } from "#/types/agent-state"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { GitControlBar } from "./git-control-bar"; +import { useConversationStore } from "#/stores/conversation-store"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; +import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils"; +import { isTaskPolling } from "#/utils/utils"; + +interface InteractiveChatBoxProps { + onSubmit: (message: string, images: File[], files: File[]) => void; + disabled?: boolean; + hasStartedConversation?: boolean; +} + +export function InteractiveChatBox({ + onSubmit, + disabled = false, + hasStartedConversation, +}: InteractiveChatBoxProps) { + const { + images, + files, + imagesMarkedUploadAsFile, + clearAllFiles, + subConversationTaskId, + } = useConversationStore(); + const { curAgentState } = useAgentState(); + const { data: conversation } = useActiveConversation(); + const { conversationId: routeConversationId } = useOptionalConversationId(); + const conversationId = routeConversationId ?? conversation?.id ?? null; + + const { taskStatus: subConversationTaskStatus } = + useSubConversationTaskPolling( + subConversationTaskId, + conversation?.id || null, + ); + + const { handleUpload } = useChatAttachmentUpload(); + + const handleAfterGoal = useBtwInterceptor(conversationId, (message) => { + const { imagesToEmbed, imagesAsFiles } = partitionImagesForUpload( + images, + imagesMarkedUploadAsFile, + ); + onSubmit(message, imagesToEmbed, [...files, ...imagesAsFiles]); + clearAllFiles(); + }); + const handleAfterPlanMode = usePlanModeInterceptor( + conversationId, + curAgentState, + handleAfterGoal, + ); + const handleAfterModel = useGoalInterceptor( + conversationId, + handleAfterPlanMode, + ); + const handleSubmit = useModelInterceptor(conversationId, handleAfterModel); + + const handleSuggestionsClick = (suggestion: string) => { + handleSubmit(suggestion); + }; + + const isDisabled = + disabled || + curAgentState === AgentState.AWAITING_USER_CONFIRMATION || + isTaskPolling(subConversationTaskStatus); + + return ( +
    + +
    + +
    +
    + ); +} diff --git a/src/components/features/chat/is-in-event-group-context.ts b/src/components/features/chat/is-in-event-group-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..a737906cc6dc7ac59087b5f3937fe78d23ca9cc0 --- /dev/null +++ b/src/components/features/chat/is-in-event-group-context.ts @@ -0,0 +1,9 @@ +import { createContext } from "react"; + +/** + * True when the consuming event message is rendered inside an `EventGroup`'s + * expanded children. Components like `GenericEventMessage` use this to drop + * their own left bar so we don't render a redundant inner bar on top of the + * group's outer bar. + */ +export const IsInEventGroupContext = createContext(false); diff --git a/src/components/features/chat/model-messages.tsx b/src/components/features/chat/model-messages.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eddb6483d965b7dde6efdb839be6c7d7fd26b424 --- /dev/null +++ b/src/components/features/chat/model-messages.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { Trans } from "react-i18next"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useModelStore } from "#/stores/model-store"; +import { I18nKey } from "#/i18n/declaration"; +import InfoCircleIcon from "#/icons/info-circle.svg?react"; +import type { ProfileInfo } from "#/api/profiles-service/profiles-service.api"; +import { useFreeModels } from "#/hooks/query/use-free-models"; +import { formatModelNameForDisplay } from "#/utils/format-model-name"; +import { GenericEventMessage } from "./generic-event-message"; + +interface ProfileRowProps { + profile: ProfileInfo; +} + +function ProfileRow({ profile }: ProfileRowProps) { + const [expanded, setExpanded] = React.useState(false); + const freeModels = useFreeModels(); + const displayModel = formatModelNameForDisplay(profile.model, freeModels); + + return ( +
    + + {expanded && ( + <> +
    +
    + {`model: ${displayModel ?? "—"}\n` + + `base_url: ${profile.base_url ?? "—"}\n` + + // eslint-disable-next-line i18next/no-literal-string -- diagnostic readout; field labels are intentionally untranslated + `api_key: ${profile.api_key_set ? "set" : "not set"}`} +
    + + )} +
    + ); +} + +export interface ModelMessagesProps { + conversationId: string | null | undefined; + /** + * Render only entries anchored to this event id. Use `null` to render the + * "no events at the time of /model" entries (top of the chat history). + */ + anchorEventId: string | null; +} + +export function ModelMessages({ + conversationId, + anchorEventId, +}: ModelMessagesProps) { + const entriesById = useModelStore((s) => s.entriesByConversation); + const allEntries = conversationId ? (entriesById[conversationId] ?? []) : []; + const entries = allEntries.filter((e) => e.anchorEventId === anchorEventId); + + if (!conversationId || entries.length === 0) return null; + + return ( +
    + {entries.map((entry) => { + if (entry.switchedTo) { + return ( + + + + ), + }} + /> + + } + details="" + /> + ); + } + + const isEmpty = entry.profiles.length === 0; + return ( + + {isEmpty ? ( + + ) : ( + + )} + + } + details={ + isEmpty ? ( + + + + ) : ( +
    + {entry.profiles.map((p) => ( + + ))} +
    + ) + } + initiallyExpanded={isEmpty} + /> + ); + })} +
    + ); +} diff --git a/src/components/features/chat/mono-component.tsx b/src/components/features/chat/mono-component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a279e8554892346fd013637b2c1866893f0117d1 --- /dev/null +++ b/src/components/features/chat/mono-component.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from "react"; +import EventLogger from "#/utils/event-logger"; + +const decodeHtmlEntities = (text: string): string => { + const textarea = document.createElement("textarea"); + textarea.innerHTML = text; + return textarea.value; +}; + +function MonoComponent(props: { children?: ReactNode }) { + const { children } = props; + + const decodeString = (str: string): string => { + try { + return decodeHtmlEntities(str); + } catch (e) { + EventLogger.error(String(e)); + return str; + } + }; + + if (Array.isArray(children)) { + const processedChildren = children.map((child) => + typeof child === "string" ? decodeString(child) : child, + ); + + return {processedChildren}; + } + + if (typeof children === "string") { + return {decodeString(children)}; + } + + return {children}; +} + +export { MonoComponent }; diff --git a/src/components/features/chat/open-repository-modal.tsx b/src/components/features/chat/open-repository-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c2994e94f1cdfedb0ce3f54da34d44ee07de5038 --- /dev/null +++ b/src/components/features/chat/open-repository-modal.tsx @@ -0,0 +1,164 @@ +import React, { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { Provider } from "#/types/settings"; +import { Branch, GitRepository } from "#/types/git"; +import { GitRepoDropdown } from "#/components/features/home/git-repo-dropdown/git-repo-dropdown"; +import { GitBranchDropdown } from "#/components/features/home/git-branch-dropdown/git-branch-dropdown"; +import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown/git-provider-dropdown"; +import { useUserProviders } from "#/hooks/use-user-providers"; + +interface OpenRepositoryModalProps { + isOpen: boolean; + onClose: () => void; + onLaunch: (repository: GitRepository, branch: Branch) => void; + defaultProvider?: Provider; +} + +export function OpenRepositoryModal({ + isOpen, + onClose, + onLaunch, + defaultProvider = "github", +}: OpenRepositoryModalProps) { + const { t } = useTranslation("openhands"); + const { providers } = useUserProviders(); + + const [selectedProvider, setSelectedProvider] = useState( + null, + ); + const [selectedRepository, setSelectedRepository] = + useState(null); + const [selectedBranch, setSelectedBranch] = useState(null); + + // Auto-select provider: single provider auto-selects, multiple uses defaultProvider if available + useEffect(() => { + if (providers.length === 1 && !selectedProvider) { + setSelectedProvider(providers[0]); + } else if (providers.length > 1 && !selectedProvider && defaultProvider) { + if (providers.includes(defaultProvider)) { + setSelectedProvider(defaultProvider); + } + } + }, [providers, selectedProvider, defaultProvider]); + + const handleProviderChange = useCallback( + (provider: Provider | null) => { + if (provider === selectedProvider) return; + setSelectedProvider(provider); + setSelectedRepository(null); + setSelectedBranch(null); + }, + [selectedProvider], + ); + + const handleRepositoryChange = useCallback((repository?: GitRepository) => { + if (repository) { + setSelectedRepository(repository); + setSelectedBranch(null); + } else { + setSelectedRepository(null); + setSelectedBranch(null); + } + }, []); + + const handleBranchSelect = useCallback((branch: Branch | null) => { + setSelectedBranch(branch); + }, []); + + const handleLaunch = () => { + if (!selectedRepository || !selectedBranch) return; + + onLaunch(selectedRepository, selectedBranch); + setSelectedRepository(null); + setSelectedBranch(null); + onClose(); + }; + + const handleClose = () => { + setSelectedProvider(null); + setSelectedRepository(null); + setSelectedBranch(null); + onClose(); + }; + + if (!isOpen) return null; + + const activeProvider = + selectedRepository?.git_provider || selectedProvider || defaultProvider; + const canLaunch = !!selectedRepository && !!selectedBranch; + + return ( + + + +
    + +
    + +
    +
    + + {t(I18nKey.CONVERSATION$SELECT_OR_INSERT_LINK)} + + {providers.length > 1 && ( + + )} +
    + +
    + + + +
    +
    + +
    event.stopPropagation()} + > + + {t(I18nKey.BUTTON$CANCEL)} + + + {t(I18nKey.BUTTON$LAUNCH)} + +
    +
    +
    + ); +} diff --git a/src/components/features/chat/pasted-image-upload-as-file-button.tsx b/src/components/features/chat/pasted-image-upload-as-file-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1ad77260d8750e13cdedaf74944114dc7f2bac09 --- /dev/null +++ b/src/components/features/chat/pasted-image-upload-as-file-button.tsx @@ -0,0 +1,51 @@ +import { Check, FilePlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; + +interface PastedImageUploadAsFileButtonProps { + active: boolean; + onToggle: () => void; +} + +export function PastedImageUploadAsFileButton({ + active, + onToggle, +}: PastedImageUploadAsFileButtonProps) { + const { t } = useTranslation("openhands"); + const uploadLabel = t(I18nKey.CHAT_INTERFACE$UPLOAD_IMAGES_AS_FILES); + const doNotUploadLabel = t(I18nKey.CHAT_INTERFACE$DO_NOT_UPLOAD_AS_FILE); + const label = active ? doNotUploadLabel : uploadLabel; + + return ( +
    + + + +
    + ); +} diff --git a/src/components/features/chat/path-component.tsx b/src/components/features/chat/path-component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06bb2d0ef203c9b72f6cdc584564954410873b8f --- /dev/null +++ b/src/components/features/chat/path-component.tsx @@ -0,0 +1,130 @@ +import { createContext, ReactNode, useContext } from "react"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { openWorkspaceFile } from "#/services/canvas-ui"; +import EventLogger from "#/utils/event-logger"; + +/** + * When false, PathComponent renders a non-interactive span. EventGroup's + * header wraps titles in a toggle + ); + } catch (e) { + EventLogger.error(String(e)); + return ( + {path} + ); + } + }; + + if (Array.isArray(children)) { + const processedChildren = children.map((child, index) => + typeof child === "string" ? ( + {processPath(child)} + ) : ( + child + ), + ); + + return ( + {processedChildren} + ); + } + + if (typeof children === "string") { + return ( + + {processPath(children)} + + ); + } + + return ( + {children} + ); +} + +export { PathComponent, isLikelyDirectory }; diff --git a/src/components/features/chat/pending-stop-icon.tsx b/src/components/features/chat/pending-stop-icon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce8f50a9251d877e765ed4a3dd7660d701199c04 --- /dev/null +++ b/src/components/features/chat/pending-stop-icon.tsx @@ -0,0 +1,26 @@ +export function PendingStopIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/src/components/features/chat/pending-user-messages.tsx b/src/components/features/chat/pending-user-messages.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e612fcb4d2dd81e35c3d3920a9288fbb955d5aaa --- /dev/null +++ b/src/components/features/chat/pending-user-messages.tsx @@ -0,0 +1,137 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { useConversationStore } from "#/stores/conversation-store"; +import { useSendMessage } from "#/hooks/use-send-message"; +import { createChatMessage } from "#/services/chat-service"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { matchesPendingConversationId } from "#/utils/pending-task-message-link"; +import { ImageCarousel } from "#/components/features/images/image-carousel"; +import { ChatMessage } from "./chat-message"; + +/** + * Renders the queue of locally-tracked user messages that have been submitted + * but not yet echoed back through the WebSocket. Each message shows a faded + * "sending" treatment until the server echoes a real `UserMessageEvent` + * (which removes it via `consumeMatchingPendingMessage`). If the API rejects the + * send, the message switches to an "error" state with a retry button. + * + * The queue is global but each entry is tagged with the conversation id it + * was enqueued from; this component filters to only entries belonging to the + * active conversation, so switching conversations never carries pending + * bubbles over. + */ +export function PendingUserMessages() { + const { t } = useTranslation("openhands"); + const { conversationId } = useOptionalConversationId(); + const pendingMessages = useOptimisticUserMessageStore( + (state) => state.pendingMessages, + ); + const markPendingMessageError = useOptimisticUserMessageStore( + (state) => state.markPendingMessageError, + ); + const markPendingMessageSending = useOptimisticUserMessageStore( + (state) => state.markPendingMessageSending, + ); + const removePendingMessage = useOptimisticUserMessageStore( + (state) => state.removePendingMessage, + ); + const restoreMessageToInputIfEmpty = useConversationStore( + (state) => state.restoreMessageToInputIfEmpty, + ); + const { send } = useSendMessage(); + + const visibleMessages = React.useMemo( + () => + conversationId + ? pendingMessages.filter((message) => + matchesPendingConversationId( + conversationId, + message.conversationId, + ), + ) + : [], + [pendingMessages, conversationId], + ); + + const handleRetry = React.useCallback( + async (id: string) => { + const message = useOptimisticUserMessageStore + .getState() + .pendingMessages.find((entry) => entry.id === id); + if (!message) return; + + markPendingMessageSending(id); + + try { + await send( + createChatMessage( + message.text, + message.imageUrls, + message.fileUrls, + message.timestamp, + ), + ); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : t(I18nKey.CHAT_INTERFACE$FAILED_TO_SEND_MESSAGE); + markPendingMessageError(id, errorMessage); + } + }, + [send, markPendingMessageError, markPendingMessageSending, t], + ); + + const handleStop = React.useCallback( + (id: string, text: string) => { + restoreMessageToInputIfEmpty(text); + removePendingMessage(id); + }, + [restoreMessageToInputIfEmpty, removePendingMessage], + ); + + const handleDismiss = React.useCallback( + (id: string) => { + removePendingMessage(id); + }, + [removePendingMessage], + ); + + if (visibleMessages.length === 0) { + return null; + } + + return ( + <> + {visibleMessages.map((message) => ( + handleRetry(message.id) + : undefined + } + onDismiss={ + message.status === "error" + ? () => handleDismiss(message.id) + : undefined + } + onStop={ + message.status === "sending" + ? () => handleStop(message.id, message.text) + : undefined + } + > + {message.imageUrls.length > 0 && ( + + )} + + ))} + + ); +} diff --git a/src/components/features/chat/plan-preview.tsx b/src/components/features/chat/plan-preview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1dd63a7beaa10de3d22661c243b5aac405e0ebe5 --- /dev/null +++ b/src/components/features/chat/plan-preview.tsx @@ -0,0 +1,142 @@ +import { useMemo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowUpRight } from "lucide-react"; +import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; +import { Typography } from "#/ui/typography"; +import { I18nKey } from "#/i18n/declaration"; +import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; +import { useHandleBuildPlanClick } from "#/hooks/use-handle-build-plan-click"; +import { cn } from "#/utils/utils"; +import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab"; +import { + planComponents, + createPlanComponents, +} from "#/components/features/markdown/plan-components"; +import { useScrollContext } from "#/context/scroll-context"; + +const MAX_CONTENT_LENGTH = 300; + +// Shine effect class for streaming text +const SHINE_TEXT_CLASS = "shine-text"; + +// Plan components with shine effect applied for streaming state +const shineComponents = createPlanComponents(SHINE_TEXT_CLASS); + +interface PlanPreviewProps { + /** Raw plan content from PLAN.md file */ + planContent?: string | null; + /** Whether the plan content is actively being streamed */ + isStreaming?: boolean; + /** Whether the Build button should be disabled (e.g., while streaming) */ + isBuildDisabled?: boolean; +} + +/* eslint-disable i18next/no-literal-string */ +export function PlanPreview({ + planContent, + isStreaming, + isBuildDisabled, +}: PlanPreviewProps) { + const { t } = useTranslation("openhands"); + const { navigateToTab } = useSelectConversationTab(); + const { handleBuildPlanClick } = useHandleBuildPlanClick(); + const { scrollDomToBottom } = useScrollContext(); + + const handleViewClick = () => { + navigateToTab("planner"); + }; + + // Handle Build action with scroll to bottom + const handleBuildClick = useCallback( + (event?: React.MouseEvent) => { + handleBuildPlanClick(event); + scrollDomToBottom(); + }, + [handleBuildPlanClick, scrollDomToBottom], + ); + + // Truncate plan content for preview + const truncatedContent = useMemo(() => { + if (!planContent) return ""; + if (planContent.length <= MAX_CONTENT_LENGTH) return planContent; + return `${planContent.slice(0, MAX_CONTENT_LENGTH)}...`; + }, [planContent]); + + if (!planContent) { + return null; + } + + return ( +
    + {/* Header */} +
    + + + {t(I18nKey.COMMON$PLAN_MD)} + +
    + +
    + + {/* Content */} +
    + {truncatedContent && ( + <> + + {truncatedContent} + + {planContent && planContent.length > MAX_CONTENT_LENGTH && ( + + )} + + )} +
    + + {/* Footer */} +
    + +
    +
    + ); +} diff --git a/src/components/features/chat/remove-file-button.tsx b/src/components/features/chat/remove-file-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf5c8d7969762b05731ee375c66dfef5513ea65e --- /dev/null +++ b/src/components/features/chat/remove-file-button.tsx @@ -0,0 +1,23 @@ +import CloseIcon from "#/icons/u-close.svg?react"; +import { cn, isMobileDevice } from "#/utils/utils"; + +interface RemoveFileButtonProps { + onClick: () => void; +} + +export function RemoveFileButton({ onClick }: RemoveFileButtonProps) { + const isMobile = isMobileDevice(); + + return ( + + ); +} diff --git a/src/components/features/chat/skill-install-restart-banner.tsx b/src/components/features/chat/skill-install-restart-banner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..30243ec432f6d7e08dcd910f55a463709df09b49 --- /dev/null +++ b/src/components/features/chat/skill-install-restart-banner.tsx @@ -0,0 +1,102 @@ +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import { I18nKey } from "#/i18n/declaration"; +import { useSkillInstalls } from "#/hooks/use-skill-installs"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; +import { useNavigation } from "#/context/navigation-context"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; + +export interface SkillInstallRestartBannerProps { + conversationId: string | null | undefined; +} + +/** + * Pinned notice shown after the agent installs a skill via the bundled + * add-skill flow. The SDK loads skills once at conversation start, so a + * chat-installed skill can't activate in the running conversation — this + * banner says so and offers one action: start a new conversation in the + * directory the skill was installed to. + */ +export function SkillInstallRestartBanner({ + conversationId, +}: SkillInstallRestartBannerProps) { + const { t } = useTranslation("openhands"); + const { installs, dismissAll } = useSkillInstalls(conversationId); + const { backend } = useActiveBackend(); + const { mutate: createConversation, isPending } = useCreateConversation(); + const isCreatingElsewhere = useIsCreatingConversation(); + const { navigate } = useNavigation(); + const { data: conversation } = useActiveConversation(); + + // Local-only: the cloud create path ignores workingDir entirely. + if (backend.kind !== "local" || installs.length === 0) return null; + + const latest = installs[installs.length - 1]; + const skillNames = installs + .filter((install) => install.workspacePath === latest.workspacePath) + .map((install) => install.skillName); + + const handleRestart = () => { + if (isPending || isCreatingElsewhere) return; + createConversation( + { + // The agent's real install root, parsed from the fetch_skill.py + // success line — correct even for worktree conversations where + // selected_workspace would point elsewhere. + workingDir: latest.workspacePath, + // new_worktree would start from a fresh worktree without the + // freshly installed .agents/skills/ directory. + workspaceMode: "local_repo", + repository: + conversation?.selected_repository && conversation?.git_provider + ? { + name: conversation.selected_repository, + gitProvider: conversation.git_provider, + branch: conversation.selected_branch ?? undefined, + } + : undefined, + entryPoint: "skill_install_restart_banner", + }, + { + onSuccess: (data) => navigate(`/conversations/${data.conversation_id}`), + onError: (error) => displayErrorToast(error.message), + }, + ); + }; + + return ( +
    +
    +

    + {t(I18nKey.SKILLS$INSTALL_BANNER_MESSAGE, { + skills: skillNames.join(", "), + })} +

    + +
    + +
    + ); +} diff --git a/src/components/features/chat/success-indicator.tsx b/src/components/features/chat/success-indicator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f986e2fbc7c61f2c0f8fba49674866aa727dc39c --- /dev/null +++ b/src/components/features/chat/success-indicator.tsx @@ -0,0 +1,19 @@ +import { FaClock } from "react-icons/fa"; +import { ObservationResultStatus } from "#/components/conversation-events/chat/event-content-helpers/get-observation-result"; + +interface SuccessIndicatorProps { + status: ObservationResultStatus; +} + +export function SuccessIndicator({ status }: SuccessIndicatorProps) { + return ( + + {status === "timeout" && ( + + )} + + ); +} diff --git a/src/components/features/chat/typing-indicator.tsx b/src/components/features/chat/typing-indicator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8aaf0486df9611ba69f92576f27049d953b669d1 --- /dev/null +++ b/src/components/features/chat/typing-indicator.tsx @@ -0,0 +1,161 @@ +import { Trans } from "react-i18next"; +import type { OHEvent } from "#/stores/use-event-store"; +import type { UserRejectObservation } from "#/types/agent-server/core"; +import { + isACPToolCallEvent, + isActionEvent, + isAgentErrorEvent, + isObservationEvent, +} from "#/types/agent-server/type-guards"; +import { + getACPToolCallTitleKey, + stripRedundantTitlePrefix, +} from "#/components/conversation-events/chat/event-content-helpers/get-acp-tool-call-content"; +import { + getActionEventTitleDescriptor, + getActionSummaryTitle, + type EventTitleDescriptor, +} from "#/components/conversation-events/chat/event-content-helpers/get-action-event-title"; +import { MonoComponent } from "./mono-component"; +import { PathComponent } from "./path-component"; + +const THINKING_ACTIVITY: EventTitleDescriptor = { + kind: "translation", + key: "ACTION_MESSAGE$THINK", + values: {}, +}; + +const LIVE_ACTION_KINDS = new Set([ + "ExecuteBashAction", + "TerminalAction", + "FileEditorAction", + "StrReplaceEditorAction", + "MCPToolAction", + "InvokeSkillAction", + "TaskAction", + "ThinkAction", + "TaskTrackerAction", + "GrepAction", + "GlobAction", + "BrowserNavigateAction", + "BrowserClickAction", + "BrowserTypeAction", + "BrowserGetStateAction", + "BrowserGetContentAction", + "BrowserScrollAction", + "BrowserGoBackAction", + "BrowserListTabsAction", + "BrowserSwitchTabAction", + "BrowserCloseTabAction", +]); + +const getLiveActionTitle = (event: OHEvent): EventTitleDescriptor | null => { + if (!isActionEvent(event)) return null; + + const summary = getActionSummaryTitle(event); + if (summary) { + return { kind: "text", text: summary }; + } + + return LIVE_ACTION_KINDS.has(event.action.kind) + ? getActionEventTitleDescriptor(event) + : null; +}; + +const isUserRejectObservation = ( + event: OHEvent, +): event is UserRejectObservation => + event.source === "environment" && "rejection_reason" in event; + +export const deriveLiveActivity = ( + events: readonly OHEvent[], +): EventTitleDescriptor => { + const resolvedActionIds = new Set(); + const resolvedToolCallIds = new Set(); + + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.isFromPlanningAgent) { + continue; + } + + if (isObservationEvent(event) || isUserRejectObservation(event)) { + resolvedActionIds.add(event.action_id); + resolvedToolCallIds.add(event.tool_call_id); + continue; + } + + if (isAgentErrorEvent(event)) { + resolvedToolCallIds.add(event.tool_call_id); + continue; + } + + if (isACPToolCallEvent(event)) { + const isInProgress = + event.status === "pending" || event.status === "in_progress"; + if (!isInProgress) { + resolvedToolCallIds.add(event.tool_call_id); + continue; + } + if (resolvedToolCallIds.has(event.tool_call_id)) { + continue; + } + + const title = stripRedundantTitlePrefix(event); + return title + ? { + kind: "translation", + key: getACPToolCallTitleKey(event), + values: { title }, + } + : THINKING_ACTIVITY; + } + + if ( + isActionEvent(event) && + !resolvedActionIds.has(event.id) && + !resolvedToolCallIds.has(event.tool_call_id) + ) { + return getLiveActionTitle(event) ?? THINKING_ACTIVITY; + } + } + + return THINKING_ACTIVITY; +}; + +interface TypingIndicatorProps { + readonly events: readonly OHEvent[]; +} + +export function TypingIndicator({ events }: TypingIndicatorProps) { + const activity = deriveLiveActivity(events); + + return ( +
    +
    + ); +} diff --git a/src/components/features/chat/uploaded-file.tsx b/src/components/features/chat/uploaded-file.tsx new file mode 100644 index 0000000000000000000000000000000000000000..59d8930001782ee491bef20ee223bbdf6cb2d975 --- /dev/null +++ b/src/components/features/chat/uploaded-file.tsx @@ -0,0 +1,47 @@ +import { LoaderCircle } from "lucide-react"; +import FileIcon from "#/icons/file.svg?react"; +import { RemoveFileButton } from "./remove-file-button"; +import { cn, getFileExtension } from "#/utils/utils"; + +interface UploadedFileProps { + file: File; + onRemove: () => void; + isLoading?: boolean; +} + +export function UploadedFile({ + file, + onRemove, + isLoading = false, +}: UploadedFileProps) { + const fileExtension = getFileExtension(file.name); + + return ( +
    +
    + +
    + + {file.name} + +
    +
    + + + {fileExtension} + +
    +
    + {isLoading && ( +
    + +
    + )} +
    + ); +} diff --git a/src/components/features/chat/uploaded-files.tsx b/src/components/features/chat/uploaded-files.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ebfd2ff7b013c719baf546a3824a7577756288c2 --- /dev/null +++ b/src/components/features/chat/uploaded-files.tsx @@ -0,0 +1,94 @@ +import { UploadedFile } from "./uploaded-file"; +import { UploadedImage } from "./uploaded-image"; +import { useConversationStore } from "#/stores/conversation-store"; + +export function UploadedFiles() { + const { + images, + files, + loadingFiles, + loadingImages, + imagesMarkedUploadAsFile, + removeFile, + removeImage, + toggleImageUploadAsFile, + } = useConversationStore(); + + const handleRemoveFile = (index: number) => { + removeFile(index); + }; + + const handleRemoveImage = (index: number) => { + removeImage(index); + }; + + // Don't render anything if there are no files, images, or loading items + if ( + images.length === 0 && + files.length === 0 && + loadingFiles.length === 0 && + loadingImages.length === 0 + ) { + return null; + } + + return ( +
    +
    + {/* Regular files */} + {files.map((file, index) => ( + handleRemoveFile(index)} + isLoading={loadingFiles.includes(file.name)} + /> + ))} + + {/* Loading files (files currently being processed) */} + {loadingFiles.map((fileName, index) => { + // Create a temporary File object for display purposes + const tempFile = new File([], fileName); + return ( + {}} // No remove action during loading + isLoading + /> + ); + })} + + {/* Regular images */} + {images.map((image, index) => ( + handleRemoveImage(index)} + isLoading={loadingImages.includes(image.name)} + showUploadAsFileToggle + uploadAsFileActive={imagesMarkedUploadAsFile.includes(image.name)} + onToggleUploadAsFile={() => toggleImageUploadAsFile(image.name)} + /> + ))} + + {/* Loading images (images currently being processed) */} + {loadingImages.map((imageName, index) => { + // Create a temporary File object for display purposes + const tempImage = new File([], imageName); + return ( + {}} // No remove action during loading + isLoading + showUploadAsFileToggle + uploadAsFileActive={imagesMarkedUploadAsFile.includes(imageName)} + onToggleUploadAsFile={() => toggleImageUploadAsFile(imageName)} + /> + ); + })} +
    +
    + ); +} diff --git a/src/components/features/chat/uploaded-image.tsx b/src/components/features/chat/uploaded-image.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8601a5342e72fe795ac59f9a3f5eba889aa555d6 --- /dev/null +++ b/src/components/features/chat/uploaded-image.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { LoaderCircle } from "lucide-react"; +import { PastedImageUploadAsFileButton } from "./pasted-image-upload-as-file-button"; +import { RemoveFileButton } from "./remove-file-button"; + +interface UploadedImageProps { + image: File; + onRemove: () => void; + isLoading?: boolean; + showUploadAsFileToggle?: boolean; + uploadAsFileActive?: boolean; + onToggleUploadAsFile?: () => void; +} + +export function UploadedImage({ + image, + onRemove, + isLoading = false, + showUploadAsFileToggle = false, + uploadAsFileActive = false, + onToggleUploadAsFile, +}: UploadedImageProps) { + const [imageUrl, setImageUrl] = React.useState(""); + + React.useEffect(() => { + // Create object URL for image preview + const url = URL.createObjectURL(image); + setImageUrl(url); + + // Cleanup function to revoke object URL + return () => { + URL.revokeObjectURL(url); + }; + }, [image]); + + return ( +
    + {isLoading ? ( + + ) : ( + imageUrl && ( + {image.name} + ) + )} + + {showUploadAsFileToggle && onToggleUploadAsFile && ( + + )} +
    + ); +} diff --git a/src/components/features/chat/user-message-body.tsx b/src/components/features/chat/user-message-body.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29f023e34dc02356a4517fa8ab2e2a65d373f531 --- /dev/null +++ b/src/components/features/chat/user-message-body.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; +import { MarkdownRenderer } from "../markdown/markdown-renderer"; +import { ChatAnchor, ChatCode, ChatStrong } from "./chat-markdown-path-code"; + +const USER_MESSAGE_MAX_LINES = 5; +const USER_MESSAGE_LENGTH_THRESHOLD = 360; +export const USER_MESSAGE_LINE_HEIGHT_PX = 24; + +export const chatBubbleMarkdownComponents = { + p: ({ children }: React.ComponentProps<"p">) => ( +

    {children}

    + ), + code: ChatCode, + a: ChatAnchor, + strong: ChatStrong, +}; + +export function UserMessageBody({ + message, + isHovering, + isExpanded, + onTruncatableChange, +}: { + message: string; + isHovering: boolean; + isExpanded: boolean; + onTruncatableChange: (truncatable: boolean) => void; +}) { + const { t } = useTranslation("openhands"); + const contentRef = React.useRef(null); + const [isTruncatable, setIsTruncatable] = React.useState(false); + + React.useEffect(() => { + onTruncatableChange(isTruncatable); + }, [isTruncatable, onTruncatableChange]); + + React.useLayoutEffect(() => { + const content = contentRef.current; + if (!content || isExpanded) { + setIsTruncatable(false); + return undefined; + } + + const measure = () => { + const lineHeight = Number.parseFloat( + getComputedStyle(content).lineHeight, + ); + const linePx = + Number.isFinite(lineHeight) && lineHeight > 0 + ? lineHeight + : USER_MESSAGE_LINE_HEIGHT_PX; + const maxHeight = USER_MESSAGE_MAX_LINES * linePx; + const newlineCount = (message.match(/\n/g) ?? []).length; + + const truncatable = + content.scrollHeight > maxHeight + 1 || + newlineCount >= USER_MESSAGE_MAX_LINES || + message.trim().length > USER_MESSAGE_LENGTH_THRESHOLD; + + setIsTruncatable(truncatable); + }; + + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(content); + + return () => observer.disconnect(); + }, [message, isExpanded]); + + const isCollapsed = isTruncatable && !isExpanded; + + return ( +
    +
    + + {message} + +
    + + {isCollapsed ? ( + <> +
    + + {t(I18nKey.COMMON$VIEW_MORE)} + + + ) : null} +
    + ); +} diff --git a/src/components/features/chat/waiting-for-runtime-message.tsx b/src/components/features/chat/waiting-for-runtime-message.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a00933b3d20f88fb8d9fb6254abe97ec1da6c57b --- /dev/null +++ b/src/components/features/chat/waiting-for-runtime-message.tsx @@ -0,0 +1,13 @@ +import { RuntimeWaitingState } from "#/components/features/conversation-panel/runtime-waiting-state"; + +interface WaitingForRuntimeMessageProps { + className?: string; + testId?: string; +} + +export function WaitingForRuntimeMessage({ + className, + testId, +}: WaitingForRuntimeMessageProps) { + return ; +} diff --git a/src/components/features/chat/workspace-mode-icon.tsx b/src/components/features/chat/workspace-mode-icon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..56bc9bc2f4aba25ae049316e581aa5c9cf57da88 --- /dev/null +++ b/src/components/features/chat/workspace-mode-icon.tsx @@ -0,0 +1,11 @@ +import { GitBranch } from "lucide-react"; +import type { WorkspaceMode } from "#/api/conversation-metadata-store"; +import RepoForkedIcon from "#/icons/repo-forked.svg?react"; + +export function WorkspaceModeIcon({ mode }: { mode: WorkspaceMode }) { + if (mode === "new_worktree") { + return ; + } + + return ; +} diff --git a/src/components/features/chat/workspace-mode-selector.tsx b/src/components/features/chat/workspace-mode-selector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..694ccf1cc29ca9077d0ca577d3927645aca571c8 --- /dev/null +++ b/src/components/features/chat/workspace-mode-selector.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown } from "lucide-react"; +import type { BackendKind } from "#/api/backend-registry/types"; +import type { WorkspaceMode } from "#/api/conversation-metadata-store"; +import { getWorkspaceModeI18nKey } from "#/utils/workspace-mode"; +import { cn } from "#/utils/utils"; +import { + dropdownMenuListClassName, + dropdownMenuRowClassName, + dropdownMenuRowIconWrapperClassName, +} from "#/utils/dropdown-classes"; +import { WorkspaceModeIcon } from "./workspace-mode-icon"; + +interface WorkspaceModeSelectorProps { + value: WorkspaceMode; + backendKind: BackendKind; + onChange: (value: WorkspaceMode) => void; + disabled?: boolean; + testId?: string; +} + +const WORKSPACE_MODE_OPTIONS: WorkspaceMode[] = ["local_repo", "new_worktree"]; + +export function WorkspaceModeSelector({ + value, + backendKind, + onChange, + disabled = false, + testId = "workspace-mode-selector", +}: WorkspaceModeSelectorProps) { + const { t } = useTranslation("openhands"); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const label = t(getWorkspaceModeI18nKey(value, backendKind)); + + useEffect(() => { + if (!open) return undefined; + + const onMouseDown = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + }; + + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [open]); + + useEffect(() => { + if (!open) return undefined; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open]); + + return ( +
    + + + {open ? ( +
    + {WORKSPACE_MODE_OPTIONS.map((option) => { + const optionLabel = t(getWorkspaceModeI18nKey(option, backendKind)); + return ( + + ); + })} +
    + ) : null} +
    + ); +} diff --git a/src/components/features/context-menu/archived-disabled-tooltip.tsx b/src/components/features/context-menu/archived-disabled-tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5198ab6eee6dccd2f6b650781005827279c7473b --- /dev/null +++ b/src/components/features/context-menu/archived-disabled-tooltip.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { I18nKey } from "#/i18n/declaration"; + +interface ArchivedDisabledTooltipProps { + isDisabled: boolean; + children: React.ReactNode; + placement?: "top" | "bottom" | "left" | "right"; +} + +export function ArchivedDisabledTooltip({ + isDisabled, + children, + placement = "right", +}: ArchivedDisabledTooltipProps) { + const { t } = useTranslation("openhands"); + + if (!isDisabled) { + return children; + } + + return ( + + {children} + + ); +} diff --git a/src/components/features/context-menu/context-menu-icon-text-with-description.tsx b/src/components/features/context-menu/context-menu-icon-text-with-description.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5962ad5d538d8932a7afa122efd058f5c9460b0d --- /dev/null +++ b/src/components/features/context-menu/context-menu-icon-text-with-description.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { ContextMenuIconText } from "./context-menu-icon-text"; +import { Typography } from "#/ui/typography"; +import { cn } from "#/utils/utils"; + +interface ContextMenuIconTextWithDescriptionProps { + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; + className?: string; + iconClassName?: string; + isActive?: boolean; +} + +export function ContextMenuIconTextWithDescription({ + icon, + title, + description, + className, + iconClassName, + isActive = false, +}: ContextMenuIconTextWithDescriptionProps) { + return ( +
    + + + {description} + +
    + ); +} diff --git a/src/components/features/context-menu/context-menu-icon-text.tsx b/src/components/features/context-menu/context-menu-icon-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..defd1e665b1f5e169ff55c5ea10fdac6f4df592b --- /dev/null +++ b/src/components/features/context-menu/context-menu-icon-text.tsx @@ -0,0 +1,44 @@ +import { cn } from "#/utils/utils"; +import { + dropdownMenuRowGapClassName, + dropdownMenuRowIconWrapperClassName, +} from "#/utils/dropdown-classes"; +import CheckIcon from "#/icons/checkmark.svg?react"; + +interface ContextMenuIconTextProps { + icon: React.ComponentType<{ className?: string }>; + text: string; + className?: string; + iconClassName?: string; + isActive?: boolean; +} + +export function ContextMenuIconText({ + icon: Icon, + text, + className, + iconClassName, + isActive = false, +}: ContextMenuIconTextProps) { + return ( +
    + + {text} + {isActive && ( + + )} +
    + ); +} diff --git a/src/components/features/context-menu/context-menu-list-item.tsx b/src/components/features/context-menu/context-menu-list-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e70ed058c07b7df87a73cf2a027f09984eb1b8c --- /dev/null +++ b/src/components/features/context-menu/context-menu-list-item.tsx @@ -0,0 +1,36 @@ +import { cn } from "#/utils/utils"; +import { dropdownMenuRowForegroundClassName } from "#/utils/dropdown-classes"; + +interface ContextMenuListItemProps { + testId?: string; + onClick: (event: React.MouseEvent) => void; + isDisabled?: boolean; + className?: string; + ref?: React.Ref; +} + +export function ContextMenuListItem({ + children, + testId, + onClick, + isDisabled, + className, + ref, +}: React.PropsWithChildren) { + return ( + + ); +} diff --git a/src/components/features/controls/agent-loading.tsx b/src/components/features/controls/agent-loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3c0175d62d8067f182c086c54a00ecbce0ac3441 --- /dev/null +++ b/src/components/features/controls/agent-loading.tsx @@ -0,0 +1,9 @@ +import { LoaderCircle } from "lucide-react"; + +export function AgentLoading() { + return ( +
    + +
    + ); +} diff --git a/src/components/features/controls/agent-status.tsx b/src/components/features/controls/agent-status.tsx new file mode 100644 index 0000000000000000000000000000000000000000..241407d5ce6ec4b3f584269ad717a379a17338a7 --- /dev/null +++ b/src/components/features/controls/agent-status.tsx @@ -0,0 +1,178 @@ +import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import { CircleCheck } from "lucide-react"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { getStatusCode } from "#/utils/status"; +import { ChatStopButton } from "../chat/chat-stop-button"; +import { AgentState } from "#/types/agent-state"; +import ClockIcon from "#/icons/u-clock-three.svg?react"; +import { ChatResumeAgentButton } from "../chat/chat-play-button"; +import { cn, isTaskPolling } from "#/utils/utils"; +import { AgentLoading } from "./agent-loading"; +import { useConversationStore } from "#/stores/conversation-store"; +import CircleErrorIcon from "#/icons/circle-error.svg?react"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; +import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; +import { useAgentNotification } from "#/hooks/use-agent-notification"; +import { I18nKey } from "#/i18n/declaration"; + +export interface AgentStatusProps { + className?: string; + handleStop: () => void; + handleResumeAgent: () => void; + disabled?: boolean; + isPausing?: boolean; +} + +export function AgentStatus({ + className = "", + handleStop, + handleResumeAgent, + disabled = false, + isPausing = false, +}: AgentStatusProps) { + const { t } = useTranslation("openhands"); + const { setShouldShownAgentLoading } = useConversationStore(); + const { curAgentState, executionStatus } = useAgentState(); + + // Trigger browser tab flash and notification sound on state changes + useAgentNotification(curAgentState); + const webSocketStatus = useUnifiedWebSocketStatus(); + const { data: conversation } = useActiveConversation(); + const { taskStatus } = useTaskPolling(); + + const { subConversationTaskId } = useConversationStore(); + + // Poll sub-conversation task to track its loading state + const { taskStatus: subConversationTaskStatus } = + useSubConversationTaskPolling( + subConversationTaskId, + conversation?.id || null, + ); + + const statusCode = getStatusCode( + webSocketStatus, + executionStatus ?? null, + taskStatus, + subConversationTaskStatus, + ); + + const shouldShownAgentLoading = + curAgentState === AgentState.INIT || + curAgentState === AgentState.LOADING || + (webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") || + isTaskPolling(taskStatus) || + isTaskPolling(subConversationTaskStatus); + + // For UI rendering - includes pause state + const isLoading = shouldShownAgentLoading || isPausing; + + const shouldShownAgentError = + curAgentState === AgentState.ERROR || + curAgentState === AgentState.RATE_LIMITED || + webSocketStatus === "CLOSED" || + taskStatus === "ERROR"; + + const shouldShownAgentStop = + !shouldShownAgentError && curAgentState === AgentState.RUNNING; + + const shouldShownAgentResume = + !shouldShownAgentError && + (curAgentState === AgentState.STOPPED || + curAgentState === AgentState.PAUSED); + const isInteractive = + !isLoading && (shouldShownAgentStop || shouldShownAgentResume); + const isDoneStatus = + statusCode === I18nKey.CHAT_INTERFACE$AGENT_FINISHED_MESSAGE; + const isReadyStatus = statusCode === I18nKey.AGENT_STATUS$WAITING_FOR_TASK; + const isTransientCheckStatus = isDoneStatus || isReadyStatus; + const [shouldRenderDoneStatus, setShouldRenderDoneStatus] = useState(true); + const [shouldFadeDoneStatus, setShouldFadeDoneStatus] = useState(false); + + // Update global state when agent loading condition changes + useEffect(() => { + setShouldShownAgentLoading(!!shouldShownAgentLoading); + }, [shouldShownAgentLoading, setShouldShownAgentLoading]); + + useEffect(() => { + if (!isTransientCheckStatus) { + setShouldRenderDoneStatus(true); + setShouldFadeDoneStatus(false); + return; + } + + setShouldRenderDoneStatus(true); + setShouldFadeDoneStatus(false); + + const fadeTimer = window.setTimeout(() => { + setShouldFadeDoneStatus(true); + }, 1000); + + const hideTimer = window.setTimeout(() => { + setShouldRenderDoneStatus(false); + }, 1500); + + return () => { + window.clearTimeout(fadeTimer); + window.clearTimeout(hideTimer); + }; + }, [isTransientCheckStatus]); + + if (isTransientCheckStatus && !shouldRenderDoneStatus) { + return null; + } + + return ( +
    + + {t(statusCode)} + +
    + {isLoading && } + {!isLoading && shouldShownAgentStop && ( + + )} + {!isLoading && shouldShownAgentResume && ( + + )} + {!isLoading && shouldShownAgentError && ( + + )} + {!isLoading && + !shouldShownAgentStop && + !shouldShownAgentResume && + !shouldShownAgentError && + (isTransientCheckStatus ? ( + + ) : ( + + ))} +
    +
    + ); +} + +export default AgentStatus; diff --git a/src/components/features/controls/git-tools-submenu.test.tsx b/src/components/features/controls/git-tools-submenu.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df846f72dac6b6d8dc3306c061d1880a6c16ae4c --- /dev/null +++ b/src/components/features/controls/git-tools-submenu.test.tsx @@ -0,0 +1,156 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GitToolsSubmenu } from "./git-tools-submenu"; + +const mocks = vi.hoisted(() => ({ + setMessageToSend: vi.fn(), + useActiveConversation: vi.fn(), + useTranslation: vi.fn(() => ({ t: (key: string) => key })), + getGitPullPrompt: vi.fn(() => "pull prompt"), + getGitPushPrompt: vi.fn(() => "push prompt"), + getCreatePRPrompt: vi.fn(() => "pr prompt"), + getCreateNewBranchPrompt: vi.fn(() => "branch prompt"), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: mocks.useTranslation, +})); + +vi.mock("#/stores/conversation-store", () => ({ + useConversationStore: () => ({ setMessageToSend: mocks.setMessageToSend }), +})); + +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: mocks.useActiveConversation, +})); + +vi.mock("#/utils/utils", () => ({ + getGitPullPrompt: mocks.getGitPullPrompt, + getGitPushPrompt: mocks.getGitPushPrompt, + getCreatePRPrompt: mocks.getCreatePRPrompt, + getCreateNewBranchPrompt: mocks.getCreateNewBranchPrompt, +})); + +vi.mock("#/ui/context-menu", () => ({ + ContextMenu: ({ + children, + testId, + className, + }: { + children: React.ReactNode; + testId: string; + className: string; + }) => ( +
    + {children} +
    + ), +})); + +vi.mock("../context-menu/context-menu-list-item", () => ({ + ContextMenuListItem: ({ + children, + testId, + onClick, + className, + }: { + children: React.ReactNode; + testId: string; + onClick: () => void; + className: string; + }) => ( + + ), +})); + +vi.mock("./tools-context-menu-icon-text", () => ({ + ToolsContextMenuIconText: ({ text }: { text: string }) => {text}, +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.useActiveConversation.mockReturnValue({ + data: { git_provider: "github" }, + }); +}); + +describe("GitToolsSubmenu", () => { + it("renders every git action in the submenu", async () => { + vi.resetModules(); + const { GitToolsSubmenu: ReloadedGitToolsSubmenu } = + await import("./git-tools-submenu"); + render(); + + expect(mocks.useTranslation).toHaveBeenCalledWith("openhands"); + expect(screen.getByTestId("git-tools-submenu")).toHaveClass("w-max"); + expect(screen.getByTestId("git-pull-button")).toHaveTextContent( + "COMMON$GIT_PULL", + ); + expect(screen.getByTestId("git-push-button")).toHaveTextContent( + "COMMON$GIT_PUSH", + ); + expect(screen.getByTestId("create-pr-button")).toHaveTextContent( + "COMMON$CREATE_PR", + ); + expect(screen.getByTestId("create-new-branch-button")).toHaveTextContent( + "COMMON$CREATE_NEW_BRANCH", + ); + for (const button of screen.getAllByRole("button")) { + expect(button).toHaveClass("!w-auto", "whitespace-nowrap"); + } + }); + + it.each([ + ["git-pull-button", mocks.getGitPullPrompt, "pull prompt"], + ["git-push-button", mocks.getGitPushPrompt, "push prompt"], + ["create-pr-button", mocks.getCreatePRPrompt, "pr prompt"], + [ + "create-new-branch-button", + mocks.getCreateNewBranchPrompt, + "branch prompt", + ], + ])( + "builds and queues the %s action before closing", + (testId, prompt, text) => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId(testId)); + + expect(prompt).toHaveBeenCalledTimes(1); + expect(mocks.setMessageToSend).toHaveBeenCalledWith(text); + expect(mocks.setMessageToSend.mock.invocationCallOrder[0]).toBeLessThan( + onClose.mock.invocationCallOrder[0], + ); + expect(onClose).toHaveBeenCalledTimes(1); + }, + ); + + it("passes the active provider to provider-specific prompts", () => { + render(); + + fireEvent.click(screen.getByTestId("git-push-button")); + fireEvent.click(screen.getByTestId("create-pr-button")); + + expect(mocks.getGitPushPrompt).toHaveBeenCalledWith("github"); + expect(mocks.getCreatePRPrompt).toHaveBeenCalledWith("github"); + }); + + it("uses the provider-agnostic prompt fallback without an active conversation", () => { + mocks.useActiveConversation.mockReturnValue({ data: undefined }); + render(); + + fireEvent.click(screen.getByTestId("git-push-button")); + fireEvent.click(screen.getByTestId("create-pr-button")); + + expect(mocks.getGitPushPrompt).toHaveBeenCalledWith(undefined); + expect(mocks.getCreatePRPrompt).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/src/components/features/controls/git-tools-submenu.tsx b/src/components/features/controls/git-tools-submenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9404284b7d3ae763f3c3489d581ee6d1d6bf5bb8 --- /dev/null +++ b/src/components/features/controls/git-tools-submenu.tsx @@ -0,0 +1,101 @@ +import { useTranslation } from "react-i18next"; +import { ContextMenu } from "#/ui/context-menu"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { ToolsContextMenuIconText } from "./tools-context-menu-icon-text"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { Provider } from "#/types/settings"; +import { + getGitPullPrompt, + getGitPushPrompt, + getCreatePRPrompt, + getCreateNewBranchPrompt, +} from "#/utils/utils"; +import { useConversationStore } from "#/stores/conversation-store"; + +import ArrowUpIcon from "#/icons/u-arrow-up.svg?react"; +import ArrowDownIcon from "#/icons/u-arrow-down.svg?react"; +import PrIcon from "#/icons/u-pr.svg?react"; +import CodeBranchIcon from "#/icons/u-code-branch.svg?react"; +import { I18nKey } from "#/i18n/declaration"; + +const submenuListItemClassName = "!w-auto whitespace-nowrap"; + +interface GitToolsSubmenuProps { + onClose: () => void; +} + +export function GitToolsSubmenu({ onClose }: GitToolsSubmenuProps) { + const { t } = useTranslation("openhands"); + const { setMessageToSend } = useConversationStore(); + const { data: conversation } = useActiveConversation(); + + const currentGitProvider = conversation?.git_provider as Provider; + + const onGitPull = () => { + setMessageToSend(getGitPullPrompt()); + onClose(); + }; + + const onGitPush = () => { + setMessageToSend(getGitPushPrompt(currentGitProvider)); + onClose(); + }; + + const onCreatePR = () => { + setMessageToSend(getCreatePRPrompt(currentGitProvider)); + onClose(); + }; + + const onCreateNewBranch = () => { + setMessageToSend(getCreateNewBranchPrompt()); + onClose(); + }; + + return ( + + + } + text={t(I18nKey.COMMON$GIT_PULL)} + /> + + + + } + text={t(I18nKey.COMMON$GIT_PUSH)} + /> + + + + } + text={t(I18nKey.COMMON$CREATE_PR)} + /> + + + + } + text={t(I18nKey.COMMON$CREATE_NEW_BRANCH)} + /> + + + ); +} diff --git a/src/components/features/controls/macros-submenu.tsx b/src/components/features/controls/macros-submenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ce549cdac7f8f44c538cc01cc87ee1956fcabd6 --- /dev/null +++ b/src/components/features/controls/macros-submenu.tsx @@ -0,0 +1,88 @@ +import { useTranslation } from "react-i18next"; +import { ContextMenu } from "#/ui/context-menu"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { ToolsContextMenuIconText } from "./tools-context-menu-icon-text"; + +import TachometerFastIcon from "#/icons/tachometer-fast.svg?react"; +import PrStatusIcon from "#/icons/pr-status.svg?react"; +import DocumentIcon from "#/icons/document.svg?react"; +import WaterIcon from "#/icons/u-water.svg?react"; +import { I18nKey } from "#/i18n/declaration"; +import { useConversationStore } from "#/stores/conversation-store"; +import { REPO_SUGGESTIONS } from "#/utils/suggestions/repo-suggestions"; + +const submenuListItemClassName = "!w-auto whitespace-nowrap"; + +interface MacrosSubmenuProps { + onClose: () => void; +} + +export function MacrosSubmenu({ onClose }: MacrosSubmenuProps) { + const { t } = useTranslation("openhands"); + const { setMessageToSend } = useConversationStore(); + + const onIncreaseTestCoverage = () => { + setMessageToSend(REPO_SUGGESTIONS.INCREASE_TEST_COVERAGE); + onClose(); + }; + const onFixReadme = () => { + setMessageToSend(REPO_SUGGESTIONS.FIX_README); + onClose(); + }; + const onAutoMergePRs = () => { + setMessageToSend(REPO_SUGGESTIONS.AUTO_MERGE_PRS); + onClose(); + }; + const onCleanDependencies = () => { + setMessageToSend(REPO_SUGGESTIONS.CLEAN_DEPENDENCIES); + onClose(); + }; + + return ( + + + } + text={t(I18nKey.INCREASE_TEST_COVERAGE)} + /> + + + + } + text={t(I18nKey.FIX_README)} + /> + + + + } + text={t(I18nKey.AUTO_MERGE_PRS)} + /> + + + + } + text={t(I18nKey.CLEAN_DEPENDENCIES)} + /> + + + ); +} diff --git a/src/components/features/controls/server-status-context-menu-icon-text.tsx b/src/components/features/controls/server-status-context-menu-icon-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..de5407f35072b8915553c42834a3deb08ff56b52 --- /dev/null +++ b/src/components/features/controls/server-status-context-menu-icon-text.tsx @@ -0,0 +1,27 @@ +import { ContextMenuListItem } from "#/components/features/context-menu/context-menu-list-item"; +import { dropdownMenuRowIconWrapperClassName } from "#/utils/dropdown-classes"; + +interface ServerStatusContextMenuIconTextProps { + icon: React.ReactNode; + text: string; + onClick: (event: React.MouseEvent) => void; + testId?: string; +} + +export function ServerStatusContextMenuIconText({ + icon, + text, + onClick, + testId, +}: ServerStatusContextMenuIconTextProps) { + return ( + +
    + {text} + + {icon} + +
    +
    + ); +} diff --git a/src/components/features/controls/server-status-context-menu.tsx b/src/components/features/controls/server-status-context-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e0e582ad9d1cf10a4276c2f673652eb00bf970be --- /dev/null +++ b/src/components/features/controls/server-status-context-menu.tsx @@ -0,0 +1,86 @@ +import type { MouseEvent, RefObject } from "react"; +import { useTranslation } from "react-i18next"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { ContextMenu } from "#/ui/context-menu"; +import { I18nKey } from "#/i18n/declaration"; +import StopCircleIcon from "#/icons/stop-circle.svg?react"; +import PlayCircleIcon from "#/icons/play-circle.svg?react"; +import { ServerStatusContextMenuIconText } from "./server-status-context-menu-icon-text"; +import { ServerStatus } from "./server-status"; +import { Divider } from "#/ui/divider"; +import { cn } from "#/utils/utils"; +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; +import { isExecutionActive, isExecutionPaused } from "#/utils/status"; + +interface ServerStatusContextMenuProps { + onClose: () => void; + onStopServer?: (event: MouseEvent) => void; + onStartServer?: (event: MouseEvent) => void; + executionStatus: ExecutionStatus | null; + position?: "top" | "bottom"; + className?: string; + isPausing?: boolean; + ignoreOutsideClickRef?: RefObject; +} + +export function ServerStatusContextMenu({ + onClose, + onStopServer, + onStartServer, + executionStatus, + position = "top", + className = "", + isPausing = false, + ignoreOutsideClickRef, +}: ServerStatusContextMenuProps) { + const { t } = useTranslation("openhands"); + const ref = useClickOutsideElement( + onClose, + ignoreOutsideClickRef, + ); + + const isActive = isExecutionActive(executionStatus); + const isPaused = isExecutionPaused(executionStatus); + const shouldActionShown = isActive || isPaused; + + return ( + + + + {shouldActionShown && ( + <> + + + {isActive && onStopServer && ( + } + text={t(I18nKey.COMMON$STOP_RUNTIME)} + onClick={onStopServer} + testId="stop-server-button" + /> + )} + + {isPaused && onStartServer && ( + } + text={t(I18nKey.COMMON$START_RUNTIME)} + onClick={onStartServer} + testId="start-server-button" + /> + )} + + )} + + ); +} diff --git a/src/components/features/controls/server-status.tsx b/src/components/features/controls/server-status.tsx new file mode 100644 index 0000000000000000000000000000000000000000..833743e854f6da8806c3caea425dcd3c719556d3 --- /dev/null +++ b/src/components/features/controls/server-status.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from "react-i18next"; +import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react"; +import { AgentState } from "#/types/agent-state"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import { getStatusColor, getStatusText } from "#/utils/utils"; +import { useErrorMessageStore } from "#/stores/error-message-store"; +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; +import { isExecutionErrored, isExecutionPaused } from "#/utils/status"; + +export interface ServerStatusProps { + className?: string; + executionStatus: ExecutionStatus | null; + isPausing?: boolean; +} + +export function ServerStatus({ + className = "", + executionStatus, + isPausing = false, +}: ServerStatusProps) { + const { curAgentState } = useAgentState(); + const { isTask, taskStatus, taskDetail } = useTaskPolling(); + const { t } = useTranslation("openhands"); + const { errorMessage } = useErrorMessageStore(); + + const isStartingStatus = + curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT; + const isStopStatus = + isExecutionErrored(executionStatus) || isExecutionPaused(executionStatus); + + const statusColor = getStatusColor({ + isPausing, + isTask, + taskStatus, + isStartingStatus, + isStopStatus, + curAgentState, + }); + + const statusText = getStatusText({ + isPausing, + isTask, + taskStatus, + taskDetail, + isStartingStatus, + isStopStatus, + curAgentState, + errorMessage, + t, + }); + + return ( +
    +
    + + {statusText} +
    +
    + ); +} + +export default ServerStatus; diff --git a/src/components/features/controls/tools-context-menu-icon-text.tsx b/src/components/features/controls/tools-context-menu-icon-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da092ee75649bfcabcf106e5b92742f665071420 --- /dev/null +++ b/src/components/features/controls/tools-context-menu-icon-text.tsx @@ -0,0 +1,43 @@ +import { cn } from "#/utils/utils"; +import { + dropdownMenuRowGapClassName, + dropdownMenuRowIconWrapperClassName, +} from "#/utils/dropdown-classes"; + +interface ToolsContextMenuIconTextProps { + icon: React.ReactNode; + text: React.ReactNode; + rightIcon?: React.ReactNode; + className?: string; +} + +export function ToolsContextMenuIconText({ + icon, + text, + rightIcon, + className, +}: ToolsContextMenuIconTextProps) { + return ( +
    +
    + + {icon} + + {text} +
    + {rightIcon ? ( + + {rightIcon} + + ) : null} +
    + ); +} diff --git a/src/components/features/controls/tools-context-menu.tsx b/src/components/features/controls/tools-context-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e149ee6dbd8e7f41cb5793f28acba1273d61cef2 --- /dev/null +++ b/src/components/features/controls/tools-context-menu.tsx @@ -0,0 +1,293 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useUserProviders } from "#/hooks/use-user-providers"; +import { cn } from "#/utils/utils"; +import { ContextMenu } from "#/ui/context-menu"; +import { ContextMenuListItem } from "../context-menu/context-menu-list-item"; +import { Divider } from "#/ui/divider"; +import { I18nKey } from "#/i18n/declaration"; + +import CodeBranchIcon from "#/icons/u-code-branch.svg?react"; +import SkillsIcon from "#/icons/skills.svg?react"; +import PuzzleIcon from "#/icons/u-puzzle-piece.svg?react"; +import FishingHookIcon from "#/icons/fishing-hook.svg?react"; +import ToolsIcon from "#/icons/u-tools.svg?react"; +import RobotIcon from "#/icons/u-robot.svg?react"; +import SettingsIcon from "#/icons/settings.svg?react"; +import CarretRightFillIcon from "#/icons/carret-right-fill.svg?react"; +import { ToolsContextMenuIconText } from "./tools-context-menu-icon-text"; +import { GitToolsSubmenu } from "./git-tools-submenu"; +import { MacrosSubmenu } from "./macros-submenu"; +import { ChatInputProfileMenuContent } from "#/components/features/chat/components/chat-input-profile-picker"; +import { ArchivedDisabledTooltip } from "../context-menu/archived-disabled-tooltip"; +import { useIsArchivedConversation } from "#/hooks/use-is-archived-conversation"; + +interface ToolsContextMenuProps { + onClose: () => void; + onShowSkills: (event: React.MouseEvent) => void; + onShowPlugins: (event: React.MouseEvent) => void; + onShowHooks: (event: React.MouseEvent) => void; + onShowAgentTools: (event: React.MouseEvent) => void; + shouldShowAgentTools?: boolean; + shouldShowHooks?: boolean; + shouldShowPlugins?: boolean; + /** + * Offer the "Switch agent profile" submenu (OSS-5735). The caller owns the + * gating (pre-start only + profiles available) so this menu stays renderable + * without query/navigation providers when the item is off. + */ + showAgentProfileSwitch?: boolean; + /** When set, renders a divider and this action as the last menu item. */ + footerAction?: { + testId: string; + icon: React.ReactNode; + label: string; + onClick: () => void; + }; +} + +export function ToolsContextMenu({ + onClose, + onShowSkills, + onShowPlugins, + onShowHooks, + onShowAgentTools, + shouldShowAgentTools = true, + shouldShowHooks = false, + shouldShowPlugins = false, + showAgentProfileSwitch = false, + footerAction, +}: ToolsContextMenuProps) { + const { t } = useTranslation("openhands"); + const { data: conversation } = useActiveConversation(); + const { providers } = useUserProviders(); + const isArchivedConversation = useIsArchivedConversation(); + + const [activeSubmenu, setActiveSubmenu] = useState< + "git" | "macros" | "agent-profile" | null + >(null); + + const hasRepository = !!conversation?.selected_repository; + const providersAreSet = providers.length > 0; + const showGitTools = hasRepository && providersAreSet; + + const handleSubmenuClick = (submenu: "git" | "macros" | "agent-profile") => { + if (isArchivedConversation) { + return; + } + setActiveSubmenu(activeSubmenu === submenu ? null : submenu); + }; + + const handleClose = () => { + setActiveSubmenu(null); + onClose(); + }; + + const ref = useClickOutsideElement(handleClose); + + return ( + + {/* Switch agent profile — only while starting a new conversation; the + profile is locked once the conversation starts (OSS-5735). Selecting + a profile activates it (home) or recreates the blank conversation + with it (see ChatInputProfileMenuContent). No archived gating: this + never renders in a started (archivable) conversation. */} + {showAgentProfileSwitch && ( +
    + handleSubmenuClick("agent-profile")} + > + } + text={t(I18nKey.CHAT$SWITCH_AGENT_PROFILE)} + rightIcon={} + /> + +
    + {/* overflow-y-auto so a long profile list scrolls within the menu; + safe because the content has no floating children — only the + flat profile list + Manage link. */} + + + +
    +
    + )} + + {/* Git Tools */} + {showGitTools && ( +
    + + handleSubmenuClick("git")} + isDisabled={isArchivedConversation} + > + } + text={t(I18nKey.COMMON$GIT_TOOLS)} + rightIcon={} + /> + + + {!isArchivedConversation && ( +
    + +
    + )} +
    + )} + + {/* Macros */} +
    + + handleSubmenuClick("macros")} + isDisabled={isArchivedConversation} + > + } + text={t(I18nKey.COMMON$MACROS)} + rightIcon={} + /> + + + {!isArchivedConversation && ( +
    + +
    + )} +
    + + {shouldShowAgentTools && } + + + + + } + text={t(I18nKey.CONVERSATION$SHOW_SKILLS)} + /> + + + + {/* Show Plugins - only when this conversation has attached plugins */} + {shouldShowPlugins && ( + + + } + text={t(I18nKey.CONVERSATION$SHOW_PLUGINS)} + /> + + + )} + + {/* Show Hooks - Only show for V1 conversations */} + {shouldShowHooks && ( + + + } + text={t(I18nKey.CONVERSATION$SHOW_HOOKS)} + /> + + + )} + + {/* Show Agent Tools and Metadata - Only show if system message is available */} + {shouldShowAgentTools && ( + + + } + text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)} + /> + + + )} + + {footerAction && ( + <> + + { + event.preventDefault(); + event.stopPropagation(); + footerAction.onClick(); + handleClose(); + }} + > + + + + )} +
    + ); +} diff --git a/src/components/features/conversation-panel/advanced-conversation-options-modal.tsx b/src/components/features/conversation-panel/advanced-conversation-options-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c96b5f1743e3f10a412ca9cb8b3601f121a3b11c --- /dev/null +++ b/src/components/features/conversation-panel/advanced-conversation-options-modal.tsx @@ -0,0 +1,329 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { + Archive, + Bot, + CalendarArrowDown, + Clock3, + ClockArrowDown, + EyeOff, + Folder, + GitBranch, + MessageCircle, + MousePointerClick, + Star, + Tag, +} from "lucide-react"; +import AutomationsIcon from "#/icons/automations.svg?react"; +import { I18nKey } from "#/i18n/declaration"; +import type { BackendKind } from "#/api/backend-registry/types"; +import { useConversationPanelPreferencesStore } from "#/stores/conversation-panel-preferences-store"; +import { + BaseModalDescription, + BaseModalTitle, +} from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { + MODAL_MAX_WIDTH_VIEWPORT, + ModalBody, +} from "#/components/shared/modals/modal-body"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { cn } from "#/utils/utils"; +import { readVerticalScrollEdgeState } from "#/utils/scroll-fade-state"; +import { MenuHeading } from "./menu-heading"; +import { MenuSeparator } from "./menu-separator"; +import { MenuRow } from "./menu-row"; +import { HideOlderConversationsRow } from "./hide-older-conversations-row"; +import { UNNAMED_AUTOMATION_FACET } from "./conversation-panel-list-helpers"; + +export interface AdvancedConversationOptionsModalProps { + open: boolean; + onClose: () => void; + backendKind: BackendKind; + /** + * Distinct automation names among the loaded conversations. Rendered as + * selectable rows under the Automations section in `only-automations` + * mode — the only control for `selectedAutomationNames`, which is + * persisted and narrows the list on its own. + */ + automationNameFacets: readonly string[]; +} + +/** + * The full preference surface, promoted from the old hamburger filter menu + * into its own modal (design from the 2026-08-14 PM walkthrough video). + * Rows apply immediately and stay open — the modal closes only via the + * top-right X, footer Close, Escape, or backdrop click. + */ +export function AdvancedConversationOptionsModal({ + open, + onClose, + backendKind, + automationNameFacets, +}: AdvancedConversationOptionsModalProps) { + const { t } = useTranslation("openhands"); + const preferences = useConversationPanelPreferencesStore(); + const scrollRef = React.useRef(null); + const [scrollEdges, setScrollEdges] = React.useState({ + top: false, + bottom: false, + }); + + const updateScrollEdges = React.useCallback(() => { + const element = scrollRef.current; + if (!element) { + return; + } + setScrollEdges(readVerticalScrollEdgeState(element)); + }, []); + + React.useLayoutEffect(() => { + if (!open) { + return undefined; + } + updateScrollEdges(); + + const element = scrollRef.current; + if (!element) { + return undefined; + } + + const resizeObserver = new ResizeObserver(updateScrollEdges); + resizeObserver.observe(element); + if (element.firstElementChild) { + resizeObserver.observe(element.firstElementChild); + } + + return () => resizeObserver.disconnect(); + }, [open, updateScrollEdges, preferences.automationFilterMode]); + + if (!open) return null; + + const groupedLabel = + backendKind === "local" + ? t(I18nKey.CONVERSATION_PANEL$BY_WORKSPACE) + : t(I18nKey.CONVERSATION_PANEL$BY_REPOSITORY); + + return ( + + + +
    + + + {t(I18nKey.CONVERSATION_PANEL$ADVANCED_OPTIONS_DESCRIPTION)} + +
    + +
    +
    +
    event.stopPropagation()} + onScroll={updateScrollEdges} + > + {t(I18nKey.CONVERSATION_PANEL$ORGANIZE)} + preferences.setOrganizeMode("grouped")} + /> + preferences.setOrganizeMode("chronological")} + /> + + + {t(I18nKey.CONVERSATION_PANEL$SORT_BY)} + preferences.setConversationSort("created")} + /> + preferences.setConversationSort("updated")} + /> + + + {t(I18nKey.CONVERSATION_PANEL$THREADS)} + preferences.setThreadScope("all")} + /> + preferences.setThreadScope("relevant")} + /> + + + {t(I18nKey.CONVERSATION_PANEL$SHOW)} + + + + + {t(I18nKey.CONVERSATION_PANEL$AUTOMATIONS)} + + preferences.setAutomationFilterMode("all")} + /> + + preferences.setAutomationFilterMode("hide-automations") + } + /> + + preferences.setAutomationFilterMode("only-automations") + } + /> + {/* Name rows only make sense while the list is scoped to + automations — and `setAutomationFilterMode` clears the selection + on the way out, so a hidden row can never keep narrowing. */} + {preferences.automationFilterMode === "only-automations" + ? automationNameFacets.map((facet) => ( + preferences.toggleAutomationName(facet)} + /> + )) + : null} + + + {t(I18nKey.CONVERSATION_PANEL$METADATA)} + + + + +
    +
    +
    + +
    event.stopPropagation()} + > + + {t(I18nKey.BUTTON$CLOSE)} + +
    + + + ); +} diff --git a/src/components/features/conversation-panel/budget-display.tsx b/src/components/features/conversation-panel/budget-display.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c5311ddaf8bede0bea853c919da469ea8c5680a --- /dev/null +++ b/src/components/features/conversation-panel/budget-display.tsx @@ -0,0 +1,24 @@ +import { BudgetProgressBar } from "./budget-progress-bar"; +import { BudgetUsageText } from "./budget-usage-text"; + +interface BudgetDisplayProps { + cost: number | null; + maxBudgetPerTask: number | null; +} + +export function BudgetDisplay({ cost, maxBudgetPerTask }: BudgetDisplayProps) { + // Only render the cap/progress UI when a real per-conversation budget cap + // exists. There is currently no way for a user to set max_budget_per_task, + // so showing a "No budget limit" line is misleading — omit it entirely + // and let the surrounding cost/usage rows speak for themselves. + if (cost === null || maxBudgetPerTask === null || maxBudgetPerTask <= 0) { + return null; + } + + return ( +
    + + +
    + ); +} diff --git a/src/components/features/conversation-panel/budget-progress-bar.tsx b/src/components/features/conversation-panel/budget-progress-bar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..80e7343ee0f097fb6ebcc9734492a938b4cf88b8 --- /dev/null +++ b/src/components/features/conversation-panel/budget-progress-bar.tsx @@ -0,0 +1,29 @@ +import { cn } from "#/utils/utils"; + +interface BudgetProgressBarProps { + currentCost: number; + maxBudget: number; +} + +export function BudgetProgressBar({ + currentCost, + maxBudget, +}: BudgetProgressBarProps) { + const usagePercentage = (currentCost / maxBudget) * 100; + const isNearLimit = usagePercentage > 80; + + return ( +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/budget-usage-text.tsx b/src/components/features/conversation-panel/budget-usage-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4fd2c716a45f4f4e7319872b355c68d5535e9c27 --- /dev/null +++ b/src/components/features/conversation-panel/budget-usage-text.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; + +interface BudgetUsageTextProps { + currentCost: number; + maxBudget: number; +} + +export function BudgetUsageText({ + currentCost, + maxBudget, +}: BudgetUsageTextProps) { + const { t } = useTranslation("openhands"); + const usagePercentage = (currentCost / maxBudget) * 100; + + return ( +
    + + {t(I18nKey.CONVERSATION$BUDGET_USAGE_FORMAT, { + currentCost: `$${currentCost.toFixed(4)}`, + maxBudget: `$${maxBudget.toFixed(4)}`, + usagePercentage: usagePercentage.toFixed(2), + used: t(I18nKey.CONVERSATION$USED), + })} + +
    + ); +} diff --git a/src/components/features/conversation-panel/cloud-new-conversation-menu.tsx b/src/components/features/conversation-panel/cloud-new-conversation-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1bc84eaa7dfdd101823cfb2723bb45b26e4ee194 --- /dev/null +++ b/src/components/features/conversation-panel/cloud-new-conversation-menu.tsx @@ -0,0 +1,373 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { useNavigation } from "#/context/navigation-context"; +import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; +import { useGitRepositories } from "#/hooks/query/use-git-repositories"; +import { useSearchRepositories } from "#/hooks/query/use-search-repositories"; +import { useUserProviders } from "#/hooks/use-user-providers"; +import { useDebounce } from "#/hooks/use-debounce"; +import { useHomeStore } from "#/stores/home-store"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { + dropdownMenuRowClassName, + dropdownInstantColorClassName, + dropdownMenuListClassName, + dropdownMenuRowIconWrapperClassName, +} from "#/utils/dropdown-classes"; +import { GitRepository } from "#/types/git"; +import { Provider } from "#/types/settings"; +import RepoIcon from "#/icons/repo.svg?react"; +import SearchIcon from "#/icons/search.svg?react"; +import { GitProviderIcon } from "#/components/shared/git-provider-icon"; +import { Divider } from "#/ui/divider"; +import { NEW_CONVERSATION_DROPDOWN_SURFACE } from "./new-conversation-dropdown-styles"; +import { usePopoverFixedPlacement } from "#/hooks/use-popover-fixed-placement"; + +export type CloudNewConversationMenuTriggerProps = { + onClick: () => void; + "aria-expanded": boolean; + "aria-haspopup": "menu"; + disabled?: boolean; +}; + +export interface CloudNewConversationMenuProps { + trigger: (props: CloudNewConversationMenuTriggerProps) => React.ReactNode; + className?: string; + popoverClassName: string; + popoverTestId?: string; + useFixedPlacement?: boolean; +} + +interface RepoListItemProps { + repo: GitRepository; + disabled: boolean; + onSelect: (repo: GitRepository) => void; + itemClass: string; +} + +function RepoListItem({ + repo, + disabled, + onSelect, + itemClass, +}: RepoListItemProps) { + return ( +
  • + +
  • + ); +} + +/** + * Repository search + launch flow for cloud backends. + * Shared by the sidebar "+ New conversation" control and the conversation + * panel "new thread folder" opener. + */ +export function CloudNewConversationMenu({ + trigger, + className, + popoverClassName, + popoverTestId = "new-conversation-popover", + useFixedPlacement = false, +}: CloudNewConversationMenuProps) { + const { t } = useTranslation("openhands"); + const { navigate } = useNavigation(); + + const { providers } = useUserProviders(); + const { lastSelectedProvider, setLastSelectedProvider } = useHomeStore(); + + const [open, setOpen] = React.useState(false); + const popoverRef = React.useRef(null); + const triggerWrapRef = React.useRef(null); + const fixedBox = usePopoverFixedPlacement(triggerWrapRef, { + open, + enabled: useFixedPlacement, + }); + const [selectedProvider, setSelectedProvider] = + React.useState(lastSelectedProvider ?? null); + const [query, setQuery] = React.useState(""); + const debouncedQuery = useDebounce(query, 300); + + React.useEffect(() => { + if (providers.length === 0) { + if (selectedProvider !== null) setSelectedProvider(null); + return; + } + if (selectedProvider && providers.includes(selectedProvider)) return; + const fallback = + lastSelectedProvider && providers.includes(lastSelectedProvider) + ? lastSelectedProvider + : providers[0]; + setSelectedProvider(fallback); + }, [providers, selectedProvider, lastSelectedProvider]); + + const { + data: repoPages, + isLoading, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useGitRepositories({ provider: selectedProvider }); + + const { data: searchResults, isLoading: isSearchLoading } = + useSearchRepositories(debouncedQuery, selectedProvider); + + const allRepositories = React.useMemo( + () => repoPages?.pages.flatMap((page) => page.items) ?? [], + [repoPages], + ); + + const repositories = debouncedQuery ? (searchResults ?? []) : allRepositories; + + const { mutate: createConversation, isPending } = useCreateConversation(); + const isCreatingElsewhere = useIsCreatingConversation(); + const isCreating = isPending || isCreatingElsewhere; + + React.useEffect(() => { + if (!open) return undefined; + const onDown = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open]); + + React.useEffect(() => { + if (!open) return undefined; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open]); + + const launchRepository = (repo: GitRepository) => { + if (isCreating) return; + createConversation( + { + repository: { + name: repo.full_name, + gitProvider: repo.git_provider, + branch: repo.main_branch ?? "main", + }, + entryPoint: "sidebar_cloud_menu", + }, + { + onSuccess: (data) => { + setOpen(false); + navigate(`/conversations/${data.conversation_id}`); + }, + }, + ); + }; + + const handleProviderChange = (provider: Provider) => { + setSelectedProvider(provider); + setLastSelectedProvider(provider); + setQuery(""); + }; + + const itemClass = dropdownMenuRowClassName; + + React.useEffect(() => { + if (!open) return; + if (debouncedQuery) return; + if (!hasNextPage || isFetchingNextPage || isLoading) return; + if (repositories.length === 0 || repositories.length >= 10) return; + fetchNextPage(); + }, [ + open, + debouncedQuery, + hasNextPage, + isFetchingNextPage, + isLoading, + repositories.length, + fetchNextPage, + ]); + + const isListLoading = debouncedQuery ? isSearchLoading : isLoading; + const showLoadMore = + !debouncedQuery && hasNextPage && repositories.length > 0; + + const toggleOpen = React.useCallback(() => { + setOpen((o) => !o); + }, []); + + const showPopover = open && (!useFixedPlacement || fixedBox !== null); + + const fixedStyle: React.CSSProperties | undefined = + useFixedPlacement && fixedBox + ? { + position: "fixed", + top: fixedBox.top, + left: fixedBox.left, + width: fixedBox.width, + } + : undefined; + + return ( +
    + + {trigger({ + onClick: toggleOpen, + "aria-expanded": open, + "aria-haspopup": "menu", + disabled: isCreating, + })} + + + {showPopover && ( +
    + {providers.length > 1 && ( +
    + {providers.map((provider) => { + const isActive = provider === selectedProvider; + return ( + + ); + })} +
    + )} + +
    +
    + + setQuery(e.target.value)} + placeholder={t(I18nKey.COMMON$SEARCH_REPOSITORIES)} + disabled={!selectedProvider} + className={cn( + "w-full border-0 bg-transparent py-1.5 pl-6 pr-0 text-sm text-white", + "outline-none placeholder:text-[var(--oh-muted)]", + "focus:outline-none focus:ring-0", + "disabled:cursor-not-allowed disabled:opacity-60", + )} + /> +
    +
    + + + +
      + {isListLoading && repositories.length === 0 && ( +
    • + {t(I18nKey.HOME$LOADING_REPOSITORIES)} +
    • + )} + {isError && ( +
    • + {t(I18nKey.HOME$FAILED_TO_LOAD_REPOSITORIES)} +
    • + )} + {!isListLoading && + !isError && + repositories.length === 0 && + !!selectedProvider && ( +
    • + {t(I18nKey.GITHUB$NO_RESULTS)} +
    • + )} + {repositories.map((repo) => ( + + ))} + {showLoadMore && ( +
    • + +
    • + )} +
    +
    + )} +
    + ); +} diff --git a/src/components/features/conversation-panel/compact-conversation-row.tsx b/src/components/features/conversation-panel/compact-conversation-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b856175380e34aeef83dbfb8574693043a0e1af2 --- /dev/null +++ b/src/components/features/conversation-panel/compact-conversation-row.tsx @@ -0,0 +1,123 @@ +import React from "react"; +import { Tooltip } from "@heroui/react"; +import { useTranslation } from "react-i18next"; +import { NavigationLink } from "#/components/shared/navigation-link"; +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; +import { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { RepositorySelection } from "#/api/open-hands.types"; +import { cn } from "#/utils/utils"; +import { ConversationStatusDot } from "./conversation-status-dot"; +import { ConversationCardFooter } from "./conversation-card/conversation-card-footer"; +import { I18nKey } from "#/i18n/declaration"; +import { useBackendScopedPath } from "#/hooks/use-backend-scoped-path"; + +interface CompactConversationRowProps { + conversationId: string; + title: string; + selectedRepository: RepositorySelection | null; + executionStatus?: ExecutionStatus | null; + sandboxStatus?: SandboxStatus | null; + lastUpdatedAt: string; + createdAt?: string; + workspaceWorkingDir?: string | null; + isActive?: boolean; + onClose?: () => void; + showRepositoryMetadata?: boolean; + llmModel?: string | null; + showLlmProfiles?: boolean; + agentKind?: "openhands" | "acp" | null; + acpServer?: string | null; + tags?: Record | null; + showTags?: boolean; +} + +/** + * Minimal one-row presentation of a conversation used by the collapsed + * sidebar. The row itself is just the agent status dot; hovering it shows a + * floating preview with the conversation's title, repo and timestamp. + */ +export function CompactConversationRow({ + conversationId, + title, + selectedRepository, + executionStatus, + sandboxStatus, + lastUpdatedAt, + createdAt, + workspaceWorkingDir, + isActive = false, + onClose, + showRepositoryMetadata = true, + llmModel = null, + showLlmProfiles = false, + agentKind = null, + acpServer = null, + tags = null, + showTags = false, +}: CompactConversationRowProps) { + const { t } = useTranslation("openhands"); + const backendScopedPath = useBackendScopedPath(); + const disableAnimation = import.meta.env.MODE === "test"; + + const preview = ( +
    +
    + + + {title || t(I18nKey.CONVERSATION$UNTITLED)} + +
    + +
    + ); + + return ( + + + cn( + "flex items-center justify-center w-10 h-9 mx-auto rounded-md", + "transition-colors cursor-pointer", + navActive || isActive + ? "bg-tertiary" + : "hover:bg-[var(--oh-surface-raised)]", + ) + } + > + + + + ); +} diff --git a/src/components/features/conversation-panel/confirm-archive-modal.tsx b/src/components/features/conversation-panel/confirm-archive-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..639cb45c6b4bacbebc8d7f012fd938908b070ec3 --- /dev/null +++ b/src/components/features/conversation-panel/confirm-archive-modal.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { + BaseModalDescription, + BaseModalTitle, +} from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { BrandButton } from "../settings/brand-button"; +import { I18nKey } from "#/i18n/declaration"; + +interface ConfirmArchiveModalProps { + onConfirm: () => void; + onCancel: () => void; + conversationTitle?: string; +} + +export function ConfirmArchiveModal({ + onConfirm, + onCancel, + conversationTitle, +}: ConfirmArchiveModalProps) { + const { t } = useTranslation("openhands"); + + const confirmationMessage = + conversationTitle != null && conversationTitle !== "" ? ( + }} + /> + ) : ( + t(I18nKey.CONVERSATION$ARCHIVE_WARNING) + ); + + return ( + + +
    + + {confirmationMessage} +
    +
    event.stopPropagation()} + > + + {t(I18nKey.BUTTON$CANCEL)} + + + {t(I18nKey.ACTION$CONFIRM_ARCHIVE)} + +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/confirm-delete-modal.tsx b/src/components/features/conversation-panel/confirm-delete-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..127fc75aed7e0e3c953994aaa40c4d6580cf9227 --- /dev/null +++ b/src/components/features/conversation-panel/confirm-delete-modal.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { + BaseModalDescription, + BaseModalTitle, +} from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { BrandButton } from "../settings/brand-button"; +import { I18nKey } from "#/i18n/declaration"; + +interface ConfirmDeleteModalProps { + onConfirm: () => void; + onCancel: () => void; + conversationTitle?: string; + title?: string; + description?: React.ReactNode; +} + +export function ConfirmDeleteModal({ + onConfirm, + onCancel, + conversationTitle, + title, + description, +}: ConfirmDeleteModalProps) { + const { t } = useTranslation("openhands"); + + let confirmationMessage: React.ReactNode; + if (description != null) { + confirmationMessage = description; + } else if (conversationTitle) { + confirmationMessage = ( + }} + /> + ); + } else { + confirmationMessage = t(I18nKey.CONVERSATION$DELETE_WARNING); + } + + return ( + + +
    + + {confirmationMessage} +
    +
    event.stopPropagation()} + > + + {t(I18nKey.BUTTON$CANCEL)} + + + {t(I18nKey.ACTION$CONFIRM_DELETE)} + +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/confirm-stop-modal.tsx b/src/components/features/conversation-panel/confirm-stop-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f536fd532f0db49ae29f3437e415dd6dce8becdc --- /dev/null +++ b/src/components/features/conversation-panel/confirm-stop-modal.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from "react-i18next"; +import { + BaseModalDescription, + BaseModalTitle, +} from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { BrandButton } from "../settings/brand-button"; +import { I18nKey } from "#/i18n/declaration"; + +interface ConfirmStopModalProps { + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmStopModal({ + onConfirm, + onCancel, +}: ConfirmStopModalProps) { + const { t } = useTranslation("openhands"); + + return ( + + +
    + + +
    +
    event.stopPropagation()} + > + + {t(I18nKey.BUTTON$CANCEL)} + + + {t(I18nKey.ACTION$CONFIRM_CLOSE)} + +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/conversation-active-tag-filters.tsx b/src/components/features/conversation-panel/conversation-active-tag-filters.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c892b05ff29fe7b3f1f94fb3567a9e2b2b1b821b --- /dev/null +++ b/src/components/features/conversation-panel/conversation-active-tag-filters.tsx @@ -0,0 +1,105 @@ +import { Tag, Workflow, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { + formatTagFacetLabel, + UNNAMED_AUTOMATION_FACET, +} from "./conversation-panel-list-helpers"; + +interface ConversationActiveTagFiltersProps { + selectedFacets: readonly string[]; + onToggleFacet: (facet: string) => void; + /** Automation names narrowing the list in `only-automations` mode. */ + selectedAutomationNames: readonly string[]; + onToggleAutomationName: (name: string) => void; + onClearAll: () => void; +} + +/** + * Always-visible record of which facet selections are narrowing the list — + * both families: user tags and automation names. + * + * The facet rows live two levels inside a menu (tag facets behind a toggle in + * the layouts menu, automation names inside the advanced-options modal). + * Without this strip a filter left switched on just makes conversations + * disappear, with nothing on screen to say why or how to get them back — and + * both selections are persisted, so a reload brings the narrowing back with + * the explanation still buried. + * + * The automation *mode* deliberately has no chip here: it is a scope, not a + * facet selection, and `onClearAll` leaves it alone for the same reason — + * this strip must not silently switch a surface it doesn't show. + * + * Renders nothing when nothing is selected — an empty bar would cost a row + * of a narrow sidebar to say "no news". + */ +export function ConversationActiveTagFilters({ + selectedFacets, + onToggleFacet, + selectedAutomationNames, + onToggleAutomationName, + onClearAll, +}: ConversationActiveTagFiltersProps) { + const { t } = useTranslation("openhands"); + + if (selectedFacets.length === 0 && selectedAutomationNames.length === 0) { + return null; + } + + const chipClassName = + "flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-full bg-[var(--oh-surface)] px-2 py-0.5 text-[10px] leading-4 text-white hover:bg-white/10"; + + return ( +
    + + +
    + {selectedFacets.map((facet) => ( + + ))} + + {selectedAutomationNames.map((name) => ( + + ))} +
    + + +
    + ); +} diff --git a/src/components/features/conversation-panel/conversation-group-folder-list.tsx b/src/components/features/conversation-panel/conversation-group-folder-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c15ff8e8346fe435a8ef8d14aa7dae62a825fdb --- /dev/null +++ b/src/components/features/conversation-panel/conversation-group-folder-list.tsx @@ -0,0 +1,191 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type DragEvent, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { I18nKey } from "#/i18n/declaration"; +import { ConversationGroupFolderRow } from "./conversation-group-folder-row"; +import { + moveGroupFolderOrder, + type ConversationGroupLaunch, + type GroupFolderDropPosition, +} from "./conversation-panel-list-helpers"; + +interface ConversationGroup { + id: string; + label: string; + conversations: AppConversation[]; + launch: ConversationGroupLaunch; +} + +interface ConversationGroupFolderListProps { + groups: ConversationGroup[]; + groupIds: readonly string[]; + groupFolderOrder: readonly string[]; + setGroupFolderOrder: (order: readonly string[]) => void; + collapsedGroupIds: ReadonlySet; + expandedGroupPreviewIds: ReadonlySet; + discoveryConversationIds: ReadonlySet | null; + onToggleGroupCollapsed: (groupId: string) => void; + onToggleGroupPreviewExpanded: (groupId: string) => void; + isCreatingConversationFlow: boolean; + activeConversationId?: string | null; + onLaunchFromGroup: (launch: ConversationGroupLaunch) => void; + renderConversationCard: (conversation: AppConversation) => ReactNode; +} + +export function ConversationGroupFolderList({ + groups, + groupIds, + groupFolderOrder, + setGroupFolderOrder, + collapsedGroupIds, + expandedGroupPreviewIds, + discoveryConversationIds, + onToggleGroupCollapsed, + onToggleGroupPreviewExpanded, + isCreatingConversationFlow, + activeConversationId, + onLaunchFromGroup, + renderConversationCard, +}: ConversationGroupFolderListProps) { + const { t } = useTranslation("openhands"); + const [draggedGroupId, setDraggedGroupId] = useState(null); + const [dropTargetGroupId, setDropTargetGroupId] = useState( + null, + ); + const [dropPosition, setDropPosition] = + useState(null); + // Layout animation is only enabled around a drag-and-drop reorder so that + // expand/collapse clicks don't trigger sibling repositioning animations. + const [animateLayout, setAnimateLayout] = useState(false); + const animateLayoutTimeoutRef = useRef | null>( + null, + ); + + useEffect( + () => () => { + if (animateLayoutTimeoutRef.current) { + clearTimeout(animateLayoutTimeoutRef.current); + } + }, + [], + ); + + const stopAnimatingLayoutSoon = useCallback(() => { + if (animateLayoutTimeoutRef.current) { + clearTimeout(animateLayoutTimeoutRef.current); + } + animateLayoutTimeoutRef.current = setTimeout(() => { + setAnimateLayout(false); + animateLayoutTimeoutRef.current = null; + }, 300); + }, []); + + const resetDragState = useCallback(() => { + setDraggedGroupId(null); + setDropTargetGroupId(null); + setDropPosition(null); + }, []); + + const computeDropPosition = useCallback( + (event: DragEvent): GroupFolderDropPosition => { + const rect = event.currentTarget.getBoundingClientRect(); + return event.clientY < rect.top + rect.height / 2 ? "before" : "after"; + }, + [], + ); + + const handleDrop = useCallback( + (targetGroupId: string, position: GroupFolderDropPosition) => { + if (!draggedGroupId || draggedGroupId === targetGroupId) { + resetDragState(); + stopAnimatingLayoutSoon(); + return; + } + + setGroupFolderOrder( + moveGroupFolderOrder( + groupFolderOrder, + groupIds, + draggedGroupId, + targetGroupId, + position, + ), + ); + resetDragState(); + // Keep layout animation active across the reorder render, then disable + // it once the spring settles. + stopAnimatingLayoutSoon(); + }, + [ + draggedGroupId, + groupFolderOrder, + groupIds, + resetDragState, + setGroupFolderOrder, + stopAnimatingLayoutSoon, + ], + ); + + return ( + + ); +} diff --git a/src/components/features/conversation-panel/conversation-group-folder-row.tsx b/src/components/features/conversation-panel/conversation-group-folder-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..db72f939ae3c8cbc50ae283a2dd035db5f43f0d5 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-group-folder-row.tsx @@ -0,0 +1,242 @@ +import { motion } from "framer-motion"; +import { Folder, FolderOpen, Plus } from "lucide-react"; +import { useRef, type DragEvent, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import type { + ConversationGroupLaunch, + GroupFolderDropPosition, +} from "./conversation-panel-list-helpers"; +import { getGroupConversationPreview } from "./conversation-panel-list-helpers"; + +interface ConversationGroup { + id: string; + label: string; + conversations: AppConversation[]; + launch: ConversationGroupLaunch; +} + +interface ConversationGroupFolderRowProps { + group: ConversationGroup; + expanded: boolean; + previewExpanded: boolean; + isDragging: boolean; + dropIndicatorPosition: GroupFolderDropPosition | null; + animateLayout: boolean; + isCreatingConversationFlow: boolean; + activeConversationId?: string | null; + discoveryConversationIds?: ReadonlySet | null; + onToggleExpanded: () => void; + onDragStart: () => void; + onDragEnd: () => void; + onDragOver: (event: DragEvent) => void; + onDragLeave: () => void; + onDrop: (event: DragEvent) => void; + onTogglePreviewExpanded: () => void; + onLaunchFromGroup: () => void; + renderConversationCard: (conversation: AppConversation) => ReactNode; +} + +export function ConversationGroupFolderRow({ + group, + expanded, + previewExpanded, + isDragging, + dropIndicatorPosition, + animateLayout, + isCreatingConversationFlow, + activeConversationId, + discoveryConversationIds, + onToggleExpanded, + onDragStart, + onDragEnd, + onDragOver, + onDragLeave, + onDrop, + onTogglePreviewExpanded, + onLaunchFromGroup, + renderConversationCard, +}: ConversationGroupFolderRowProps) { + const { t } = useTranslation("openhands"); + const sectionRef = useRef(null); + const headingId = `thread-folder-${group.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`; + const groupTestIdSuffix = group.id.replace(/[^a-zA-Z0-9_-]/g, "-"); + const { visibleConversations, isPreviewTruncated, isShowingAll } = + getGroupConversationPreview(group.conversations, { + expanded: previewExpanded, + activeConversationId, + discoveryConversationIds: discoveryConversationIds ?? undefined, + }); + + return ( + + {dropIndicatorPosition ? ( +
    + ) : null} + {/* Keep the row's footprint (height) while dragging but hide its + content so the original slot reads as an empty placeholder. Use + opacity (not visibility/display) because hiding the drag source + synchronously during dragstart cancels the native drag in Chrome. */} +
    +
    + + +
    + {expanded ? ( +
    + {visibleConversations.map(renderConversationCard)} + {isPreviewTruncated ? ( +
    + +
    + ) : null} +
    + ) : null} +
    + + ); +} diff --git a/src/components/features/conversation-panel/conversation-layouts-menu.tsx b/src/components/features/conversation-panel/conversation-layouts-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df231e8388ea5ba425efc7e8af9051d0b6654240 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-layouts-menu.tsx @@ -0,0 +1,310 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { + Clock3, + Folder, + ListFilter, + Shrink, + SlidersHorizontal, + Star, + Tag, + Trash2, +} from "lucide-react"; +import { I18nKey } from "#/i18n/declaration"; +import type { BackendKind } from "#/api/backend-registry/types"; +import { cn } from "#/utils/utils"; +import { + dropdownInstantColorClassName, + dropdownMenuListClassName, + dropdownMenuViewportScrollClassName, +} from "#/utils/dropdown-classes"; +import { + DEFAULT_LAYOUT_SETTINGS, + useConversationPanelPreferencesStore, + type LayoutSettingsSlice, +} from "#/stores/conversation-panel-preferences-store"; +import { formatTagFacetLabel } from "./conversation-panel-list-helpers"; +import { MenuHeading } from "./menu-heading"; +import { MenuSeparator } from "./menu-separator"; +import { MenuRow } from "./menu-row"; +import { AdvancedConversationOptionsModal } from "./advanced-conversation-options-modal"; + +interface LayoutPreset { + id: string; + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>; + /** Omitted for the backend-dependent first preset (workspace vs repo). */ + labelKey?: I18nKey; + settings: LayoutSettingsSlice; +} + +/** + * Layout presets are complete preference bundles (design inference from the + * 2026-08-14 PM walkthrough video; the video names the presets but not their + * exact bundles). Applying one replaces every layout-controlled field so a + * previous preset cannot leak into the next one. Any manual deviation reads + * as "Custom". + */ +const LAYOUT_PRESETS: LayoutPreset[] = [ + { + id: "by-workspace", + icon: Folder, + settings: { + ...DEFAULT_LAYOUT_SETTINGS, + organizeMode: "grouped", + }, + }, + { + id: "recent-activity", + icon: Clock3, + labelKey: I18nKey.CONVERSATION_PANEL$LAYOUT_RECENT_ACTIVITY, + settings: { + ...DEFAULT_LAYOUT_SETTINGS, + showOlderConversations: false, + }, + }, + { + id: "focused", + icon: Star, + labelKey: I18nKey.CONVERSATION_PANEL$LAYOUT_FOCUSED, + settings: { + ...DEFAULT_LAYOUT_SETTINGS, + threadScope: "relevant", + showOlderConversations: false, + }, + }, + { + id: "minimal", + icon: Shrink, + labelKey: I18nKey.CONVERSATION_PANEL$LAYOUT_MINIMAL, + settings: { + ...DEFAULT_LAYOUT_SETTINGS, + threadScope: "relevant", + showOlderConversations: false, + showRepoBranchMetadata: false, + showLlmProfiles: false, + showTagsMetadata: false, + showHoverMetadata: false, + }, + }, +]; + +export function getActiveLayoutPreset( + settings: LayoutSettingsSlice, +): LayoutPreset | null { + return ( + LAYOUT_PRESETS.find((preset) => + Object.entries(preset.settings).every( + ([key, value]) => settings[key as keyof LayoutSettingsSlice] === value, + ), + ) ?? null + ); +} + +export interface ConversationLayoutsMenuProps { + menuOpen: boolean; + setMenuOpen: (open: boolean) => void; + menuRef: React.RefObject; + backendKind: BackendKind; + /** Distinct user-facing `key=value` facets among the loaded conversations. */ + tagFacets: readonly string[]; + /** Distinct automation names; the advanced modal owns their facet rows. */ + automationNameFacets: readonly string[]; + totalConversationsCount: number; + onRequestDeleteAll: () => void; +} + +export function ConversationLayoutsMenu({ + menuOpen, + setMenuOpen, + menuRef, + backendKind, + tagFacets, + automationNameFacets, + totalConversationsCount, + onRequestDeleteAll, +}: ConversationLayoutsMenuProps) { + const { t } = useTranslation("openhands"); + const preferences = useConversationPanelPreferencesStore(); + const [tagSectionExpanded, setTagSectionExpanded] = React.useState(false); + const [advancedOpen, setAdvancedOpen] = React.useState(false); + + const groupedLabel = + backendKind === "local" + ? t(I18nKey.CONVERSATION_PANEL$BY_WORKSPACE) + : t(I18nKey.CONVERSATION_PANEL$BY_REPOSITORY); + + const activePreset = getActiveLayoutPreset(preferences); + + const triggerRef = React.useRef(null); + const menuContentRef = React.useRef(null); + + // Mirror the filter menu's focus management: focus into the menu on open, + // return focus to the trigger on close. + const wasOpenRef = React.useRef(menuOpen); + React.useEffect(() => { + if (menuOpen) { + const firstItem = + menuContentRef.current?.querySelector( + '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]', + ); + firstItem?.focus(); + } else if (wasOpenRef.current) { + triggerRef.current?.focus(); + } + wasOpenRef.current = menuOpen; + }, [menuOpen]); + + const handleMenuKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setMenuOpen(false); + return; + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + const container = menuContentRef.current; + if (!container) return; + const items = Array.from( + container.querySelectorAll( + '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]', + ), + ).filter((el) => !el.disabled); + if (items.length === 0) return; + const currentIdx = items.indexOf( + document.activeElement as HTMLButtonElement, + ); + const delta = event.key === "ArrowDown" ? 1 : -1; + const start = currentIdx === -1 ? 0 : currentIdx; + const nextIdx = (start + delta + items.length) % items.length; + event.preventDefault(); + items[nextIdx]?.focus(); + }; + + return ( +
    + + + {menuOpen ? ( +
    + + {t(I18nKey.CONVERSATION_PANEL$LAYOUTS_HEADING)} + + {LAYOUT_PRESETS.map((preset) => ( + { + preferences.applyLayoutSettings(preset.settings); + setMenuOpen(false); + }} + /> + ))} + + + setTagSectionExpanded(!tagSectionExpanded)} + /> + {tagSectionExpanded ? ( + tagFacets.length > 0 ? ( + tagFacets.map((facet) => ( + preferences.toggleTagFacet(facet)} + /> + )) + ) : ( +

    + {t(I18nKey.CONVERSATION_PANEL$NO_VISIBLE_TAGS)} +

    + ) + ) : null} + + + { + setMenuOpen(false); + setAdvancedOpen(true); + }} + /> + + + { + if (totalConversationsCount === 0) return; + onRequestDeleteAll(); + setMenuOpen(false); + }} + /> +
    + ) : null} + + setAdvancedOpen(false)} + backendKind={backendKind} + automationNameFacets={automationNameFacets} + /> +
    + ); +} + +const capitalizeLabel = (label: string) => + label.length > 0 ? label.charAt(0).toUpperCase() + label.slice(1) : label; diff --git a/src/components/features/conversation-panel/conversation-panel-list-helpers.ts b/src/components/features/conversation-panel/conversation-panel-list-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa25b3253a15287267b37255da1196a3a0558b74 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-panel-list-helpers.ts @@ -0,0 +1,657 @@ +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; +import type { BackendKind } from "#/api/backend-registry/types"; +import type { LocalWorkspace } from "#/types/workspace"; +import type { Provider } from "#/types/settings"; +import { + AUTOMATION_NAME_TAG_KEY, + AUTOMATION_TAG_KEYS, + getDisplayConversationTags, +} from "#/api/agent-server-adapter"; + +export type ConversationSortField = "created" | "updated"; +export type ThreadScope = "all" | "relevant"; +export type OrganizeMode = "grouped" | "chronological"; +export type AutomationFilterMode = + | "all" + | "hide-automations" + | "only-automations"; +export type OlderConversationCutoff = "1h" | "1d" | "7d" | "30d"; + +export const OLDER_CONVERSATION_CUTOFFS = [ + "1h", + "1d", + "7d", + "30d", +] as const satisfies readonly OlderConversationCutoff[]; + +export const DEFAULT_OLDER_CONVERSATION_CUTOFF: OlderConversationCutoff = "7d"; + +export const OLDER_CONVERSATION_CUTOFF_MS: Record< + OlderConversationCutoff, + number +> = { + "1h": 60 * 60 * 1000, + "1d": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + +export function isOlderConversationCutoff( + value: unknown, +): value is OlderConversationCutoff { + return ( + typeof value === "string" && + (OLDER_CONVERSATION_CUTOFFS as readonly string[]).includes(value) + ); +} + +/** + * Splits conversations by last update relative to `nowMs`. Missing or + * unparseable timestamps stay in `recent` so they are never hidden by the + * hide-older toggle. + */ +export function partitionByCutoff( + items: readonly T[], + cutoffMs: number, + nowMs: number = Date.now(), +): { recent: T[]; older: T[] } { + const cutoff = nowMs - cutoffMs; + const recent: T[] = []; + const older: T[] = []; + for (const item of items) { + const updatedAt = item.updated_at ? Date.parse(item.updated_at) : NaN; + if (Number.isFinite(updatedAt) && updatedAt < cutoff) { + older.push(item); + } else { + recent.push(item); + } + } + return { recent, older }; +} + +/** Max conversations shown under a workspace/repo folder before "View more". */ +export const GROUP_CONVERSATIONS_PREVIEW_LIMIT = 5; + +interface GroupConversationPreviewOptions { + limit?: number; + expanded: boolean; + activeConversationId?: string | null; + /** + * When set, the collapsed preview is drawn only from these conversation IDs + * (plus the active conversation when it belongs to the group). Expanding + * still reveals every loaded conversation in `conversations`. + */ + discoveryConversationIds?: ReadonlySet; +} + +/** + * Builds the frozen collapsed-preview pool for a folder. + * + * Global "Load more" may fetch later pages that add conversations to an + * already-visible folder; those rows stay out of the collapsed preview so the + * folder's first impression stays stable until the user expands it. + */ +function resolveCollapsedPreviewPool( + conversations: readonly AppConversation[], + discoveryConversationIds: ReadonlySet | undefined, + activeConversationId: string | null | undefined, +): AppConversation[] { + if (!discoveryConversationIds) { + return [...conversations]; + } + + const pool = conversations.filter((conversation) => + discoveryConversationIds.has(conversation.id), + ); + + // The load-bearing active-conversation guarantee lives in + // `getGroupDiscoveryConversationIds` (`forceIncludeConversationId`); this + // fallback only covers callers that pass a discovery set built without it. + if ( + activeConversationId != null && + !pool.some((conversation) => conversation.id === activeConversationId) + ) { + const activeConversation = conversations.find( + (conversation) => conversation.id === activeConversationId, + ); + if (activeConversation) { + pool.push(activeConversation); + } + } + + return pool; +} + +export function getGroupConversationPreview( + conversations: readonly AppConversation[], + options: GroupConversationPreviewOptions, +): { + visibleConversations: AppConversation[]; + isPreviewTruncated: boolean; + isShowingAll: boolean; +} { + const limit = options.limit ?? GROUP_CONVERSATIONS_PREVIEW_LIMIT; + const pool = resolveCollapsedPreviewPool( + conversations, + options.discoveryConversationIds, + options.activeConversationId, + ); + + let collapsedVisible: AppConversation[]; + if (pool.length <= limit) { + collapsedVisible = pool; + } else { + const activeIndex = + options.activeConversationId != null + ? pool.findIndex( + (conversation) => conversation.id === options.activeConversationId, + ) + : -1; + + if (activeIndex >= limit) { + collapsedVisible = [...pool.slice(0, limit - 1), pool[activeIndex]]; + } else { + collapsedVisible = pool.slice(0, limit); + } + } + + const collapsedHidesSomething = + collapsedVisible.length < conversations.length; + + if (options.expanded) { + return { + visibleConversations: [...conversations], + isPreviewTruncated: collapsedHidesSomething, + isShowingAll: true, + }; + } + + return { + visibleConversations: collapsedVisible, + isPreviewTruncated: collapsedHidesSomething, + isShowingAll: !collapsedHidesSomething, + }; +} + +export function resolvePinnedConversations( + pinnedIds: readonly string[], + conversations: readonly AppConversation[], +): AppConversation[] { + const byId = new Map( + conversations.map((conversation) => [conversation.id, conversation]), + ); + return pinnedIds + .map((id) => byId.get(id)) + .filter( + (conversation): conversation is AppConversation => conversation != null, + ); +} + +export function filterOutPinnedConversations( + conversations: readonly AppConversation[], + pinnedIds: readonly string[], +): AppConversation[] { + if (pinnedIds.length === 0) { + return [...conversations]; + } + + const pinnedSet = new Set(pinnedIds); + return conversations.filter( + (conversation) => !pinnedSet.has(conversation.id), + ); +} + +/** + * Facet bucket for automation-born conversations that carry no + * `automationname` tag (double-underscore prefix mirrors the + * `__none_workspace` group-id idiom). + */ +export const UNNAMED_AUTOMATION_FACET = "__unnamed__"; + +/** + * Whether a conversation was created by an automation run: the cloud backend + * stamps `trigger: "automation"`, while local agent-server conversations are + * recognized by the automation tags the SDK workspace attaches at creation. + */ +export function isAutomationConversation( + conversation: AppConversation, +): boolean { + if (conversation.trigger === "automation") { + return true; + } + const tags = conversation.tags; + if (!tags) { + return false; + } + return AUTOMATION_TAG_KEYS.some((key) => Boolean(tags[key])); +} + +export function getAutomationNameFacet(conversation: AppConversation): string { + const name = conversation.tags?.[AUTOMATION_NAME_TAG_KEY]?.trim(); + return name || UNNAMED_AUTOMATION_FACET; +} + +/** + * Unique automation names among the given conversations, sorted for stable + * menu order; the unnamed bucket (if present) is appended last. + */ +export function collectAutomationNameFacets( + conversations: readonly AppConversation[], +): string[] { + const names = new Set(); + let hasUnnamed = false; + for (const conversation of conversations) { + if (!isAutomationConversation(conversation)) { + continue; + } + const facet = getAutomationNameFacet(conversation); + if (facet === UNNAMED_AUTOMATION_FACET) { + hasUnnamed = true; + } else { + names.add(facet); + } + } + const sorted = [...names].sort((a, b) => a.localeCompare(b)); + if (hasUnnamed) { + sorted.push(UNNAMED_AUTOMATION_FACET); + } + return sorted; +} + +export function applyAutomationConversationFilter( + conversations: readonly AppConversation[], + mode: AutomationFilterMode, + selectedNames: readonly string[], + availableFacets: readonly string[], +): AppConversation[] { + if (mode === "all") { + return [...conversations]; + } + if (mode === "hide-automations") { + return conversations.filter( + (conversation) => !isAutomationConversation(conversation), + ); + } + // "only-automations": an empty selection means every automation run, and a + // selection that no longer intersects the available facets (stale names + // persisted from another backend, renamed automations) degrades the same + // way instead of yielding an unfillable empty list. + const facetSet = new Set(availableFacets); + const effectiveNames = selectedNames.filter((name) => facetSet.has(name)); + const narrowing = effectiveNames.length > 0 ? new Set(effectiveNames) : null; + return conversations.filter((conversation) => { + if (!isAutomationConversation(conversation)) { + return false; + } + return ( + narrowing === null || narrowing.has(getAutomationNameFacet(conversation)) + ); + }); +} + +/** + * Distinct user-facing `key=value` facets among the given conversations, + * sorted A–Z for stable menu order. Reserved/internal tag keys are excluded + * via `getDisplayConversationTags`; unlike the automation filter there is no + * unnamed bucket — a conversation with no user tags simply yields no facet. + */ +export function collectTagFacets( + conversations: readonly AppConversation[], +): string[] { + const facets = new Set(); + for (const conversation of conversations) { + for (const [key, value] of getDisplayConversationTags(conversation.tags)) { + facets.add(`${key}=${value}`); + } + } + return [...facets].sort((a, b) => a.localeCompare(b)); +} + +/** + * Display form of a stored facet: a bare tag (empty value, stored as `key=`) + * renders as just the key. Matching keeps the raw `key=value` form — this is + * label-only. + */ +export function formatTagFacetLabel(facet: string): string { + return facet.endsWith("=") ? facet.slice(0, -1) : facet; +} + +/** + * Union semantics: a conversation matches when it carries ANY selected + * facet, mirroring the automation multi-select. An empty selection — or one + * that no longer intersects the available facets (tags edited away, stale + * selections persisted from another backend) — leaves the list unfiltered + * instead of yielding an unfillable empty list. + */ +export function applyTagConversationFilter( + conversations: readonly AppConversation[], + selectedFacets: readonly string[], + availableFacets: readonly string[], +): AppConversation[] { + const facetSet = new Set(availableFacets); + const effectiveFacets = selectedFacets.filter((facet) => facetSet.has(facet)); + if (effectiveFacets.length === 0) { + return [...conversations]; + } + const narrowing = new Set(effectiveFacets); + return conversations.filter((conversation) => + getDisplayConversationTags(conversation.tags).some(([key, value]) => + narrowing.has(`${key}=${value}`), + ), + ); +} + +/** Subset of `useCreateConversation` variables for launching from a group row */ +export type ConversationGroupLaunch = { + workingDir?: string; + repository?: { + name: string; + gitProvider: Provider; + branch?: string; + }; +}; + +function buildGroupLaunch( + id: string, + backendKind: BackendKind, + conversations: AppConversation[], +): ConversationGroupLaunch { + if (backendKind === "local") { + if (id === "__none_workspace") { + return {}; + } + if (id.startsWith("ws:")) { + return { workingDir: id.slice(3) }; + } + return {}; + } + + if (id === "__none_repo") { + return {}; + } + if (id.startsWith("repo:")) { + const name = id.slice(5); + const sample = conversations[0]; + const gitProvider = (sample?.git_provider ?? "github") as Provider; + const branch = sample?.selected_branch ?? "main"; + return { + repository: { + name, + gitProvider, + branch, + }, + }; + } + + return {}; +} + +export function parseConversationTimeMs(iso: string | undefined): number { + if (!iso) return 0; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; +} + +export function sortConversationsByField( + items: readonly AppConversation[], + field: ConversationSortField, +): AppConversation[] { + const key = field === "created" ? "created_at" : "updated_at"; + return [...items].sort( + (a, b) => parseConversationTimeMs(b[key]) - parseConversationTimeMs(a[key]), + ); +} + +function workspaceGroup(conversation: AppConversation): { + id: string; + label: string; +} { + // Group by the user-selected workspace (a stable identifier shared by + // every conversation launched from the same picker selection), not + // `workspace.working_dir` — that field holds the per-conversation + // worktree path the agent-server creates, which is unique per + // conversation and would fragment the grouping. + // + // Normalize first, then check emptiness: inputs like "/", "///", or + // " ///" trim+strip to "" and must fall back to the "no workspace" + // bucket rather than producing a stray `ws:` group with no label. + const normalized = conversation.selected_workspace + ?.trim() + .replace(/\/+$/, ""); + if (!normalized) { + return { id: "__none_workspace", label: "" }; + } + const label = normalized.split("/").filter(Boolean).pop() ?? normalized; + return { id: `ws:${normalized}`, label }; +} + +function repositoryGroup(conversation: AppConversation): { + id: string; + label: string; +} { + // Mirror `workspaceGroup`'s normalize-then-check order so "/", "///", + // and trailing-slash variants of the same repo all collapse to one + // group instead of producing a stray `repo:/` bucket. + const normalized = conversation.selected_repository + ?.trim() + .replace(/\/+$/, ""); + if (!normalized) { + return { id: "__none_repo", label: "" }; + } + const parts = normalized.split("/").filter(Boolean); + const label = parts.length + ? (parts[parts.length - 1] ?? normalized).replace(/\.git$/, "") + : normalized.replace(/\.git$/, ""); + return { id: `repo:${normalized}`, label }; +} + +/** + * Resolves the stable folder identity used by grouped conversation views. + * + * Keeping this shared with `groupConversations` lets pagination reason about + * folder discovery without duplicating workspace/repository normalization. + */ +function getConversationGroupIdentity( + conversation: AppConversation, + backendKind: BackendKind, +): { id: string; label: string } { + return backendKind === "local" + ? workspaceGroup(conversation) + : repositoryGroup(conversation); +} + +/** + * Max backend pages fetched for a single grouped "Load more" click. The + * driver walks past pages that only deepen already-visible folders looking + * for a new folder, and this cap prevents one click from walking the entire + * remaining cursor when no undiscovered folder exists. Chronological mode is + * not capped — it keeps its pre-existing fetch-until-visible behavior. + */ +export const MAX_PAGES_PER_LOAD_MORE_CLICK = 3; + +/** + * Conversation IDs that belong on each folder's discovery page — the first + * backend page where that folder appeared. + * + * Global "Load more" still discovers folders from later pages, but the + * collapsed preview for an already-visible folder stays frozen to this set. + * Expanding the folder reads the full grouped `conversations` array instead. + * `forceIncludeConversationId` keeps the active thread in the preview even + * when it landed on a non-discovery page. + */ +export function getGroupDiscoveryConversationIds( + items: readonly AppConversation[], + pageByConversationId: ReadonlyMap, + backendKind: BackendKind, + options?: { forceIncludeConversationId?: string | null }, +): Set { + // Resolve each conversation's folder identity exactly once; both passes + // below (finding each folder's discovery page, then collecting the ids on + // that page) read from this record instead of re-running the + // workspace/repository normalization. + const resolved = items.map((conversation) => ({ + conversationId: conversation.id, + groupId: getConversationGroupIdentity(conversation, backendKind).id, + page: pageByConversationId.get(conversation.id) ?? 0, + })); + + const discoveryPageByGroupId = new Map(); + for (const { groupId, page } of resolved) { + const currentDiscoveryPage = discoveryPageByGroupId.get(groupId); + if (currentDiscoveryPage === undefined || page < currentDiscoveryPage) { + discoveryPageByGroupId.set(groupId, page); + } + } + + const discoveryIds = new Set(); + for (const { conversationId, groupId, page } of resolved) { + if (page === discoveryPageByGroupId.get(groupId)) { + discoveryIds.add(conversationId); + } + } + + const forceIncludeId = options?.forceIncludeConversationId; + if ( + forceIncludeId != null && + items.some((conversation) => conversation.id === forceIncludeId) + ) { + discoveryIds.add(forceIncludeId); + } + + return discoveryIds; +} + +export function groupConversations( + items: readonly AppConversation[], + backendKind: BackendKind, + sortField: ConversationSortField, + labels: { emptyWorkspace: string; emptyRepository: string }, + knownWorkspaces?: readonly LocalWorkspace[], +): { + id: string; + label: string; + conversations: AppConversation[]; + launch: ConversationGroupLaunch; +}[] { + const byId = new Map< + string, + { label: string; conversations: AppConversation[] } + >(); + + if (backendKind === "local" && knownWorkspaces) { + for (const ws of knownWorkspaces) { + const normalized = ws.path.trim().replace(/\/+$/, ""); + if (normalized) { + byId.set(`ws:${normalized}`, { label: ws.name, conversations: [] }); + } + } + } + + for (const c of items) { + const { id, label: rawLabel } = getConversationGroupIdentity( + c, + backendKind, + ); + const label = + id === "__none_workspace" + ? labels.emptyWorkspace + : id === "__none_repo" + ? labels.emptyRepository + : rawLabel; + const bucket = byId.get(id); + if (bucket) { + bucket.conversations.push(c); + } else { + byId.set(id, { label, conversations: [c] }); + } + } + + const groups = [...byId.entries()].map(([id, g]) => { + const conversations = sortConversationsByField(g.conversations, sortField); + return { + id, + label: g.label, + conversations, + launch: buildGroupLaunch(id, backendKind, conversations), + }; + }); + + // Use reduce instead of `Math.max(...arr)` — the spread form would push + // every conversation onto the call stack as a separate argument, which + // hits JS engines' ~100k-arg limit on very large buckets. + const groupOrderKey = (g: (typeof groups)[number]) => + g.conversations.reduce( + (max, c) => + Math.max( + max, + parseConversationTimeMs( + sortField === "created" ? c.created_at : c.updated_at, + ), + ), + 0, + ); + + groups.sort((a, b) => groupOrderKey(b) - groupOrderKey(a)); + return groups; +} + +export function applyGroupFolderOrder( + groups: readonly T[], + order: readonly string[], +): T[] { + if (order.length === 0) { + return [...groups]; + } + + const byId = new Map(groups.map((group) => [group.id, group])); + const ordered: T[] = []; + const seen = new Set(); + + for (const id of order) { + const group = byId.get(id); + if (group) { + ordered.push(group); + seen.add(id); + } + } + + for (const group of groups) { + if (!seen.has(group.id)) { + ordered.push(group); + } + } + + return ordered; +} + +export type GroupFolderDropPosition = "before" | "after"; + +export function moveGroupFolderOrder( + order: readonly string[], + groupIds: readonly string[], + activeGroupId: string, + targetGroupId: string, + position: GroupFolderDropPosition = "after", +): string[] { + if (activeGroupId === targetGroupId) { + return [...order]; + } + + const effectiveOrder = applyGroupFolderOrder( + groupIds.map((id) => ({ id })), + order, + ).map((group) => group.id); + const fromIndex = effectiveOrder.indexOf(activeGroupId); + const toIndex = effectiveOrder.indexOf(targetGroupId); + if (fromIndex < 0 || toIndex < 0) { + return [...order]; + } + + const nextOrder = [...effectiveOrder]; + nextOrder.splice(fromIndex, 1); + const adjustedTargetIndex = nextOrder.indexOf(targetGroupId); + const insertIndex = + position === "before" ? adjustedTargetIndex : adjustedTargetIndex + 1; + nextOrder.splice(insertIndex, 0, activeGroupId); + return nextOrder; +} diff --git a/src/components/features/conversation-panel/conversation-panel-new-thread-picker.tsx b/src/components/features/conversation-panel/conversation-panel-new-thread-picker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c575c67031c5442ed327dab73ec3ea80b78edc16 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-panel-new-thread-picker.tsx @@ -0,0 +1,89 @@ +import { useTranslation } from "react-i18next"; +import { FolderPlus } from "lucide-react"; +import { I18nKey } from "#/i18n/declaration"; +import type { BackendKind } from "#/api/backend-registry/types"; +import { cn } from "#/utils/utils"; +import { LocalNewConversationMenu } from "./local-new-conversation-menu"; +import { CloudNewConversationMenu } from "./cloud-new-conversation-menu"; + +const triggerClassName = cn( + "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md", + "text-[var(--oh-muted)] transition-colors", + "hover:bg-[var(--oh-surface-raised)] hover:text-white", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--oh-border)]", + "disabled:cursor-not-allowed disabled:opacity-50", +); + +/** + * Compact "new thread folder" control for the conversation panel header. + * Opens the same workspace (local) or repository (cloud) picker as the + * sidebar new-conversation flows. + */ +export function ConversationPanelNewThreadPicker({ + backendKind, +}: { + backendKind: BackendKind; +}) { + const { t } = useTranslation("openhands"); + const ariaLabel = t(I18nKey.CONVERSATION_PANEL$NEW_THREAD_FOLDER_ARIA); + + const triggerIcon = ( + + ); + + if (backendKind === "local") { + return ( + ( + + )} + /> + ); + } + + return ( + ( + + )} + /> + ); +} diff --git a/src/components/features/conversation-panel/conversation-panel-pinned-section.tsx b/src/components/features/conversation-panel/conversation-panel-pinned-section.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fac830269338f1ab46d1a9726377c963456137c7 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-panel-pinned-section.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Pin } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { cn } from "#/utils/utils"; +import { + getGroupConversationPreview, + GROUP_CONVERSATIONS_PREVIEW_LIMIT, +} from "./conversation-panel-list-helpers"; + +interface ConversationPanelPinnedSectionProps { + pinnedConversations: readonly AppConversation[]; + isPreviewExpanded: boolean; + onTogglePreviewExpanded: () => void; + activeConversationId: string | null; + showDivider?: boolean; + renderConversationCard: (conversation: AppConversation) => React.ReactNode; +} + +export function ConversationPanelPinnedSection({ + pinnedConversations, + isPreviewExpanded, + onTogglePreviewExpanded, + activeConversationId, + showDivider = false, + renderConversationCard, +}: ConversationPanelPinnedSectionProps) { + const { t } = useTranslation("openhands"); + + const { visibleConversations, isPreviewTruncated, isShowingAll } = + getGroupConversationPreview(pinnedConversations, { + limit: GROUP_CONVERSATIONS_PREVIEW_LIMIT, + expanded: isPreviewExpanded, + activeConversationId, + }); + + return ( +
    +

    + + {t(I18nKey.CONVERSATION_PANEL$PINNED)} +

    +
    + {visibleConversations.map(renderConversationCard)} +
    + {isPreviewTruncated ? ( +
    + +
    + ) : null} +
    + ); +} diff --git a/src/components/features/conversation-panel/conversation-panel-wrapper.tsx b/src/components/features/conversation-panel/conversation-panel-wrapper.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6023e7265ba876497be57ca1095dbcbbb5cd9948 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-panel-wrapper.tsx @@ -0,0 +1,31 @@ +import ReactDOM from "react-dom"; +import { useNavigation } from "#/context/navigation-context"; +import { cn } from "#/utils/utils"; + +interface ConversationPanelWrapperProps { + isOpen: boolean; +} + +export function ConversationPanelWrapper({ + isOpen, + children, +}: React.PropsWithChildren) { + const { currentPath } = useNavigation(); + + if (!isOpen) return null; + + const portalTarget = document.getElementById("root-outlet"); + if (!portalTarget) return null; + + return ReactDOM.createPortal( +
    + {children} +
    , + portalTarget, + ); +} diff --git a/src/components/features/conversation-panel/conversation-panel.tsx b/src/components/features/conversation-panel/conversation-panel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..70759260f0e4b87b0dd8c44aaf90132f6f685973 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-panel.tsx @@ -0,0 +1,1313 @@ +import React from "react"; +import { Tooltip } from "@heroui/react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useNavigation } from "#/context/navigation-context"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useBackendScopedPath } from "#/hooks/use-backend-scoped-path"; +import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations"; +import { useResolvedWorkspaces } from "#/hooks/query/use-resolved-workspaces"; +import { useStartTasks } from "#/hooks/query/use-start-tasks"; +import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"; +import { useUnifiedPauseConversation } from "#/hooks/mutation/use-unified-stop-conversation"; +import { ConfirmArchiveModal } from "./confirm-archive-modal"; +import { ConfirmDeleteModal } from "./confirm-delete-modal"; +import { ConfirmStopModal } from "./confirm-stop-modal"; +import { NavigationLink } from "#/components/shared/navigation-link"; +import { ExitConversationModal } from "./exit-conversation-modal"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { Provider } from "#/types/settings"; +import type { LocalWorkspace } from "#/types/workspace"; +import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation"; +import { useUpdateConversationTags } from "#/hooks/mutation/use-update-conversation-tags"; +import { EditConversationTagsModal } from "./edit-conversation-tags-modal"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { isExecutionActive } from "#/utils/status"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; +import { ConversationCard } from "./conversation-card/conversation-card"; +import { ConversationCardPreview } from "./conversation-card/conversation-card-preview"; +import { StartTaskCard } from "./start-task-card/start-task-card"; +import { ConversationCardSkeleton } from "./conversation-card/conversation-card-skeleton"; +import { CompactConversationRow } from "./compact-conversation-row"; +import { useConversationPanelPreferencesStore } from "#/stores/conversation-panel-preferences-store"; +import { cn } from "#/utils/utils"; +import { ConversationLayoutsMenu } from "./conversation-layouts-menu"; +import { ConversationActiveTagFilters } from "./conversation-active-tag-filters"; +import { ConversationPanelNewThreadPicker } from "./conversation-panel-new-thread-picker"; +import { ConversationGroupFolderList } from "./conversation-group-folder-list"; +import { ConversationPanelPinnedSection } from "./conversation-panel-pinned-section"; +import { + applyAutomationConversationFilter, + applyGroupFolderOrder, + applyTagConversationFilter, + collectAutomationNameFacets, + collectTagFacets, + DEFAULT_OLDER_CONVERSATION_CUTOFF, + filterOutPinnedConversations, + getGroupDiscoveryConversationIds, + groupConversations, + isOlderConversationCutoff, + MAX_PAGES_PER_LOAD_MORE_CLICK, + OLDER_CONVERSATION_CUTOFF_MS, + partitionByCutoff, + resolvePinnedConversations, + sortConversationsByField, + type ConversationGroupLaunch, +} from "./conversation-panel-list-helpers"; +import { useArchivedConversationsStore } from "#/stores/archived-conversations-store"; +import { usePinnedConversationsStore } from "#/stores/pinned-conversations-store"; + +interface ConversationPanelProps { + onClose?: () => void; + /** + * Render a minimal icon-only variant of each conversation row (used by the + * collapsed sidebar). Each row is a single status dot with a hover preview + * containing the full card content. + */ + compact?: boolean; +} + +const noop = () => {}; + +const EMPTY_PINNED_CONVERSATION_IDS: readonly string[] = []; + +export function ConversationPanel({ + onClose, + compact = false, +}: ConversationPanelProps) { + const { t } = useTranslation("openhands"); + const { conversationId: currentConversationId, navigate } = useNavigation(); + const { backend: activeBackend } = useActiveBackend(); + const backendScopedPath = useBackendScopedPath(); + // Click-outside is only relevant in the legacy drawer mode where an + // onClose handler is provided. When the panel is rendered inline (e.g. + // as the always-visible conversation list pane), clicking outside should + // not dismiss the list, so we pass a no-op callback in that case. + const ref = useClickOutsideElement(onClose ?? noop); + + const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = + React.useState(false); + const [confirmArchiveModalVisible, setConfirmArchiveModalVisible] = + React.useState(false); + const [editTagsModalVisible, setEditTagsModalVisible] = React.useState(false); + const [confirmStopModalVisible, setConfirmStopModalVisible] = + React.useState(false); + const [ + confirmExitConversationModalVisible, + setConfirmExitConversationModalVisible, + ] = React.useState(false); + const [confirmDeleteAllVisible, setConfirmDeleteAllVisible] = + React.useState(false); + const showOlderConversations = useConversationPanelPreferencesStore( + (state) => state.showOlderConversations, + ); + const olderConversationCutoff = useConversationPanelPreferencesStore( + (state) => state.olderConversationCutoff, + ); + const showArchivedConversations = useConversationPanelPreferencesStore( + (state) => state.showArchivedConversations, + ); + const showRepoBranchMetadata = useConversationPanelPreferencesStore( + (state) => state.showRepoBranchMetadata, + ); + const showLlmProfiles = useConversationPanelPreferencesStore( + (state) => state.showLlmProfiles, + ); + const showTagsMetadata = useConversationPanelPreferencesStore( + (state) => state.showTagsMetadata, + ); + const showHoverMetadata = useConversationPanelPreferencesStore( + (state) => state.showHoverMetadata, + ); + const organizeMode = useConversationPanelPreferencesStore( + (state) => state.organizeMode, + ); + const conversationSort = useConversationPanelPreferencesStore( + (state) => state.conversationSort, + ); + const threadScope = useConversationPanelPreferencesStore( + (state) => state.threadScope, + ); + const automationFilterMode = useConversationPanelPreferencesStore( + (state) => state.automationFilterMode, + ); + const selectedAutomationNames = useConversationPanelPreferencesStore( + (state) => state.selectedAutomationNames, + ); + const selectedTagFacets = useConversationPanelPreferencesStore( + (state) => state.selectedTagFacets, + ); + const toggleTagFacet = useConversationPanelPreferencesStore( + (state) => state.toggleTagFacet, + ); + const toggleAutomationName = useConversationPanelPreferencesStore( + (state) => state.toggleAutomationName, + ); + const clearFilterSelections = useConversationPanelPreferencesStore( + (state) => state.clearFilterSelections, + ); + const groupFolderOrder = useConversationPanelPreferencesStore( + (state) => state.groupFolderOrder, + ); + const setGroupFolderOrder = useConversationPanelPreferencesStore( + (state) => state.setGroupFolderOrder, + ); + const [filterMenuOpen, setFilterMenuOpen] = React.useState(false); + const [isListScrolled, setIsListScrolled] = React.useState(false); + const filterMenuRef = useClickOutsideElement(() => { + setFilterMenuOpen(false); + }); + const [collapsedGroupIds, setCollapsedGroupIds] = React.useState< + ReadonlySet + >(() => new Set()); + const [expandedGroupPreviewIds, setExpandedGroupPreviewIds] = React.useState< + ReadonlySet + >(() => new Set()); + const [expandedPinnedPreview, setExpandedPinnedPreview] = + React.useState(false); + + const pinnedIds = usePinnedConversationsStore( + (state) => + state.pinsByBackendId[activeBackend.id] ?? EMPTY_PINNED_CONVERSATION_IDS, + ); + const togglePin = usePinnedConversationsStore((state) => state.togglePin); + const unpinConversation = usePinnedConversationsStore( + (state) => state.unpinConversation, + ); + const pruneMissingPinnedConversations = usePinnedConversationsStore( + (state) => state.pruneMissingConversations, + ); + const archivedIds = useArchivedConversationsStore( + (state) => + state.archivesByBackendId[activeBackend.id] ?? + EMPTY_PINNED_CONVERSATION_IDS, + ); + const archiveConversation = useArchivedConversationsStore( + (state) => state.archiveConversation, + ); + const archivedIdSet = React.useMemo( + () => new Set(archivedIds), + [archivedIds], + ); + const removeArchivedConversation = useArchivedConversationsStore( + (state) => state.removeArchivedConversation, + ); + + const toggleGroupCollapsed = React.useCallback((groupId: string) => { + setCollapsedGroupIds((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + const toggleGroupPreviewExpanded = React.useCallback((groupId: string) => { + setExpandedGroupPreviewIds((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + React.useEffect(() => { + if (organizeMode !== "grouped") { + setCollapsedGroupIds(new Set()); + setExpandedGroupPreviewIds(new Set()); + } + }, [organizeMode]); + + const scrollContainerRef = React.useRef(null); + + const [selectedConversationId, setSelectedConversationId] = React.useState< + string | null + >(null); + const [selectedConversationTitle, setSelectedConversationTitle] = + React.useState(null); + const [openContextMenuId, setOpenContextMenuId] = React.useState< + string | null + >(null); + + const { + data, + isLoading, + isFetched, + hasNextPage, + isFetching, + isFetchingNextPage, + fetchNextPage, + } = usePaginatedConversations(); + const { workspaces: knownWorkspaces } = useResolvedWorkspaces(); + + // Fetch in-progress start tasks + const { data: startTasks } = useStartTasks(); + + // Deduped, archive-unaware collection of every conversation currently loaded + // from the backend. Bulk actions like "Delete all" must use this list so + // hiding archived rows from the UI never shrinks what gets deleted. + const allLoadedConversations = React.useMemo(() => { + const all = data?.pages.flatMap((page) => page.items) ?? []; + // The 10s background refetch re-fetches every loaded page with the + // `UPDATED_AT_DESC` cursor. If a conversation's `updated_at` shifts between + // page fetches, a later page can overlap an earlier one and surface the + // same conversation twice. Dedupe by id (keeping the first/freshest copy) + // so the rendered count reflects real growth and React keys stay unique. + const seen = new Set(); + return all.filter((conversation) => { + if (seen.has(conversation.id)) { + return false; + } + seen.add(conversation.id); + return true; + }); + }, [data]); + + // Grouped pagination is folder-oriented. Record the first backend page for + // every conversation so later pages can introduce new folders without + // mutating the contents of folders that are already visible. + // + // Known limitation: the mapping is recomputed from `data.pages` on the 10s + // background refetch, so a conversation whose `updated_at` shifts can move + // to an earlier page and change its recorded discovery page. The blast + // radius is only which rows a collapsed preview shows (expanding a folder + // always reveals every loaded conversation); pinning discovery pages across + // refetches is not worth the extra bookkeeping today. + const conversationPageById = React.useMemo(() => { + const pageById = new Map(); + data?.pages.forEach((page, pageIndex) => { + page.items.forEach((conversation) => { + if (!pageById.has(conversation.id)) { + pageById.set(conversation.id, pageIndex); + } + }); + }); + return pageById; + }, [data]); + + // Display collection: same loaded pages, with archived rows filtered out + // unless the user has opted into "Show archived". + const conversations = React.useMemo(() => { + if (showArchivedConversations) { + return allLoadedConversations; + } + return allLoadedConversations.filter( + (conversation) => !archivedIdSet.has(conversation.id), + ); + }, [allLoadedConversations, archivedIdSet, showArchivedConversations]); + + // Facets derive from the unfiltered list so the automation-name rows in the + // advanced-options modal don't vanish while a narrowing selection is active. + const automationNameFacets = React.useMemo( + () => collectAutomationNameFacets(conversations), + [conversations], + ); + + const allWorkspacesForGrouping = React.useMemo< + readonly LocalWorkspace[] + >(() => { + if ( + compact || + organizeMode !== "grouped" || + activeBackend.kind !== "local" + ) { + return []; + } + const normalize = (p: string) => p.trim().replace(/\/+$/, ""); + const byPath = new Map(); + for (const ws of knownWorkspaces) { + const key = normalize(ws.path); + if (key) { + byPath.set(key, ws); + } + } + for (const c of conversations) { + const normalized = c.selected_workspace + ? normalize(c.selected_workspace) + : ""; + if (normalized && !byPath.has(normalized)) { + const label = normalized.split("/").filter(Boolean).pop() ?? normalized; + byPath.set(normalized, { + id: normalized, + name: label, + path: normalized, + }); + } + } + return Array.from(byPath.values()); + }, [ + activeBackend.kind, + compact, + conversations, + knownWorkspaces, + organizeMode, + ]); + + const automationFilteredConversations = React.useMemo( + () => + applyAutomationConversationFilter( + conversations, + automationFilterMode, + selectedAutomationNames, + automationNameFacets, + ), + [ + automationFilterMode, + automationNameFacets, + conversations, + selectedAutomationNames, + ], + ); + + // Tag facets likewise derive from the unfiltered list so the tag rows in + // the layouts menu don't vanish while a narrowing selection is active. + const tagFacets = React.useMemo( + () => collectTagFacets(conversations), + [conversations], + ); + + // The tag filter applies after the automation filter so a conversation + // must pass both. + const tagFilteredConversations = React.useMemo( + () => + applyTagConversationFilter( + automationFilteredConversations, + selectedTagFacets, + tagFacets, + ), + [automationFilteredConversations, selectedTagFacets, tagFacets], + ); + + const pinnedConversations = React.useMemo( + () => resolvePinnedConversations(pinnedIds, conversations), + [conversations, pinnedIds], + ); + + React.useEffect(() => { + if (!isFetched) { + return; + } + // Prune pins against the unfiltered loaded pages so archived-but-still- + // pinned rows are not treated as missing. Archived IDs are intentionally + // not pruned here — pagination would otherwise drop archives that are not + // on the currently loaded pages and let them reappear in the list. + const loadedIds = + data?.pages.flatMap((page) => page.items.map((item) => item.id)) ?? []; + pruneMissingPinnedConversations(activeBackend.id, loadedIds); + }, [activeBackend.id, data, isFetched, pruneMissingPinnedConversations]); + + React.useEffect(() => { + if (pinnedIds.length === 0) { + setExpandedPinnedPreview(false); + } + }, [pinnedIds.length]); + + const scopedConversations = React.useMemo(() => { + // The pinned section intentionally bypasses the automation and tag + // filters (same exemption the thread scope has): a pin is an explicit + // user override. + const scopeFiltered = + threadScope === "relevant" + ? tagFilteredConversations.filter((c) => + isExecutionActive(c.execution_status), + ) + : tagFilteredConversations; + + // In the expanded panel, pinned conversations should only appear inside + // the dedicated pinned section (not duplicated in grouped/flat lists). + if (compact) { + return scopeFiltered; + } + + return filterOutPinnedConversations(scopeFiltered, pinnedIds); + }, [tagFilteredConversations, compact, pinnedIds, threadScope]); + + const { recent: recentScoped, older: olderScoped } = React.useMemo(() => { + const cutoff = isOlderConversationCutoff(olderConversationCutoff) + ? olderConversationCutoff + : DEFAULT_OLDER_CONVERSATION_CUTOFF; + return partitionByCutoff( + scopedConversations, + OLDER_CONVERSATION_CUTOFF_MS[cutoff], + ); + }, [olderConversationCutoff, scopedConversations]); + + // Sort the full visible set as one list. The recent/older partition is + // still computed (it gates the "Show older" toggle and "Load more" + // visibility), but the rendering must not use it as a visual boundary — + // when sorting by `created`, a stale-but-recently-touched conversation + // would otherwise land in `recent` and render above an actually-newer- + // by-`created_at` conversation sitting in `older`. + const sortedVisibleConversations = React.useMemo(() => { + const visible = showOlderConversations + ? [...recentScoped, ...olderScoped] + : recentScoped; + return sortConversationsByField(visible, conversationSort); + }, [recentScoped, olderScoped, showOlderConversations, conversationSort]); + + const groupLabels = React.useMemo( + () => ({ + emptyWorkspace: t(I18nKey.CONVERSATION_PANEL$NO_WORKSPACE), + emptyRepository: t(I18nKey.CONVERSATION_PANEL$NO_REPOSITORY), + }), + [t], + ); + + const groupedSourceConversations = React.useMemo(() => { + if (compact || organizeMode !== "grouped") { + return null; + } + // Use the unsorted partitions: groupConversations sorts each bucket + // internally by `sortField`, so pre-sorting the merged input is wasted + // work in grouped mode (the per-group sort overrides any global order). + return [...recentScoped, ...(showOlderConversations ? olderScoped : [])]; + }, [ + compact, + olderScoped, + organizeMode, + recentScoped, + showOlderConversations, + ]); + + const conversationGroups = React.useMemo(() => { + if (!groupedSourceConversations) { + return null; + } + // Keep every loaded conversation in the group model. Folder discovery only + // freezes the collapsed preview — expanding a folder must reach later-page + // rows for that same workspace/repo. + return groupConversations( + groupedSourceConversations, + activeBackend.kind, + conversationSort, + groupLabels, + allWorkspacesForGrouping, + ); + }, [ + activeBackend.kind, + conversationSort, + groupLabels, + groupedSourceConversations, + allWorkspacesForGrouping, + ]); + + const groupDiscoveryConversationIds = React.useMemo(() => { + if (!groupedSourceConversations) { + return null; + } + return getGroupDiscoveryConversationIds( + groupedSourceConversations, + conversationPageById, + activeBackend.kind, + { forceIncludeConversationId: currentConversationId }, + ); + }, [ + activeBackend.kind, + conversationPageById, + currentConversationId, + groupedSourceConversations, + ]); + + const orderedConversationGroups = React.useMemo(() => { + if (!conversationGroups) { + return null; + } + return applyGroupFolderOrder(conversationGroups, groupFolderOrder); + }, [conversationGroups, groupFolderOrder]); + + const conversationGroupIds = React.useMemo( + () => conversationGroups?.map((group) => group.id) ?? [], + [conversationGroups], + ); + + const compactVisibleConversations = React.useMemo( + () => + sortConversationsByField( + recentScoped.filter((conversation) => + isExecutionActive(conversation.execution_status), + ), + conversationSort, + ), + [conversationSort, recentScoped], + ); + + const visibleFlatCount = sortedVisibleConversations.length; + + const visibleGroupCount = orderedConversationGroups?.length ?? 0; + + const listIsEffectivelyEmpty = + organizeMode === "grouped" && !compact + ? visibleGroupCount === 0 + : visibleFlatCount === 0; + + // Attribution is exact: the automation filter step itself produced zero + // rows out of a non-empty loaded set (not merely threadScope/older-cutoff + // effects). Used to pick the empty-state message. + const emptyDueToAutomationFilter = + listIsEffectivelyEmpty && + automationFilterMode !== "all" && + conversations.length > 0 && + automationFilteredConversations.length === 0; + + // Same attribution for the tag filter: it produced zero rows out of what + // the automation filter left behind. + const emptyDueToTagFilter = + listIsEffectivelyEmpty && + selectedTagFacets.length > 0 && + automationFilteredConversations.length > 0 && + tagFilteredConversations.length === 0; + + // Grouped pagination prefers discovering another folder; chronological + // pagination succeeds when another row appears. Pages that only deepen + // already-visible folders are not success for the grouped control — the + // driver walks past them (bounded by the per-click page cap) so a single + // click can still reach a folder hiding behind deepen-only pages. + const visibleCount = + organizeMode === "grouped" && !compact + ? visibleGroupCount + : visibleFlatCount; + const loadedPageCount = data?.pages.length ?? 0; + + // KNOWN ISSUE (unresolved as of 2026-05-29): users still report that the + // sidebar "Load more" sometimes requires two clicks before new conversations + // appear. The mitigation below (dedupe by id in `conversations`, plus the + // floor-tracking driver that keeps fetching until the visible count grows) + // reduced but did NOT fully eliminate the symptom in manual testing. Likely + // remaining suspects to investigate next: the agent-server cursor pagination + // returning an overlapping/short page under `UPDATED_AT_DESC` while the 10s + // `refetchInterval` reorders pages (see `usePaginatedConversations`), or a + // React Query state lag where `hasNextPage`/`isFetching` settle a render + // after the click. If you pick this up, reproduce against a backend with + // >40 conversations and watch the `/api/conversations/search` cursors. + // + // Robust "Load more" driver. A single click can fail to surface new rows for + // two reasons: (1) `fetchNextPage()` is silently dropped while the 10s + // background refetch is in flight, and (2) a fetched page can yield zero + // *visible* rows (filtered out by the active scope, or deduped as overlap), + // so the list does not appear to grow. We capture floors at click time and + // keep fetching — once idle — until the visible count grows, the grouped + // per-click page cap is hit, or pages run out. `loadedPageCount` keeps the + // driver advancing when a fetched page contains only folders that were + // already discovered. + const [loadMoreFloor, setLoadMoreFloor] = React.useState(null); + const [loadMorePageFloor, setLoadMorePageFloor] = React.useState< + number | null + >(null); + const visibleCountRef = React.useRef(visibleCount); + visibleCountRef.current = visibleCount; + const loadedPageCountRef = React.useRef(loadedPageCount); + loadedPageCountRef.current = loadedPageCount; + + const clearLoadMoreRequest = React.useCallback(() => { + setLoadMoreFloor(null); + setLoadMorePageFloor(null); + }, []); + + const requestLoadMore = React.useCallback(() => { + if (hasNextPage) { + setLoadMoreFloor(visibleCountRef.current); + setLoadMorePageFloor(loadedPageCountRef.current); + } + }, [hasNextPage]); + + React.useEffect(() => { + if (loadMoreFloor === null) { + return; + } + // Goal met: the visible list grew past where it was when the user clicked. + if (visibleCount > loadMoreFloor) { + clearLoadMoreRequest(); + return; + } + // Hard cap (grouped only): pages that merely deepen already-visible + // folders are walked past — a new folder may sit right behind them — but + // never unbounded many, so one click cannot drain the whole cursor. + // Chronological mode keeps its pre-existing behavior: fetch until a + // visible row appears or pages run out. + if ( + organizeMode === "grouped" && + !compact && + loadMorePageFloor != null && + loadedPageCount >= loadMorePageFloor + MAX_PAGES_PER_LOAD_MORE_CLICK + ) { + clearLoadMoreRequest(); + return; + } + // Wait for any in-flight fetch (including the background refetch) to settle + // before evaluating `hasNextPage`; React Query may transiently clear that + // flag while replacing the last page. + if (isFetching || isFetchingNextPage) { + return; + } + // Nothing more to fetch — stop waiting even if the list did not grow. + if (!hasNextPage) { + clearLoadMoreRequest(); + return; + } + fetchNextPage(); + }, [ + clearLoadMoreRequest, + compact, + loadMoreFloor, + loadMorePageFloor, + visibleCount, + loadedPageCount, + organizeMode, + hasNextPage, + isFetching, + isFetchingNextPage, + fetchNextPage, + ]); + + const isLoadingMore = loadMoreFloor !== null || isFetchingNextPage; + + const { mutate: deleteConversation, mutateAsync: deleteConversationAsync } = + useDeleteConversation(); + const { mutate: pauseConversation } = useUnifiedPauseConversation(); + const { mutate: updateConversation } = useUpdateConversation(); + const { mutate: updateConversationTags } = useUpdateConversationTags(); + + // The next page of conversations is loaded only via the explicit "Load + // more" link rendered at the end of the list — there is no scroll-driven + // pagination, which previously caused the panel to feel like it had stray + // scrollable space at the bottom. + const olderHidden = olderScoped.length > 0 && !showOlderConversations; + // Compact mode also hides "Load more" — paginating into stale conversations + // contradicts the "active only" intent of the icon rail. + // Availability tracks backend exhaustion, never visible emptiness: a + // client-side filter (archiving, the automation filter) can hide every row + // of the loaded pages, and hiding the control there would strand the + // remaining pages behind an empty-state message with no way forward. + // `requestLoadMore`'s floor driver keeps paging until a visible row + // appears, so a single click walks past pages that are entirely filtered. + const showLoadMore = !!hasNextPage && !olderHidden && !compact; + + const { mutate: createConversation } = useCreateConversation(); + const isCreatingConversationFlow = useIsCreatingConversation(); + + const launchFromGroup = React.useCallback( + (launch: ConversationGroupLaunch) => { + if (isCreatingConversationFlow) return; + createConversation( + { + workingDir: launch.workingDir, + repository: launch.repository, + entryPoint: "sidebar_relaunch_project", + }, + { + onSuccess: (data) => { + navigate(`/conversations/${data.conversation_id}`); + }, + }, + ); + }, + [createConversation, isCreatingConversationFlow, navigate], + ); + + const handleDeleteProject = React.useCallback( + (conversationId: string, title: string) => { + setConfirmDeleteModalVisible(true); + setSelectedConversationId(conversationId); + setSelectedConversationTitle(title); + }, + [], + ); + + const handleArchiveProject = React.useCallback( + (conversationId: string, title: string) => { + setConfirmArchiveModalVisible(true); + setSelectedConversationId(conversationId); + setSelectedConversationTitle(title); + }, + [], + ); + + // Editing tags is a local agent-server affordance: Cloud conversations + // don't carry server-side tags (`tags` stays null), so the card leaves + // `onEditTags` undefined on Cloud and the menu item never appears there. + const handleEditTags = React.useCallback((conversationId: string) => { + setEditTagsModalVisible(true); + setSelectedConversationId(conversationId); + }, []); + + const handleConfirmEditTags = React.useCallback( + (mergedTags: Record) => { + if (!selectedConversationId) { + return; + } + updateConversationTags( + { conversationId: selectedConversationId, tags: mergedTags }, + { + onSuccess: () => { + displaySuccessToast(t(I18nKey.CONVERSATION$TAGS_UPDATED)); + }, + }, + ); + }, + [selectedConversationId, t, updateConversationTags], + ); + + // Unarchiving needs no confirmation: it restores a row the user can archive + // again in one click, and nothing about the conversation itself changes. + const handleUnarchiveProject = React.useCallback( + (conversationId: string) => { + removeArchivedConversation(activeBackend.id, conversationId); + }, + [activeBackend.id, removeArchivedConversation], + ); + + const handleStopConversation = React.useCallback((conversationId: string) => { + setConfirmStopModalVisible(true); + setSelectedConversationId(conversationId); + }, []); + + const handleConversationTitleChange = React.useCallback( + (conversationId: string, newTitle: string) => { + updateConversation( + { conversationId, newTitle }, + { + onSuccess: () => { + displaySuccessToast(t(I18nKey.CONVERSATION$TITLE_UPDATED)); + }, + }, + ); + }, + [t, updateConversation], + ); + + const handleConfirmDelete = () => { + if (selectedConversationId) { + const conversationId = selectedConversationId; + deleteConversation( + { conversationId }, + { + onSuccess: () => { + removeArchivedConversation(activeBackend.id, conversationId); + if (conversationId === currentConversationId) { + navigate("/conversations"); + } + }, + }, + ); + } + }; + + const handleConfirmArchive = () => { + if (!selectedConversationId) { + return; + } + archiveConversation(activeBackend.id, selectedConversationId); + unpinConversation(activeBackend.id, selectedConversationId); + if (selectedConversationId === currentConversationId) { + navigate("/conversations"); + } + }; + + const handleConfirmStop = () => { + if (selectedConversationId) { + pauseConversation({ + conversationId: selectedConversationId, + }); + } + }; + + const handleConfirmDeleteAll = async () => { + // Delete against the unfiltered loaded set so archived (currently hidden) + // conversations are still removed from the server — matching the action's + // "delete all conversations" label and confirmation count. + const idsToDelete = allLoadedConversations.map((c) => c.id); + const results = await Promise.allSettled( + idsToDelete.map((conversationId) => + deleteConversationAsync({ conversationId }), + ), + ); + + const deletedIds = results.flatMap((result, index) => + result.status === "fulfilled" ? [idsToDelete[index]] : [], + ); + const failedCount = results.length - deletedIds.length; + + for (const conversationId of deletedIds) { + removeArchivedConversation(activeBackend.id, conversationId); + } + + if ( + currentConversationId !== null && + deletedIds.includes(currentConversationId) + ) { + navigate("/conversations"); + } + + if (failedCount > 0) { + displayErrorToast( + `${failedCount} conversation${failedCount === 1 ? "" : "s"} could not be deleted.`, + ); + } + }; + + const renderConversationCard = React.useCallback( + ( + conversation: (typeof conversations)[number], + options?: { inPinnedSection?: boolean }, + ) => { + const isPinned = pinnedIds.includes(conversation.id); + const isArchived = archivedIdSet.has(conversation.id); + if (compact) { + return ( + + ); + } + return ( + + } + > + + + handleDeleteProject(conversation.id, conversation.title ?? "") + } + // Exactly one direction is offered per row, so the menu always + // reflects the conversation's current archived state. + onArchive={ + isArchived + ? undefined + : () => + handleArchiveProject( + conversation.id, + conversation.title ?? "", + ) + } + onUnarchive={ + isArchived + ? () => handleUnarchiveProject(conversation.id) + : undefined + } + onStop={() => handleStopConversation(conversation.id)} + onEditTags={ + activeBackend.kind === "local" + ? () => handleEditTags(conversation.id) + : undefined + } + onChangeTitle={(title) => + handleConversationTitleChange(conversation.id, title) + } + title={conversation.title ?? ""} + selectedRepository={{ + selected_repository: conversation.selected_repository, + selected_branch: conversation.selected_branch, + git_provider: conversation.git_provider as Provider, + }} + lastUpdatedAt={conversation.updated_at} + createdAt={conversation.created_at} + executionStatus={conversation.execution_status} + sandboxStatus={conversation.sandbox_status} + conversationId={conversation.id} + contextMenuOpen={openContextMenuId === conversation.id} + onContextMenuToggle={(isOpen) => + setOpenContextMenuId(isOpen ? conversation.id : null) + } + isActive={conversation.id === currentConversationId} + workspaceWorkingDir={ + conversation.selected_workspace ?? + conversation.workspace?.working_dir + } + showRepositoryMetadata={showRepoBranchMetadata} + llmModel={conversation.llm_model} + showLlmProfiles={showLlmProfiles} + agentKind={conversation.agent_kind} + acpServer={conversation.acp_server} + tags={conversation.tags} + showTags={showTagsMetadata} + isArchived={isArchived} + isPinned={isPinned} + onTogglePin={() => togglePin(activeBackend.id, conversation.id)} + alwaysShowPinIcon={isPinned && !options?.inPinnedSection} + /> + + + ); + }, + [ + activeBackend.id, + activeBackend.kind, + archivedIdSet, + compact, + currentConversationId, + handleArchiveProject, + handleConversationTitleChange, + handleDeleteProject, + handleEditTags, + handleStopConversation, + handleUnarchiveProject, + onClose, + openContextMenuId, + pinnedIds, + showRepoBranchMetadata, + showLlmProfiles, + showTagsMetadata, + showHoverMetadata, + togglePin, + ], + ); + + // Standard layout: panel fills its slot in the sidebar; the inner scroll + // child fills the panel and scrolls when its content overflows. Modals are + // siblings of the scroll element and are `position: fixed`, so they don't + // participate in the panel's scroll geometry. + // Gate on `isLoading` / `!isFetched` (true only until the first fetch settles), + // not `isFetching` — the latter flips back to true on every 10s background + // refetch, causing the skeleton/empty-state to flicker when the list is empty. + const showInitialSkeleton = isLoading || !isFetched; + const showPinnedSection = + !compact && !showInitialSkeleton && pinnedConversations.length > 0; + const hasVisibleGroups = + organizeMode === "grouped" && + !compact && + orderedConversationGroups != null && + orderedConversationGroups.length > 0; + const showEmptyState = + isFetched && + !isLoading && + !compact && + listIsEffectivelyEmpty && + !showPinnedSection && + !startTasks?.length && + !hasVisibleGroups; + + const showConversationHeader = !compact; + + return ( +
    + {showConversationHeader && ( +
    +
    + + {t(I18nKey.SIDEBAR$CONVERSATIONS)} + +
    + + setConfirmDeleteAllVisible(true)} + /> +
    +
    +
    + )} + + {/* Sits above the list, not inside the scroll container: a filter that + scrolls out of view is a filter the user can't see. */} + {!compact && ( + + )} + +
    { + setIsListScrolled(event.currentTarget.scrollTop > 0); + }} + className={cn( + "flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain custom-scrollbar-always", + !compact && "conversation-panel-list-scroll", + )} + > + {showInitialSkeleton && } + + {!compact && showEmptyState && ( +
    +

    + {t( + emptyDueToAutomationFilter + ? I18nKey.CONVERSATION_PANEL$NO_AUTOMATION_MATCHES + : emptyDueToTagFilter + ? I18nKey.CONVERSATION_PANEL$NO_TAG_MATCHES + : I18nKey.CONVERSATION$NO_CONVERSATIONS, + )} +

    +
    + )} + + {showPinnedSection ? ( + + setExpandedPinnedPreview((current) => !current) + } + activeConversationId={currentConversationId} + showDivider={!compact && organizeMode === "chronological"} + renderConversationCard={(conversation) => + renderConversationCard(conversation, { inPinnedSection: true }) + } + /> + ) : null} + + {/* Render in-progress start tasks first (skipped in compact mode — + their rich card layout doesn't fit in the icon rail). */} + {!compact && + startTasks?.map((task) => ( + + + + ))} + + {!showInitialSkeleton && compact + ? compactVisibleConversations.map((conversation) => + renderConversationCard(conversation), + ) + : null} + + {!showInitialSkeleton && + !compact && + organizeMode === "grouped" && + orderedConversationGroups && + orderedConversationGroups.length > 0 ? ( + + renderConversationCard(conversation) + } + /> + ) : null} + + {!showInitialSkeleton && + !compact && + organizeMode === "chronological" ? ( +
    + {sortedVisibleConversations.map((conversation) => + renderConversationCard(conversation), + )} +
    + ) : null} + + {/* Explicit "Load more" trigger. Only shown when more pages exist + *and* the older list is currently visible (or there are no older + conversations to begin with) — otherwise the next page would be + populated mostly with conversations the user has chosen to hide. */} + {showLoadMore && + (isLoadingMore ? ( +
    + +
    + ) : ( +
    + +
    + ))} +
    + + {confirmDeleteModalVisible && ( + { + handleConfirmDelete(); + setConfirmDeleteModalVisible(false); + setSelectedConversationTitle(null); + }} + onCancel={() => { + setConfirmDeleteModalVisible(false); + setSelectedConversationTitle(null); + }} + conversationTitle={selectedConversationTitle ?? undefined} + /> + )} + + {confirmArchiveModalVisible && ( + { + handleConfirmArchive(); + setConfirmArchiveModalVisible(false); + setSelectedConversationTitle(null); + }} + onCancel={() => { + setConfirmArchiveModalVisible(false); + setSelectedConversationTitle(null); + }} + conversationTitle={selectedConversationTitle ?? undefined} + /> + )} + + {editTagsModalVisible && ( + conversation.id === selectedConversationId, + )?.tags + } + onConfirm={(mergedTags) => { + handleConfirmEditTags(mergedTags); + setEditTagsModalVisible(false); + }} + onCancel={() => setEditTagsModalVisible(false)} + /> + )} + + {confirmDeleteAllVisible && ( + { + await handleConfirmDeleteAll(); + setConfirmDeleteAllVisible(false); + }} + onCancel={() => setConfirmDeleteAllVisible(false)} + /> + )} + + {confirmStopModalVisible && ( + { + handleConfirmStop(); + setConfirmStopModalVisible(false); + }} + onCancel={() => setConfirmStopModalVisible(false)} + /> + )} + + {confirmExitConversationModalVisible && ( + { + onClose?.(); + }} + onClose={() => setConfirmExitConversationModalVisible(false)} + onCancel={() => setConfirmExitConversationModalVisible(false)} + /> + )} +
    + ); +} diff --git a/src/components/features/conversation-panel/conversation-status-dot.tsx b/src/components/features/conversation-panel/conversation-status-dot.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0899128678ab21780b4ea2d218a08e2abb0e0c11 --- /dev/null +++ b/src/components/features/conversation-panel/conversation-status-dot.tsx @@ -0,0 +1,166 @@ +import { useTranslation } from "react-i18next"; +import { FaArchive } from "react-icons/fa"; +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; +import { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; + +interface ConversationStatusDotProps { + executionStatus: ExecutionStatus | null | undefined; + /** + * Cloud-only sandbox lifecycle status. When provided, MISSING and ERROR + * override the execution-status visual so the dot reflects the sandbox + * state rather than the last agent execution state. + */ + sandboxStatus?: SandboxStatus | null; + /** + * Wrap the dot in a tooltip showing the human-readable status label. + * Disable this when the dot is already nested inside a larger tooltip + * (e.g. the collapsed-sidebar conversation preview) so the smaller + * tooltip doesn't intercept the hover. + */ + showTooltip?: boolean; +} + +type Visual = "check" | "working" | "active" | "paused" | "error" | "unknown"; + +const visualFor = (status: ExecutionStatus | null | undefined): Visual => { + switch (status) { + case ExecutionStatus.FINISHED: + return "check"; + case ExecutionStatus.RUNNING: + return "working"; + case ExecutionStatus.IDLE: + case ExecutionStatus.WAITING_FOR_CONFIRMATION: + return "active"; + case ExecutionStatus.PAUSED: + return "paused"; + case ExecutionStatus.ERROR: + case ExecutionStatus.STUCK: + return "error"; + default: + return "unknown"; + } +}; + +const labelKeyFor = (visual: Visual, isArchived?: boolean): string => { + if (isArchived) return "COMMON$ARCHIVED"; + switch (visual) { + case "check": + return "COMMON$FINISHED"; + case "working": + case "active": + return "COMMON$WORKING"; + case "paused": + return "COMMON$PAUSED"; + case "error": + return "COMMON$ERROR"; + default: + return "COMMON$STOPPED"; + } +}; + +function renderIndicator(visual: Visual) { + switch (visual) { + case "check": + return ( + + ); + case "working": + return ( + + ); + case "active": + return ( + + ); + case "paused": + return ( + + ); + case "error": + return ( + + ); + default: + return ( + + ); + } +} + +export function ConversationStatusDot({ + executionStatus, + sandboxStatus, + showTooltip = true, +}: ConversationStatusDotProps) { + const { t } = useTranslation("openhands"); + + // sandbox_status === "MISSING" → show archived (gray) dot + // sandbox_status === "ERROR" → show error (red) dot + // Otherwise fall through to the execution-status visual. + const isArchived = sandboxStatus === "MISSING"; + const effectiveVisual: Visual = + sandboxStatus === "ERROR" + ? "error" + : isArchived + ? "paused" + : visualFor(executionStatus); + + const visual = effectiveVisual; + const label = t(labelKeyFor(visual, isArchived)); + const indicator = isArchived ? ( + + ) : ( + renderIndicator(visual) + ); + + const dot = ( +
    + {indicator} +
    + ); + + if (!showTooltip) return dot; + + return ( + + {dot} + + ); +} diff --git a/src/components/features/conversation-panel/edit-conversation-tags-modal.tsx b/src/components/features/conversation-panel/edit-conversation-tags-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..660a6ba963a999304aac270c2ab67ae5f8bd13b9 --- /dev/null +++ b/src/components/features/conversation-panel/edit-conversation-tags-modal.tsx @@ -0,0 +1,266 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import { + BaseModalDescription, + BaseModalTitle, +} from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { BrandButton } from "../settings/brand-button"; +import { I18nKey } from "#/i18n/declaration"; +import { + getDisplayConversationTags, + RESERVED_CONVERSATION_TAG_KEYS, +} from "#/api/agent-server-adapter"; + +/** Backend rule for tag keys (see `ConversationInfo.tags` in the SDK). */ +export const CONVERSATION_TAG_KEY_PATTERN = /^[a-z0-9]+$/; +/** Backend cap on tag values (see `ConversationInfo.tags` in the SDK). */ +export const CONVERSATION_TAG_VALUE_MAX_LENGTH = 256; + +interface TagRow { + key: string; + value: string; +} + +/** + * Merges the user's edited display tags with every entry the display helper + * drops from the current map (reserved/internal keys, whitespace-only or + * non-string values), so the replace-all PATCH never silently discards + * internal tags. Bare tags (empty-string values) are user-manageable and + * come through `editedUserTags` like any valued tag. + */ +export function mergeConversationTagEdits( + currentTags: Record | null | undefined, + editedUserTags: readonly (readonly [string, string])[], +): Record { + const merged: Record = {}; + for (const [key, value] of Object.entries(currentTags ?? {})) { + // Preserve what the editor does not surface: reserved keys, non-string + // values, and whitespace-only values (raw-write junk the display drops). + // Empty-string values are bare tags — the editor manages them, so they + // must NOT be preserved here or a removed bare tag would resurrect. + if ( + RESERVED_CONVERSATION_TAG_KEYS.has(key.trim().toLowerCase()) || + typeof value !== "string" || + (value !== "" && value.trim().length === 0) + ) { + merged[key] = value; + } + } + for (const [key, value] of editedUserTags) { + merged[key] = value; + } + return merged; +} + +interface EditConversationTagsModalProps { + /** The conversation's complete server-side tag map (including internals). */ + tags: Record | null | undefined; + /** Called with the merged complete map (user edits + preserved internals). */ + onConfirm: (mergedTags: Record) => void; + onCancel: () => void; +} + +export function EditConversationTagsModal({ + tags, + onConfirm, + onCancel, +}: EditConversationTagsModalProps) { + const { t } = useTranslation("openhands"); + const [rows, setRows] = React.useState(() => + getDisplayConversationTags(tags).map(([key, value]) => ({ key, value })), + ); + const [newKey, setNewKey] = React.useState(""); + const [newValue, setNewValue] = React.useState(""); + const [errorKey, setErrorKey] = React.useState(null); + const keyInputRef = React.useRef(null); + + /** + * Validates whatever is sitting in the key/value inputs against the backend's + * tag rules and the rows already staged. Shared by Add and Save so both + * doors apply the same checks. + */ + const buildPendingRow = (): { row: TagRow } | { errorKey: I18nKey } => { + const key = newKey.trim(); + const value = newValue.trim(); + if (!CONVERSATION_TAG_KEY_PATTERN.test(key)) { + return { errorKey: I18nKey.CONVERSATION$TAG_KEY_INVALID }; + } + if (RESERVED_CONVERSATION_TAG_KEYS.has(key)) { + return { errorKey: I18nKey.CONVERSATION$TAG_KEY_RESERVED }; + } + if (rows.some((row) => row.key === key)) { + return { errorKey: I18nKey.CONVERSATION$TAG_KEY_DUPLICATE }; + } + // The value is optional: an empty value is a bare tag — stored as "" (the + // backend validator allows it) and rendered as just the key. + if (value.length > CONVERSATION_TAG_VALUE_MAX_LENGTH) { + return { errorKey: I18nKey.CONVERSATION$TAG_VALUE_INVALID }; + } + return { row: { key, value } }; + }; + + const handleAdd = () => { + const result = buildPendingRow(); + if ("errorKey" in result) { + setErrorKey(result.errorKey); + return; + } + setRows((prev) => [...prev, result.row]); + setNewKey(""); + setNewValue(""); + setErrorKey(null); + // Ready for the next tag: focus returns to the key box, not the value box. + keyInputRef.current?.focus(); + }; + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + handleAdd(); + } + }; + + const handleRemove = (key: string) => { + setRows((prev) => prev.filter((row) => row.key !== key)); + }; + + const handleConfirm = () => { + // Add is an affordance for entering several tags, not a toll on entering + // one: a tag typed into the inputs and never Added is still a tag the user + // asked for, so Save commits it. If it doesn't validate, surface the error + // and keep the modal open — never drop the typing on the floor. + const hasPendingInput = newKey.trim() !== "" || newValue.trim() !== ""; + let committedRows = rows; + if (hasPendingInput) { + const result = buildPendingRow(); + if ("errorKey" in result) { + setErrorKey(result.errorKey); + return; + } + committedRows = [...rows, result.row]; + } + + onConfirm( + mergeConversationTagEdits( + tags, + committedRows.map((row) => [row.key, row.value] as const), + ), + ); + }; + + return ( + + +
    + + + {t(I18nKey.CONVERSATION$EDIT_TAGS_DESCRIPTION)} + +
    + +
    event.stopPropagation()} + > + {rows.length > 0 ? ( +
      + {rows.map((row) => ( +
    • + + {row.value ? `${row.key}=${row.value}` : row.key} + + +
    • + ))} +
    + ) : null} + +
    + setNewKey(event.target.value)} + onKeyDown={handleInputKeyDown} + className="w-28 shrink-0 rounded-md border border-[var(--oh-border)] bg-base-secondary px-2 py-1 text-xs text-white placeholder:text-[var(--oh-muted)]" + /> + setNewValue(event.target.value)} + onKeyDown={handleInputKeyDown} + className="min-w-0 flex-1 rounded-md border border-[var(--oh-border)] bg-base-secondary px-2 py-1 text-xs text-white placeholder:text-[var(--oh-muted)]" + /> + + {t(I18nKey.BUTTON$ADD)} + +
    + + {errorKey ? ( +

    + {t(errorKey)} +

    + ) : null} +
    + +
    event.stopPropagation()} + > + + {t(I18nKey.BUTTON$CANCEL)} + + + {t(I18nKey.BUTTON$SAVE)} + +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/ellipsis-button.tsx b/src/components/features/conversation-panel/ellipsis-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e8e381f5bd2f4a986636c550c437cba0d469018 --- /dev/null +++ b/src/components/features/conversation-panel/ellipsis-button.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react"; +import { cn } from "#/utils/utils"; +import { + formControlTransitionClassName, + formControlMutedHoverClassName, +} from "#/utils/form-control-classes"; + +interface EllipsisButtonProps { + onClick: (event: React.MouseEvent) => void; + /** + * Optional override classes applied to the button. Use this when a + * caller needs to customize layout (e.g. translate, flex), not to + * replace hover treatment which is intentionally shared. + */ + className?: string; + /** Icon box; default `w-4 h-4` (use `mobileTopBarIconClassName` in mobile top bars). */ + iconClassName?: string; + ariaLabel?: string; + /** + * Override the default `ellipsis-button` test id. Most callers + * should leave this unset; provide a custom id only when an + * existing test suite already targets a specific id (e.g. the + * `profile-menu-trigger` in LLM profile rows). + */ + testId?: string; +} + +/** + * Standardized "three vertical dots" overflow trigger. + * + * Use this anywhere the chrome needs an inline ellipsis menu trigger + * (conversation header, conversation cards in the side panel, + * conversation tabs, settings rows, etc.) so that size, color, and + * hover treatment stay consistent. + * + * The chat input area's overflow button uses its own pill-shaped + * variant with a custom hover background — do NOT replace that one + * with this component; it is intentionally different. + */ +export const EllipsisButton = React.forwardRef< + HTMLButtonElement, + EllipsisButtonProps +>(function EllipsisButton( + { onClick, className, iconClassName, ariaLabel, testId = "ellipsis-button" }, + ref, +) { + return ( + + ); +}); diff --git a/src/components/features/conversation-panel/exit-conversation-modal.tsx b/src/components/features/conversation-panel/exit-conversation-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..08f9458e872909059587677c9054f51f6130e0ba --- /dev/null +++ b/src/components/features/conversation-panel/exit-conversation-modal.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { ModalButton } from "#/components/shared/buttons/modal-button"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; +import { I18nKey } from "#/i18n/declaration"; + +interface ExitConversationModalProps { + onConfirm: () => void; + onClose: () => void; + onCancel: () => void; +} + +export function ExitConversationModal({ + onConfirm, + onClose, + onCancel, +}: ExitConversationModalProps) { + const { t } = useTranslation("openhands"); + + return ( + + + +
    + + +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/hide-older-conversations-row.tsx b/src/components/features/conversation-panel/hide-older-conversations-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d17ff91550831eba8652fc76171d0ec6ba1b131d --- /dev/null +++ b/src/components/features/conversation-panel/hide-older-conversations-row.tsx @@ -0,0 +1,98 @@ +import { useTranslation } from "react-i18next"; +import { EyeOff } from "lucide-react"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { + dropdownMenuRowClassName, + dropdownMenuRowIconClassName, +} from "#/utils/dropdown-classes"; +import { ToggleSwitchVisual } from "#/ui/toggle-switch"; +import { EnumFilterDropdown } from "#/components/shared/filters/enum-filter-dropdown"; +import { useConversationPanelPreferencesStore } from "#/stores/conversation-panel-preferences-store"; +import { + DEFAULT_OLDER_CONVERSATION_CUTOFF, + isOlderConversationCutoff, + OLDER_CONVERSATION_CUTOFFS, +} from "./conversation-panel-list-helpers"; + +const CUTOFF_LABEL_KEYS = { + "1h": I18nKey.CONVERSATION_PANEL$OLDER_OVER_ONE_HOUR, + "1d": I18nKey.CONVERSATION_PANEL$OLDER_OVER_ONE_DAY, + "7d": I18nKey.CONVERSATION_PANEL$OLDER_OVER_ONE_WEEK, + "30d": I18nKey.CONVERSATION_PANEL$OLDER_OVER_THIRTY_DAYS, +} as const; + +/** + * Hide-older control: a toggle plus an interval select. The persisted + * preference is still `showOlderConversations` (true = show everything); + * the toggle is inverted so on means hide. + */ +export function HideOlderConversationsRow() { + const { t } = useTranslation("openhands"); + const showOlderConversations = useConversationPanelPreferencesStore( + (state) => state.showOlderConversations, + ); + const olderConversationCutoff = useConversationPanelPreferencesStore( + (state) => state.olderConversationCutoff, + ); + const toggleShowOlderConversations = useConversationPanelPreferencesStore( + (state) => state.toggleShowOlderConversations, + ); + const setOlderConversationCutoff = useConversationPanelPreferencesStore( + (state) => state.setOlderConversationCutoff, + ); + + const hideOlder = !showOlderConversations; + const cutoff = isOlderConversationCutoff(olderConversationCutoff) + ? olderConversationCutoff + : DEFAULT_OLDER_CONVERSATION_CUTOFF; + + return ( +
    { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + toggleShowOlderConversations(); + } + }} + className={cn( + "group", + dropdownMenuRowClassName, + "text-[var(--oh-foreground)]", + )} + > + + + {t(I18nKey.CONVERSATION_PANEL$HIDE_CONVERSATIONS)} + +
    event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + +
    + +
    + ); +} diff --git a/src/components/features/conversation-panel/hook-event-item.tsx b/src/components/features/conversation-panel/hook-event-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..82a4c4228f5d529ec10611cb7dee296702ab2480 --- /dev/null +++ b/src/components/features/conversation-panel/hook-event-item.tsx @@ -0,0 +1,82 @@ +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Typography } from "#/ui/typography"; +import { HookEvent } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { HookMatcherContent } from "./hook-matcher-content"; +import { I18nKey } from "#/i18n/declaration"; + +interface HookEventItemProps { + hookEvent: HookEvent; + isExpanded: boolean; + onToggle: (eventType: string) => void; +} + +const EVENT_TYPE_I18N_KEYS: Record = { + pre_tool_use: I18nKey.HOOKS_MODAL$EVENT_PRE_TOOL_USE, + post_tool_use: I18nKey.HOOKS_MODAL$EVENT_POST_TOOL_USE, + user_prompt_submit: I18nKey.HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT, + session_start: I18nKey.HOOKS_MODAL$EVENT_SESSION_START, + session_end: I18nKey.HOOKS_MODAL$EVENT_SESSION_END, + stop: I18nKey.HOOKS_MODAL$EVENT_STOP, +}; + +const HOOK_PILL_CLASS = + "inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-[var(--oh-border)] bg-[var(--oh-surface)] text-tertiary-light"; + +const HOOK_COUNT_I18N_KEY = "HOOKS_MODAL$HOOK_COUNT"; + +export function HookEventItem({ + hookEvent, + isExpanded, + onToggle, +}: HookEventItemProps) { + const { t } = useTranslation("openhands"); + const i18nKey = EVENT_TYPE_I18N_KEYS[hookEvent.event_type]; + const eventTypeLabel = i18nKey ? t(i18nKey) : hookEvent.event_type; + + const totalHooks = hookEvent.matchers.reduce( + (sum, matcher) => sum + (matcher.hooks ?? []).length, + 0, + ); + + return ( +
    + + + {isExpanded && ( +
    +
    + {hookEvent.matchers.map((matcher, index) => ( + + ))} +
    +
    + )} +
    + ); +} diff --git a/src/components/features/conversation-panel/hook-matcher-content.tsx b/src/components/features/conversation-panel/hook-matcher-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a6b7f306305029048e15c3413e7f8911e9ca6f1 --- /dev/null +++ b/src/components/features/conversation-panel/hook-matcher-content.tsx @@ -0,0 +1,62 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { Pre } from "#/ui/pre"; +import { HookMatcher } from "#/api/conversation-service/agent-server-conversation-service.types"; + +interface HookMatcherContentProps { + matcher: HookMatcher; +} + +const HOOK_PILL_CLASS = + "inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-[var(--oh-border)] bg-[var(--oh-surface)] text-tertiary-light"; + +export function HookMatcherContent({ matcher }: HookMatcherContentProps) { + const { t } = useTranslation("openhands"); + + return ( +
    +
    + + {t(I18nKey.HOOKS_MODAL$MATCHER)} + + {matcher.matcher} +
    + +
    + + {t(I18nKey.HOOKS_MODAL$COMMANDS)} + + {(matcher.hooks ?? []).map((hook, index) => ( +
    +
    +              {hook.command}
    +            
    +
    + + {t(I18nKey.HOOKS_MODAL$TYPE, { type: hook.type })} + + + {t(I18nKey.HOOKS_MODAL$TIMEOUT, { timeout: hook.timeout })} + + {hook.async ? ( + + {t(I18nKey.HOOKS_MODAL$ASYNC)} + + ) : null} +
    +
    + ))} +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/hooks-empty-state.tsx b/src/components/features/conversation-panel/hooks-empty-state.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bc14c2564160cc3cea91509622a33adee2fd8bac --- /dev/null +++ b/src/components/features/conversation-panel/hooks-empty-state.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; + +interface HooksEmptyStateProps { + isError: boolean; +} + +export function HooksEmptyState({ isError }: HooksEmptyStateProps) { + const { t } = useTranslation("openhands"); + + return ( +
    + + {isError + ? t(I18nKey.COMMON$FETCH_ERROR) + : t(I18nKey.CONVERSATION$NO_HOOKS)} + +
    + ); +} diff --git a/src/components/features/conversation-panel/hooks-loading-state.tsx b/src/components/features/conversation-panel/hooks-loading-state.tsx new file mode 100644 index 0000000000000000000000000000000000000000..50f467c27c190a83cfb807f17cff911f4a97e1d0 --- /dev/null +++ b/src/components/features/conversation-panel/hooks-loading-state.tsx @@ -0,0 +1,7 @@ +export function HooksLoadingState() { + return ( +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/hooks-modal-header.tsx b/src/components/features/conversation-panel/hooks-modal-header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..949727e13626207bfda72567d020816f4a31a1b2 --- /dev/null +++ b/src/components/features/conversation-panel/hooks-modal-header.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { RefreshCw } from "lucide-react"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { cn } from "#/utils/utils"; + +interface HooksModalHeaderProps { + isLoading: boolean; + isRefetching: boolean; + onRefresh: () => void; + onClose: () => void; +} + +const ICON_BUTTON_CLASS = + "rounded-md p-1 text-white hover:bg-tertiary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"; + +export function HooksModalHeader({ + isLoading, + isRefetching, + onRefresh, + onClose, +}: HooksModalHeaderProps) { + const { t } = useTranslation("openhands"); + const refreshLabel = t(I18nKey.BUTTON$REFRESH); + + return ( + <> + +
    +
    + + + {t(I18nKey.HOOKS_MODAL$WARNING)} + +
    + + + +
    + + ); +} diff --git a/src/components/features/conversation-panel/hooks-modal.tsx b/src/components/features/conversation-panel/hooks-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..33926799fdac534a44fb7ec9f2efce91e3fbff17 --- /dev/null +++ b/src/components/features/conversation-panel/hooks-modal.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { useConversationHooks } from "#/hooks/query/use-conversation-hooks"; +import { AgentState } from "#/types/agent-state"; +import { HooksModalHeader } from "./hooks-modal-header"; +import { HooksLoadingState } from "./hooks-loading-state"; +import { HooksEmptyState } from "./hooks-empty-state"; +import { HookEventItem } from "./hook-event-item"; +import { RuntimeWaitingState } from "./runtime-waiting-state"; +import { useAgentState } from "#/hooks/use-agent-state"; + +interface HooksModalProps { + onClose: () => void; +} + +export function HooksModal({ onClose }: HooksModalProps) { + const { curAgentState } = useAgentState(); + const [expandedEvents, setExpandedEvents] = useState>( + {}, + ); + const { + data: hooks, + isLoading, + isError, + refetch, + isRefetching, + } = useConversationHooks(); + + const toggleEvent = (eventType: string) => { + setExpandedEvents((prev) => ({ + ...prev, + [eventType]: !prev[eventType], + })); + }; + + const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes( + curAgentState, + ); + + return ( + + + + +
    + {!isAgentReady ? ( + + ) : isLoading ? ( + + ) : isError || !hooks || hooks.length === 0 ? ( + + ) : ( +
    + {hooks.map((hookEvent) => { + const isExpanded = + expandedEvents[hookEvent.event_type] || false; + + return ( + + ); + })} +
    + )} +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/local-new-conversation-menu.tsx b/src/components/features/conversation-panel/local-new-conversation-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..349bd0b829c76f0a214bc045c111cdc9ba338024 --- /dev/null +++ b/src/components/features/conversation-panel/local-new-conversation-menu.tsx @@ -0,0 +1,297 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { useNavigation } from "#/context/navigation-context"; +import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; +import { + useAddWorkspaces, + useAddWorkspaceParents, + useRemoveWorkspace, + useRemoveWorkspaceParent, +} from "#/hooks/mutation/use-local-workspaces-mutations"; +import { useLocalWorkspaces } from "#/hooks/query/use-local-workspaces"; +import { useResolvedWorkspaces } from "#/hooks/query/use-resolved-workspaces"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { + dropdownMenuRowClassName, + dropdownMenuListClassName, + dropdownMenuRowIconWrapperClassName, +} from "#/utils/dropdown-classes"; +import { getWorkspacesUnsupportedMessage } from "#/utils/workspaces-compatibility"; +import RepoIcon from "#/icons/repo.svg?react"; + +import { FolderBrowserModal } from "#/components/features/home/workspace-dropdown/folder-browser-modal"; +import { ManageWorkspacesModal } from "#/components/features/home/workspace-dropdown/manage-workspaces-modal"; + +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { Divider } from "#/ui/divider"; +import { NEW_CONVERSATION_DROPDOWN_SURFACE } from "./new-conversation-dropdown-styles"; +import { usePopoverFixedPlacement } from "#/hooks/use-popover-fixed-placement"; + +export type LocalNewConversationMenuTriggerProps = { + onClick: () => void; + "aria-expanded": boolean; + "aria-haspopup": "menu"; + disabled?: boolean; +}; + +export interface LocalNewConversationMenuProps { + trigger: (props: LocalNewConversationMenuTriggerProps) => React.ReactNode; + /** Root wrapper class (e.g. `relative` + alignment in header row) */ + className?: string; + /** Panel positioning / dimensions when using absolute placement (sidebar) */ + popoverClassName: string; + /** Optional test id for the popover surface */ + popoverTestId?: string; + /** + * Use `position: fixed` from the trigger rect so the menu is not clipped by + * sidebar overflow (conversation panel header). + */ + useFixedPlacement?: boolean; +} + +/** + * Workspace/repo picker + launch flow for local agent-server backends. + * Shared by the sidebar "+ New conversation" control and the conversation + * panel "new thread folder" control. + */ +export function LocalNewConversationMenu({ + trigger, + className, + popoverClassName, + popoverTestId = "new-conversation-popover", + useFixedPlacement = false, +}: LocalNewConversationMenuProps) { + const { t } = useTranslation("openhands"); + const { navigate } = useNavigation(); + + const [open, setOpen] = React.useState(false); + const popoverRef = React.useRef(null); + const triggerWrapRef = React.useRef(null); + const fixedBox = usePopoverFixedPlacement(triggerWrapRef, { + open, + enabled: useFixedPlacement, + }); + + const { data: workspacesData, error: workspacesError } = useLocalWorkspaces(); + const workspaceParents = workspacesData?.workspaceParents ?? []; + const { mutate: addWorkspaces } = useAddWorkspaces(); + const { mutate: removeWorkspace } = useRemoveWorkspace(); + const { mutate: addWorkspaceParents } = useAddWorkspaceParents(); + const { mutate: removeWorkspaceParent } = useRemoveWorkspaceParent(); + const { workspaces } = useResolvedWorkspaces(); + const workspacesUnsupportedMessage = getWorkspacesUnsupportedMessage( + workspacesError, + t, + ); + const [browserOpen, setBrowserOpen] = React.useState(false); + const [manageOpen, setManageOpen] = React.useState(false); + + const { mutate: createConversation, isPending } = useCreateConversation(); + const isCreatingElsewhere = useIsCreatingConversation(); + const isCreating = isPending || isCreatingElsewhere; + + React.useEffect(() => { + if (!open || browserOpen || manageOpen) return undefined; + const onDown = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [open, browserOpen, manageOpen]); + + React.useEffect(() => { + if (!open || browserOpen || manageOpen) return undefined; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, browserOpen, manageOpen]); + + const launch = (workingDir?: string) => { + if (isCreating) return; + createConversation( + { workingDir, entryPoint: "sidebar_local_menu" }, + { + onSuccess: (data) => { + setOpen(false); + navigate(`/conversations/${data.conversation_id}`); + }, + }, + ); + }; + + const itemClass = dropdownMenuRowClassName; + + const keepPopoverOpenOnMouseDown = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, + [], + ); + + const toggleOpen = React.useCallback(() => { + setOpen((o) => !o); + }, []); + + const showPopover = open && (!useFixedPlacement || fixedBox !== null); + const workspaceActionsDisabled = Boolean(workspacesUnsupportedMessage); + + const fixedStyle: React.CSSProperties | undefined = + useFixedPlacement && fixedBox + ? { + position: "fixed", + top: fixedBox.top, + left: fixedBox.left, + width: fixedBox.width, + } + : undefined; + + const addWorkspacesButton = ( + + ); + + const addWorkspacesControl = workspacesUnsupportedMessage ? ( + + {addWorkspacesButton} + + ) : ( + addWorkspacesButton + ); + + return ( +
    + + {trigger({ + onClick: toggleOpen, + "aria-expanded": open, + "aria-haspopup": "menu", + disabled: isCreating, + })} + + + {showPopover && ( +
    +
      +
    • + +
    • + {workspaces.map((w) => ( +
    • + +
    • + ))} +
    + +
    + + {addWorkspacesControl} + {(workspaces.length > 0 || workspaceParents.length > 0) && ( + + )} +
    +
    + )} + + setBrowserOpen(false)} + onAdd={(items) => addWorkspaces(items)} + onAddParent={(items) => addWorkspaceParents(items)} + /> + + setManageOpen(false)} + onRemove={(path) => removeWorkspace(path)} + onRemoveParent={(path) => removeWorkspaceParent(path)} + /> +
    + ); +} diff --git a/src/components/features/conversation-panel/menu-heading.tsx b/src/components/features/conversation-panel/menu-heading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31d067da33053ffd95e4f1fc9091177639fb4d29 --- /dev/null +++ b/src/components/features/conversation-panel/menu-heading.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { cn } from "#/utils/utils"; + +const MENU_SECTION_HEADING_PADDING = "px-2 pb-1 pt-1"; +const MENU_SECTION_HEADING_TEXT = + "text-[11px] font-semibold uppercase tracking-wide text-[var(--oh-muted)]"; + +export function MenuHeading({ + children, + suffix, +}: { + children: React.ReactNode; + suffix?: React.ReactNode; +}) { + if (suffix != null) { + return ( +
    + + {children} + + {suffix} +
    + ); + } + + return ( +
    + {children} +
    + ); +} diff --git a/src/components/features/conversation-panel/menu-row.tsx b/src/components/features/conversation-panel/menu-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..95c3971898a52ad1d9a2c0ef8033c3742342b4e3 --- /dev/null +++ b/src/components/features/conversation-panel/menu-row.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { Check } from "lucide-react"; +import { cn } from "#/utils/utils"; +import { + dropdownMenuRowClassName, + dropdownMenuRowIconClassName, +} from "#/utils/dropdown-classes"; +import { ToggleSwitchVisual } from "#/ui/toggle-switch"; + +export function MenuRow({ + icon: Icon, + label, + sublabel, + selected, + onClick, + testId, + disabled, + destructive, + variant = "radio", +}: { + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>; + label: string; + /** Muted second line under the label (e.g. a threshold hint). */ + sublabel?: string; + selected?: boolean; + onClick: () => void; + testId?: string; + disabled?: boolean; + /** Destructive action rows (delete/reset) render in the danger color. */ + destructive?: boolean; + /** + * "radio" rows mark selection with a checkmark (mutually exclusive + * groups); "toggle" rows render a switch pill for independent on/off + * preferences that stay put after clicking (modal-style menus). + */ + variant?: "radio" | "toggle"; +}) { + const role = + selected === undefined + ? "menuitem" + : variant === "toggle" + ? "menuitemcheckbox" + : "menuitemradio"; + return ( + + ); +} diff --git a/src/components/features/conversation-panel/menu-separator.tsx b/src/components/features/conversation-panel/menu-separator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d073e7c48bd5ed8c2d50411191926b0c9cf9c0d2 --- /dev/null +++ b/src/components/features/conversation-panel/menu-separator.tsx @@ -0,0 +1,5 @@ +import { Divider } from "#/ui/divider"; + +export function MenuSeparator() { + return ; +} diff --git a/src/components/features/conversation-panel/new-conversation-dropdown-styles.ts b/src/components/features/conversation-panel/new-conversation-dropdown-styles.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e4bb3b3712877bb60db93614e55183efa5d0b61 --- /dev/null +++ b/src/components/features/conversation-panel/new-conversation-dropdown-styles.ts @@ -0,0 +1,8 @@ +import { cn } from "#/utils/utils"; +import { dropdownMenuListGapClassName } from "#/utils/dropdown-classes"; + +/** Match conversation panel filter and other sidebar menus */ +export const NEW_CONVERSATION_DROPDOWN_SURFACE = cn( + "z-50 flex flex-col rounded-md border border-[var(--oh-border-subtle)] bg-tertiary px-1 py-1 text-white shadow-lg", + dropdownMenuListGapClassName, +); diff --git a/src/components/features/conversation-panel/plugins-modal.tsx b/src/components/features/conversation-panel/plugins-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c23198591494c1f988d1204916246f4f95b9b9a --- /dev/null +++ b/src/components/features/conversation-panel/plugins-modal.tsx @@ -0,0 +1,77 @@ +import { useTranslation } from "react-i18next"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { useConversationPlugins } from "#/hooks/use-conversation-plugins"; +import { + getPluginDisplayName, + getPluginSourceLabel, + isLocalPluginSource, + pluginReferenceKey, +} from "#/utils/plugin-display"; + +interface PluginsModalProps { + onClose: () => void; +} + +/** + * Display-only view of the plugins loaded into the current conversation, + * captured in client-side metadata at creation (explicitly attached plugins + * plus the enabled installed plugins the SDK auto-loads). Mirrors + * {@link SkillsModal}. The agent-server doesn't expose a live conversation's + * loaded plugins, so this reads that client-side snapshot. + */ +export function PluginsModal({ onClose }: PluginsModalProps) { + const { t } = useTranslation("openhands"); + const plugins = useConversationPlugins(); + + return ( + + + +
    + + + {t(I18nKey.PLUGINS_MODAL$DESCRIPTION)} + +
    + +
    + {plugins.length === 0 ? ( +
    + + {t(I18nKey.PLUGINS_MODAL$EMPTY)} + +
    + ) : ( +
      + {plugins.map((plugin) => ( +
    • + + {getPluginDisplayName(plugin)} + + + {isLocalPluginSource(plugin) + ? t(I18nKey.PLUGINS_MODAL$SOURCE_LOCAL) + : getPluginSourceLabel(plugin)} + +
    • + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/runtime-waiting-state.tsx b/src/components/features/conversation-panel/runtime-waiting-state.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4aca9735c0450b13700d5fa6096c85247d4d1ad9 --- /dev/null +++ b/src/components/features/conversation-panel/runtime-waiting-state.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from "react-i18next"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { cn } from "#/utils/utils"; + +interface RuntimeWaitingStateProps { + testId?: string; + messageKey?: I18nKey; + className?: string; +} + +export function RuntimeWaitingState({ + testId = "runtime-waiting", + messageKey = I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME, + className, +}: RuntimeWaitingStateProps) { + const { t } = useTranslation("openhands"); + + return ( +
    + + + {t(messageKey)} + +
    + ); +} diff --git a/src/components/features/conversation-panel/skill-content.tsx b/src/components/features/conversation-panel/skill-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0883f5287c8a2e338ac0dc4bf540829940ef939e --- /dev/null +++ b/src/components/features/conversation-panel/skill-content.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { Pre } from "#/ui/pre"; + +interface SkillContentProps { + content: string; +} + +export function SkillContent({ content }: SkillContentProps) { + const { t } = useTranslation("openhands"); + + return ( +
    + + {t(I18nKey.COMMON$CONTENT)} + +
    +        {content || t(I18nKey.SKILLS_MODAL$NO_CONTENT)}
    +      
    +
    + ); +} diff --git a/src/components/features/conversation-panel/skill-item.tsx b/src/components/features/conversation-panel/skill-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e088fcd78714c0bfdd02a4fd1cc15f0b9156e642 --- /dev/null +++ b/src/components/features/conversation-panel/skill-item.tsx @@ -0,0 +1,57 @@ +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Typography } from "#/ui/typography"; +import { SkillTriggers } from "./skill-triggers"; +import { SkillContent } from "./skill-content"; +import { SkillInfo } from "#/types/settings"; + +interface SkillItemProps { + skill: SkillInfo & { content?: string }; + isExpanded: boolean; + onToggle: (agentName: string) => void; +} + +const SKILL_TYPE_LABEL: Record = { + knowledge: "Knowledge", + repo: "Repository", + agentskills: "AgentSkills", +}; + +const SKILL_PILL_CLASS = + "inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-[var(--oh-border)] bg-[var(--oh-surface)] text-tertiary-light"; + +export function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) { + return ( +
    + + + {isExpanded && ( +
    + + +
    + )} +
    + ); +} diff --git a/src/components/features/conversation-panel/skill-triggers.tsx b/src/components/features/conversation-panel/skill-triggers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf04d35d35db20ab0e68b9f2fd51b9f5053921a3 --- /dev/null +++ b/src/components/features/conversation-panel/skill-triggers.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; + +interface SkillTriggersProps { + triggers: string[]; +} + +export function SkillTriggers({ triggers }: SkillTriggersProps) { + const { t } = useTranslation("openhands"); + + if (!triggers || triggers.length === 0) { + return null; + } + + return ( +
    + + {t(I18nKey.COMMON$TRIGGERS)} + +
    + {triggers.map((trigger) => ( + + {trigger} + + ))} +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/skills-empty-state.tsx b/src/components/features/conversation-panel/skills-empty-state.tsx new file mode 100644 index 0000000000000000000000000000000000000000..79b3dbec90669c259ba6622b5b015b23878d6766 --- /dev/null +++ b/src/components/features/conversation-panel/skills-empty-state.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; + +interface SkillsEmptyStateProps { + isError: boolean; +} + +export function SkillsEmptyState({ isError }: SkillsEmptyStateProps) { + const { t } = useTranslation("openhands"); + + return ( +
    + + {isError + ? t(I18nKey.COMMON$FETCH_ERROR) + : t(I18nKey.CONVERSATION$NO_SKILLS)} + +
    + ); +} diff --git a/src/components/features/conversation-panel/skills-loading-state.tsx b/src/components/features/conversation-panel/skills-loading-state.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3119a1e663a6e0b6d28966b4ec8f9ed08c59363f --- /dev/null +++ b/src/components/features/conversation-panel/skills-loading-state.tsx @@ -0,0 +1,7 @@ +export function SkillsLoadingState() { + return ( +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/skills-modal-header.tsx b/src/components/features/conversation-panel/skills-modal-header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ace489e8dbd9be9ae4b6835c80f16c37b582acb0 --- /dev/null +++ b/src/components/features/conversation-panel/skills-modal-header.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from "react-i18next"; +import { RefreshCw } from "lucide-react"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; +import { ModalCloseButton } from "#/components/shared/modals/modal-close-button"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { I18nKey } from "#/i18n/declaration"; +import { Typography } from "#/ui/typography"; +import { cn } from "#/utils/utils"; + +interface SkillsModalHeaderProps { + isLoading: boolean; + isRefetching: boolean; + onRefresh: () => void; + onClose: () => void; +} + +const ICON_BUTTON_CLASS = + "rounded-md p-1 text-white hover:bg-tertiary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"; + +export function SkillsModalHeader({ + isLoading, + isRefetching, + onRefresh, + onClose, +}: SkillsModalHeaderProps) { + const { t } = useTranslation("openhands"); + const refreshLabel = t(I18nKey.BUTTON$REFRESH); + + return ( + <> + +
    +
    + + + {t(I18nKey.SKILLS_MODAL$WARNING)} + +
    + + + +
    + + ); +} diff --git a/src/components/features/conversation-panel/skills-modal-section.tsx b/src/components/features/conversation-panel/skills-modal-section.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f88d10e7ae5b82e26f433c7021f0be75cc57c7d1 --- /dev/null +++ b/src/components/features/conversation-panel/skills-modal-section.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { Typography } from "#/ui/typography"; + +interface SkillsModalSectionProps { + title: string; + count: number; + children: ReactNode; +} + +export function SkillsModalSection({ + title, + count, + children, +}: SkillsModalSectionProps) { + return ( +
    +
    +
    + + {title} + + + {count} + +
    +
    +
    {children}
    +
    + ); +} diff --git a/src/components/features/conversation-panel/skills-modal.tsx b/src/components/features/conversation-panel/skills-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2dee4f6130981ed1fc54b1d9590415311324b08b --- /dev/null +++ b/src/components/features/conversation-panel/skills-modal.tsx @@ -0,0 +1,122 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { I18nKey } from "#/i18n/declaration"; +import { getAgentServerWorkingDir } from "#/api/agent-server-config"; +import { useConversationSkills } from "#/hooks/query/use-conversation-skills"; +import { useSkillEnabledFilter } from "#/hooks/use-skill-enablement"; +import { + groupSkillsByScope, + SKILL_SCOPE_ORDER, + type SkillScope, +} from "#/utils/skill-scope"; +import { SkillsModalHeader } from "./skills-modal-header"; +import { SkillsModalSection } from "./skills-modal-section"; +import { SkillsLoadingState } from "./skills-loading-state"; +import { SkillsEmptyState } from "./skills-empty-state"; +import { SkillItem } from "./skill-item"; + +interface SkillsModalProps { + onClose: () => void; +} + +const SECTION_TITLE_KEY: Record = { + project: I18nKey.SKILLS_MODAL$SECTION_PROJECT, + personal: I18nKey.SKILLS_MODAL$SECTION_USER, + public: I18nKey.SKILLS_MODAL$SECTION_PUBLIC, +}; + +export function SkillsModal({ onClose }: SkillsModalProps) { + const { t } = useTranslation("openhands"); + const projectDir = getAgentServerWorkingDir(); + const [expandedAgents, setExpandedAgents] = useState>( + {}, + ); + // Scope the catalog to this conversation's attached workspace so the listed + // skills match the project skills actually loaded into the conversation. + const { + data: skills, + isLoading, + isError, + refetch, + isRefetching, + } = useConversationSkills(); + const isSkillEnabled = useSkillEnabledFilter(); + + // The modal reports what the conversation actually has, so it lists the + // enabled set rather than the whole catalog. + const visibleSkills = useMemo( + () => (skills ?? []).filter(isSkillEnabled), + [skills, isSkillEnabled], + ); + + const groupedSkills = useMemo( + () => groupSkillsByScope(visibleSkills, projectDir), + [visibleSkills, projectDir], + ); + + const toggleAgent = (agentName: string) => { + setExpandedAgents((prev) => ({ + ...prev, + [agentName]: !prev[agentName], + })); + }; + + return ( + + + + +
    + {isLoading ? ( + + ) : isError || !skills || visibleSkills.length === 0 ? ( + + ) : ( + groupedSkills && ( +
    + {SKILL_SCOPE_ORDER.map((scope) => { + const scopedSkills = groupedSkills[scope]; + if (scopedSkills.length === 0) { + return null; + } + + return ( + + {scopedSkills.map((skill) => { + const isExpanded = expandedAgents[skill.name] || false; + + return ( + + ); + })} + + ); + })} +
    + ) + )} +
    +
    +
    + ); +} diff --git a/src/components/features/conversation-panel/system-message-modal.tsx b/src/components/features/conversation-panel/system-message-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4a87b7e0d260daa4fb60662b0d51971e99079b0 --- /dev/null +++ b/src/components/features/conversation-panel/system-message-modal.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { SystemMessageHeader } from "./system-message-modal/system-message-header"; +import { TabNavigation } from "./system-message-modal/tab-navigation"; +import { TabContent } from "./system-message-modal/tab-content"; +import { SystemMessageForModal } from "#/utils/system-message-adapter"; + +interface SystemMessageModalProps { + isOpen: boolean; + onClose: () => void; + systemMessage: SystemMessageForModal | null; +} + +export function SystemMessageModal({ + isOpen, + onClose, + systemMessage, +}: SystemMessageModalProps) { + const [activeTab, setActiveTab] = useState<"system" | "tools">("system"); + const [expandedTools, setExpandedTools] = useState>( + {}, + ); + + if (!systemMessage) { + return null; + } + + const toggleTool = (index: number) => { + setExpandedTools((prev) => ({ + ...prev, + [index]: !prev[index], + })); + }; + + return ( + isOpen && ( + + + + +
    + 0) + } + /> + +
    + +
    +
    +
    +
    + ) + ); +} diff --git a/src/components/features/diff-viewer/commit-list.tsx b/src/components/features/diff-viewer/commit-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..361132965d86aae3a2197ee49bd203446b68d67c --- /dev/null +++ b/src/components/features/diff-viewer/commit-list.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { GitCommit } from "#/api/open-hands.types"; +import { I18nKey } from "#/i18n/declaration"; +import { CommitRow } from "./commit-row"; +import { UncommittedChangesRow } from "./uncommitted-changes-row"; +import type { DiffChangeListItem } from "./diff-change-list"; + +const UNCOMMITTED_KEY = "uncommitted"; + +export interface CommitListProps { + commits: GitCommit[]; + /** True when the server capped the page — surfaces the cap notice. */ + hasMore: boolean; + uncommittedChanges: DiffChangeListItem[]; + autoExpandUncommitted?: boolean; + onAutoExpandHandled?: () => void; +} + +/** + * The workspace's recent commit history (newest first). Single-open + * accordion: expanding a commit collapses the previously expanded one. + * Empty/loading/waiting states are the host view's job + * (see routes/commits-tab.tsx). + */ +export function CommitList({ + commits, + hasMore, + uncommittedChanges, + autoExpandUncommitted = false, + onAutoExpandHandled, +}: CommitListProps) { + const { t } = useTranslation("openhands"); + const [expandedKey, setExpandedKey] = React.useState(null); + + React.useEffect(() => { + if (!autoExpandUncommitted) return; + setExpandedKey(UNCOMMITTED_KEY); + onAutoExpandHandled?.(); + }, [autoExpandUncommitted, onAutoExpandHandled]); + + // Author is noise when every commit has the same one (the usual + // single-agent conversation); show it only when authors differ. + const showAuthor = new Set(commits.map((commit) => commit.author)).size > 1; + + return ( +
    + + setExpandedKey((prev) => + prev === UNCOMMITTED_KEY ? null : UNCOMMITTED_KEY, + ) + } + /> + {commits.map((commit) => ( + + setExpandedKey((prev) => (prev === commit.sha ? null : commit.sha)) + } + /> + ))} + + {hasMore && ( +
    + {t(I18nKey.DIFF_VIEWER$COMMITS_CAP, { count: commits.length })} +
    + )} +
    + ); +} diff --git a/src/components/features/diff-viewer/commit-row.tsx b/src/components/features/diff-viewer/commit-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ee4712dbdd4a0ac6178164b6d1fea59e958a158d --- /dev/null +++ b/src/components/features/diff-viewer/commit-row.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { GitCommit } from "#/api/open-hands.types"; +import { I18nKey } from "#/i18n/declaration"; +import { formatTimeDelta } from "#/utils/format-time-delta"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useCommitChanges } from "#/hooks/query/use-commit-changes"; +import { AccordionPanel } from "./accordion-panel"; +import { DiffChangeList } from "./diff-change-list"; +import { LoadingSpinner } from "./loading-spinner"; + +export interface CommitRowProps { + commit: GitCommit; + /** Only shown when the listed commits have more than one author. */ + showAuthor: boolean; + isExpanded: boolean; + onToggle: () => void; +} + +/** + * One collapsible commit: header row (short SHA, subject, relative time), + * expanding into the files that commit changed as a single-open accordion. + */ +export function CommitRow({ + commit, + showAuthor, + isExpanded, + onToggle, +}: CommitRowProps) { + const { t } = useTranslation("openhands"); + + // Only fetch a commit's file list once the row is expanded. + const { + data: changes, + isLoading, + isSuccess, + } = useCommitChanges(commit.sha, { enabled: isExpanded }); + + return ( +
    + + + + {isLoading && ( +
    + +
    + )} + {isSuccess && changes && changes.length > 0 && ( + + )} +
    +
    + ); +} diff --git a/src/components/features/diff-viewer/diff-drawer-icon.tsx b/src/components/features/diff-viewer/diff-drawer-icon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..373b880698d1ddbc5507feea4eebc972658b1b7e --- /dev/null +++ b/src/components/features/diff-viewer/diff-drawer-icon.tsx @@ -0,0 +1,5 @@ +import { LuFileDiff } from "react-icons/lu"; + +export function DiffDrawerIcon() { + return ; +} diff --git a/src/components/features/diff-viewer/editor-container.test.tsx b/src/components/features/diff-viewer/editor-container.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b4cf114bb1ff7b5e2fb8e67770aa977a3c2b2438 --- /dev/null +++ b/src/components/features/diff-viewer/editor-container.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { EditorContainer } from "./editor-container"; + +describe("EditorContainer", () => { + it("renders children", () => { + render( + +
    + , + ); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("renders with the editor-container test id", () => { + render( + +
    + , + ); + expect(screen.getByTestId("editor-container")).toBeInTheDocument(); + }); + + // Functional contract: the `height` prop is observable only via the + // `--editor-height` CSS custom property the component writes to the DOM. + it("sets the --editor-height CSS variable based on height prop", () => { + render( + +
    + , + ); + const container = screen.getByTestId("editor-container"); + expect(container.style.getPropertyValue("--editor-height")).toBe("500px"); + }); +}); diff --git a/src/components/features/diff-viewer/loading-spinner.tsx b/src/components/features/diff-viewer/loading-spinner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f20e170f244c062bd46fff6c4be939c9394c073a --- /dev/null +++ b/src/components/features/diff-viewer/loading-spinner.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; + +export interface LoadingSpinnerProps { + className?: string; +} + +export function LoadingSpinner({ className }: LoadingSpinnerProps) { + const { t } = useTranslation("openhands"); + + return ( +
    +
    +
    + ); +} diff --git a/src/components/features/files-tab/file-content-viewer.tsx b/src/components/features/files-tab/file-content-viewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2d4efa729d8a7b22f83afe7e7f074ee7e12f05bf --- /dev/null +++ b/src/components/features/files-tab/file-content-viewer.tsx @@ -0,0 +1,230 @@ +import { useTranslation } from "react-i18next"; + +import { I18nKey } from "#/i18n/declaration"; +import { useWorkspaceFileContent } from "#/hooks/query/use-workspace-file-content"; +import { + useWorkspaceMutationCounter, + withWorkspaceCacheBuster, +} from "#/stores/use-workspace-mutation-counter"; +import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer"; +import { isMarkdownFilePath } from "#/utils/is-markdown-file-path"; +import { HighlightedSourceView } from "./highlighted-source-view"; +import type { ViewMode } from "./view-mode"; + +interface FileContentViewerProps { + path: string; + viewMode: ViewMode; +} + +const HTML_LIKE_EXTS = new Set(["html", "htm", "svg"]); + +// Office/document formats we can't preview inline. The label doubles as the +// allow-list (a present entry => Office doc) and feeds a clear, format-named +// "no preview" message instead of the generic binary fallback. +const OFFICE_DOCUMENT_LABELS: Record = { + pptx: "PowerPoint", + ppt: "PowerPoint", + docx: "Word", + doc: "Word", + xlsx: "Excel", + xls: "Excel", +}; + +function getExtension(path: string): string { + const idx = path.lastIndexOf("."); + return idx === -1 ? "" : path.slice(idx + 1).toLowerCase(); +} + +/** + * Fallback shown when a file's bytes aren't previewable. Office documents + * (.pptx / .docx / .xlsx …) get a clear, format-named message; every other + * binary keeps the generic "binary file" string so the pane is never blank. + */ +function UnpreviewableFallback({ path }: { path: string }) { + const { t } = useTranslation("openhands"); + const documentLabel = OFFICE_DOCUMENT_LABELS[getExtension(path)]; + return ( +
    + {documentLabel + ? t(I18nKey.FILES$UNSUPPORTED_DOCUMENT, { type: documentLabel }) + : t(I18nKey.FILES$BINARY_FALLBACK)} +
    + ); +} + +/** + * Renders the contents of a single workspace file. In `rich` mode we point + * an iframe / straight at the agent server's static workspace + * fileserver for HTML / SVG / images / PDFs, so relative asset references + * load naturally. In `plain` mode we always show the raw bytes as text (or + * a fallback message for binaries). + */ +export function FileContentViewer({ path, viewMode }: FileContentViewerProps) { + const { t } = useTranslation("openhands"); + const query = useWorkspaceFileContent(path); + // Subscribe to the workspace mutation counter so the iframe / src + // changes after every agent-side edit, forcing a fresh fetch even when + // the *path* hasn't moved (e.g. agent rewrote `style.css` referenced by + // the currently-displayed `index.html`). + const mutationCounter = useWorkspaceMutationCounter((state) => state.count); + + if (query.isLoading) { + return ( +
    + {t(I18nKey.FILES$LOADING_FILES)} +
    + ); + } + + if (query.isError || !query.data) { + // Show a load-error message rather than the binary-fallback string — + // these are completely different failure modes (couldn't even fetch + // the file vs. fetched fine but the bytes aren't previewable) and + // mixing them up hides real backend failures behind a misleading + // "Binary file" label. Prefer the underlying error's own message when + // we have one; fall back to the generic translated string otherwise. + return ( +
    + {(query.error as Error | undefined)?.message ?? + t(I18nKey.FILES$LOAD_ERROR)} +
    + ); + } + + const { kind, text, staticUrl, mimeType } = query.data; + const bustedStaticUrl = withWorkspaceCacheBuster(staticUrl, mutationCounter); + + // ----- Plain mode: raw source bytes, syntax-highlighted when we can + // recognize the grammar (falls through to a `
    ` otherwise). This
    +  // includes "plain" view of markdown / HTML, where the point is to see
    +  // the markup behind the rich preview.
    +  if (viewMode === "plain") {
    +    if (kind === "text" && text !== null) {
    +      return (
    +        
    +      );
    +    }
    +    return ;
    +  }
    +
    +  // ----- Rich mode: render HTML, markdown, images, PDFs from staticUrl. ----
    +  if (kind === "image") {
    +    return (
    +      
    + {path} +
    + ); + } + + if (kind === "pdf") { + // Deliberately NOT sandboxed: Chromium refuses to instantiate its + // built-in PDF viewer inside any sandboxed frame (the `sandbox` + // attribute disables plugins unconditionally — no token re-enables + // them), rendering "This page has been blocked by Chrome" instead of + // the document. Unsandboxed is safe here: the fileserver declares + // `Content-Type: application/pdf`, which browsers never sniff into + // HTML, so the frame can only host the PDF viewer — and PDF-embedded + // JS (AcroForm, OpenAction…) runs in the viewer's own sandboxed + // engine with no access to this page's DOM. + return ( +