diff --git a/src/extensions/canvas-extension-module-loader.test.ts b/src/extensions/canvas-extension-module-loader.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6441ee7c21dab09207d163e5491f26f1599c8c1 --- /dev/null +++ b/src/extensions/canvas-extension-module-loader.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { + assertCanvasExtensionModule, + InvalidCanvasExtensionModuleError, +} from "#/extensions/canvas-extension-module-loader"; + +describe("assertCanvasExtensionModule", () => { + it("accepts a module namespace with an activate export", () => { + expect(() => + assertCanvasExtensionModule({ activate: () => undefined }), + ).not.toThrow(); + }); + + it("rejects modules that do not implement the host lifecycle", () => { + expect(() => assertCanvasExtensionModule({ default: {} })).toThrow( + InvalidCanvasExtensionModuleError, + ); + }); +}); diff --git a/src/extensions/canvas-extension-module-loader.ts b/src/extensions/canvas-extension-module-loader.ts new file mode 100644 index 0000000000000000000000000000000000000000..da5599400db5f64b225ec14cc3cd56c564d416a6 --- /dev/null +++ b/src/extensions/canvas-extension-module-loader.ts @@ -0,0 +1,42 @@ +import type { CanvasExtensionModule } from "#/types/canvas-extension"; + +export class InvalidCanvasExtensionModuleError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidCanvasExtensionModuleError"; + } +} + +export function assertCanvasExtensionModule( + value: unknown, +): asserts value is CanvasExtensionModule { + if ( + typeof value !== "object" || + value === null || + !("activate" in value) || + typeof value.activate !== "function" + ) { + throw new InvalidCanvasExtensionModuleError( + 'Canvas Extension entrypoint must export an "activate" function.', + ); + } +} + +/** + * Import an authenticated, self-contained ESM bundle without putting the + * Agent Server session key in a URL. The source has already been fetched by + * Canvas through its authenticated HTTP client. + */ +export async function loadCanvasExtensionModule( + source: string, +): Promise { + const blob = new Blob([source], { type: "text/javascript" }); + const moduleUrl = URL.createObjectURL(blob); + try { + const imported: unknown = await import(/* @vite-ignore */ moduleUrl); + assertCanvasExtensionModule(imported); + return imported; + } finally { + URL.revokeObjectURL(moduleUrl); + } +} diff --git a/src/hooks/use-acp-credential-form.ts b/src/hooks/use-acp-credential-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..92589462dce1f93f3af9bd85946ab5c56427edaf --- /dev/null +++ b/src/hooks/use-acp-credential-form.ts @@ -0,0 +1,114 @@ +import React from "react"; +import { useSearchSecrets } from "#/hooks/query/use-get-secrets"; +import { useSaveAcpSecrets } from "#/hooks/use-save-acp-secrets"; +import { + getAcpCredentialConflicts, + getAcpProviderSecrets, + type ACPProviderSecretField, +} from "#/constants/acp-providers"; + +export interface AcpCredentialForm { + /** Credential fields for ``providerKey`` (see {@link getAcpProviderSecrets}). */ + fields: ACPProviderSecretField[]; + /** Current (unsaved) input values, keyed by field name. */ + values: Record; + setValue: (name: string, value: string) => void; + /** Whether a secret with this name is already saved on the backend. */ + secretExists: (name: string) => boolean; + /** Typed now (non-blank) or previously saved. */ + hasValueFor: (name: string) => boolean; + /** ``[credential, conflicting]`` pairs currently both set (typed or saved). */ + conflicts: Array<[string, string]>; + /** Whether a credential (a ``secret`` field — API key, OAuth token, or + * file-content blob) is already saved on the backend for this provider. This + * is the backend-truthful auth signal that works on Docker/cloud, where the + * host-login probe can't run (agent-canvas#1244). A non-credential field (a + * base URL or GCP scalar) being set does not count. */ + credentialsConfigured: boolean; + /** Whether the active backend can materialise file-content (``multiline``) + * credentials to disk. False on cloud (agent-canvas#1016), where such a + * credential would be orphaned. */ + consumesFileCredentials: boolean; + /** At least one field has a non-blank typed value. */ + isDirty: boolean; + /** Persist the filled fields; resolves ``true`` when everything saved. + * ``silent`` suppresses the success toast so a caller saving credentials + * alongside other state can emit a single combined confirmation. */ + save: (options?: { silent?: boolean }) => Promise; + reset: () => void; + isSaving: boolean; +} + +/** + * Shared state + derived values for the ACP credential forms (the onboarding + * step and the Settings → Agent section): the provider's field list, the typed + * values, existing-secret lookups, the credential-conflict pairs, and the save + * flow ({@link useSaveAcpSecrets}). Values reset when ``providerKey`` changes — + * typed-but-unsaved input belongs to the previous provider's fields. + */ +export function useAcpCredentialForm( + providerKey: string | null | undefined, +): AcpCredentialForm { + const { data: existingSecrets } = useSearchSecrets(); + const fields = React.useMemo( + () => getAcpProviderSecrets(providerKey), + [providerKey], + ); + const [values, setValues] = React.useState>({}); + + // Both local and cloud agent-servers materialise file-content credentials + // (Codex auth.json, Gemini Vertex SA) via the SDK's acp_file_secrets: locally + // from the user's machine, on cloud from the per-user encrypted secret store + // routed through agent_context.secrets at conversation start (the cloud + // backend pins an SDK that materialises reserved file secrets). So a pasted + // file blob is consumable on every supported backend. + const consumesFileCredentials = true; + const { saveFilled, isSaving } = useSaveAcpSecrets( + fields, + consumesFileCredentials, + ); + + React.useEffect(() => { + setValues({}); + }, [providerKey]); + + const secretExists = React.useCallback( + (name: string) => + (existingSecrets ?? []).some((secret) => secret.name === name), + [existingSecrets], + ); + + const hasValueFor = React.useCallback( + (name: string) => Boolean(values[name]?.trim()) || secretExists(name), + [values, secretExists], + ); + + const setValue = React.useCallback( + (name: string, value: string) => + setValues((prev) => ({ ...prev, [name]: value })), + [], + ); + + const reset = React.useCallback(() => setValues({}), []); + + return { + fields, + values, + setValue, + secretExists, + hasValueFor, + conflicts: getAcpCredentialConflicts(providerKey, hasValueFor), + // `=== true` is the intended strict check: only a credential-bearing field + // (API key, OAuth token, file blob) counts — never a base URL or GCP scalar, + // which omit `secret` entirely. A field must never set `secret: false` to + // opt out; omit the flag instead. + credentialsConfigured: fields.some( + (field) => field.secret === true && secretExists(field.name), + ), + consumesFileCredentials, + isDirty: fields.some((field) => Boolean(values[field.name]?.trim())), + save: (options) => saveFilled(values, options), + reset, + isSaving, + }; +} diff --git a/src/hooks/use-active-agent-profile.ts b/src/hooks/use-active-agent-profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1bcd4491675b4b1c1b60a57945bf5444b8d6b2a --- /dev/null +++ b/src/hooks/use-active-agent-profile.ts @@ -0,0 +1,32 @@ +import type { AgentKind } from "@openhands/typescript-client"; +import type { AgentProfileSummary } from "#/api/agent-profiles-service/agent-profiles-service.api"; +import { useAgentProfiles } from "#/hooks/query/use-agent-profiles"; + +/** + * The agent profile the user has activated — the authoritative "current agent" + * now that Settings → Agent IS the profile library (#1571). `activate` is + * pointer-only and never writes `settings.agent_settings`, so the active + * profile — not the global agent settings — is the source of truth for what + * kind of agent (OpenHands vs ACP) is in effect. + */ +export function useActiveAgentProfile(): { + activeProfile: AgentProfileSummary | null; + isLoading: boolean; +} { + const { data, isLoading } = useAgentProfiles(); + const activeId = data?.active_agent_profile_id ?? null; + const activeProfile = + data?.profiles?.find((p) => p.id != null && p.id === activeId) ?? null; + return { activeProfile, isLoading }; +} + +/** + * The effective agent kind from the active profile. `undefined` while the + * profile list is loading or when no profile is active — callers should fall + * back to `settings.agent_settings.agent_kind` in that window to avoid a flash + * of the wrong (OpenHands-default) UI. + */ +export function useActiveAgentKind(): AgentKind | undefined { + const { activeProfile } = useActiveAgentProfile(); + return activeProfile?.agent_kind; +} diff --git a/src/hooks/use-agent-state.test.tsx b/src/hooks/use-agent-state.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2bf9c35728b3f72fd1364b3de0946193537aef7 --- /dev/null +++ b/src/hooks/use-agent-state.test.tsx @@ -0,0 +1,73 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentState } from "./use-agent-state"; +import { useActiveConversation } from "./query/use-active-conversation"; +import { useOptionalConversationId } from "./use-conversation-id"; +import { useConversationStateStore } from "#/stores/conversation-state-store"; +import { AgentState } from "#/types/agent-state"; +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; + +const TEST_CONVERSATION_ID = "test-conversation-id"; + +vi.mock("./query/use-active-conversation", () => ({ + useActiveConversation: vi.fn(), +})); + +vi.mock("./use-conversation-id", () => ({ + useOptionalConversationId: vi.fn(), +})); + +describe("useAgentState", () => { + const mockUseActiveConversation = vi.mocked(useActiveConversation); + const mockUseOptionalConversationId = vi.mocked(useOptionalConversationId); + + beforeEach(() => { + act(() => { + useConversationStateStore.getState().reset(); + }); + + mockUseActiveConversation.mockReturnValue({ + data: null, + } as ReturnType); + + mockUseOptionalConversationId.mockReturnValue({ + conversationId: TEST_CONVERSATION_ID, + }); + }); + + it("prefers live websocket execution status over cached conversation status", () => { + mockUseActiveConversation.mockReturnValue({ + data: { + execution_status: ExecutionStatus.FINISHED, + }, + } as ReturnType); + + act(() => { + useConversationStateStore + .getState() + .setExecutionStatus(TEST_CONVERSATION_ID, ExecutionStatus.RUNNING); + }); + + const { result } = renderHook(() => useAgentState()); + + expect(result.current.executionStatus).toBe(ExecutionStatus.RUNNING); + expect(result.current.curAgentState).toBe(AgentState.RUNNING); + }); + + it("falls back to cached conversation execution status when live state is empty", () => { + mockUseActiveConversation.mockReturnValue({ + data: { + execution_status: ExecutionStatus.WAITING_FOR_CONFIRMATION, + }, + } as ReturnType); + + const { result } = renderHook(() => useAgentState()); + + expect(result.current.executionStatus).toBe( + ExecutionStatus.WAITING_FOR_CONFIRMATION, + ); + expect(result.current.curAgentState).toBe( + AgentState.AWAITING_USER_CONFIRMATION, + ); + }); +}); diff --git a/src/hooks/use-agent-state.ts b/src/hooks/use-agent-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccd7d8cdc0f55d48c27bd330bcf269a4e24cee7b --- /dev/null +++ b/src/hooks/use-agent-state.ts @@ -0,0 +1,107 @@ +import { useMemo } from "react"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { useConversationStateStore } from "#/stores/conversation-state-store"; +import { useConversationStore } from "#/stores/conversation-store"; +import { AgentState } from "#/types/agent-state"; +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; + +/** + * Maps agent execution status to AgentState + */ +function mapExecutionStatusToAgentState( + status: ExecutionStatus | null, +): AgentState { + if (!status) { + return AgentState.LOADING; + } + + switch (status) { + case ExecutionStatus.IDLE: + return AgentState.AWAITING_USER_INPUT; + case ExecutionStatus.RUNNING: + return AgentState.RUNNING; + case ExecutionStatus.PAUSED: + return AgentState.PAUSED; + case ExecutionStatus.WAITING_FOR_CONFIRMATION: + return AgentState.AWAITING_USER_CONFIRMATION; + case ExecutionStatus.FINISHED: + return AgentState.FINISHED; + case ExecutionStatus.ERROR: + return AgentState.ERROR; + case ExecutionStatus.STUCK: + return AgentState.ERROR; // Map STUCK to ERROR for now + default: + return AgentState.LOADING; + } +} + +export interface UseAgentStateResult { + curAgentState: AgentState; + executionStatus?: ExecutionStatus | null; +} + +/** + * Returns the current agent state from conversation execution status. + * + * Defaults to the conversation in the current route. Pass `conversationId` + * to read another conversation's status instead — e.g. a local planner + * helper conversation, whose run/idle transitions are tracked separately + * from the main conversation's (see conversation-state-store.ts). + */ +export function useAgentState(conversationId?: string): UseAgentStateResult { + const { conversationId: routeConversationId } = useOptionalConversationId(); + const targetConversationId = conversationId ?? routeConversationId; + const isRouteConversation = + !conversationId || conversationId === routeConversationId; + + const liveExecutionStatus = useConversationStateStore((state) => + targetConversationId + ? (state.executionStatusByConversation[targetConversationId] ?? null) + : null, + ); + // The REST fallback only ever describes the route's own conversation, so it + // only applies when no other conversation was explicitly requested. + const routeConversationExecutionStatus = + useActiveConversation().data?.execution_status ?? null; + const fallbackExecutionStatus = isRouteConversation + ? routeConversationExecutionStatus + : null; + + const executionStatus = liveExecutionStatus ?? fallbackExecutionStatus; + const curAgentState = useMemo( + () => mapExecutionStatusToAgentState(executionStatus), + [executionStatus], + ); + + return { curAgentState, executionStatus }; +} + +export interface UsePlanningAgentStateResult { + localPlanningConversationId: string | null; + curPlanningAgentState: AgentState; + /** Running or loading. `false` (not "unknown") when there's no planner yet. */ + isPlanningAgentRunning: boolean; +} + +/** + * The local planner helper's own state — read separately from the main + * conversation's via `useAgentState` since the two run independently. + */ +export function usePlanningAgentState(): UsePlanningAgentStateResult { + const localPlanningConversationId = useConversationStore( + (state) => state.localPlanningConversationId, + ); + const { curAgentState: curPlanningAgentState } = useAgentState( + localPlanningConversationId ?? undefined, + ); + const isPlanningAgentRunning = + !!localPlanningConversationId && + (curPlanningAgentState === AgentState.RUNNING || + curPlanningAgentState === AgentState.LOADING); + return { + localPlanningConversationId, + curPlanningAgentState, + isPlanningAgentRunning, + }; +} diff --git a/src/hooks/use-auto-refresh-files-on-edit.ts b/src/hooks/use-auto-refresh-files-on-edit.ts new file mode 100644 index 0000000000000000000000000000000000000000..5901c24d44d8690fc694a82ce0c6958870960104 --- /dev/null +++ b/src/hooks/use-auto-refresh-files-on-edit.ts @@ -0,0 +1,151 @@ +import { useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { useEventStore, type OHEvent } from "#/stores/use-event-store"; +import { useWorkspaceMutationCounter } from "#/stores/use-workspace-mutation-counter"; + +// `kind` values we treat as a file-mutation observation. +const FILE_EDIT_OBSERVATION_KINDS = new Set([ + "FileEditorObservation", + "StrReplaceEditorObservation", + "PlanningFileEditorObservation", +]); + +// Commands on the str-replace-editor family that don't change anything on +// disk. We don't want to invalidate caches for those. +const READ_ONLY_COMMANDS = new Set(["view"]); + +// Bash observations are how `git commit` / `git push` (and any file edit +// done through the shell) arrive, so they must refresh the Diff view too. +// Legacy agent-servers emit `ExecuteBashObservation`; the current SDK +// emits `TerminalObservation`. +const BASH_OBSERVATION_KINDS = new Set([ + "ExecuteBashObservation", + "TerminalObservation", +]); + +function isFileMutationObservation(event: OHEvent): boolean { + // ObservationEvents have `source: "environment"` and an `observation` + // field — narrow to that shape without pulling in the whole event union. + const obs = (event as { observation?: { kind?: string; command?: string } }) + .observation; + if (!obs || typeof obs.kind !== "string") return false; + if (!FILE_EDIT_OBSERVATION_KINDS.has(obs.kind)) return false; + if (obs.command && READ_ONLY_COMMANDS.has(obs.command)) return false; + return true; +} + +// Note: `READ_ONLY_COMMANDS` intentionally doesn't apply here — for bash +// observations `command` is the whole shell command line, not a +// file-editor sub-command. +function isBashObservation(event: OHEvent): boolean { + const obs = (event as { observation?: { kind?: string } }).observation; + return ( + !!obs && + typeof obs.kind === "string" && + BASH_OBSERVATION_KINDS.has(obs.kind) + ); +} + +/** + * Watches the conversation event stream and invalidates the workspace file + * queries whenever the agent commits a file-editor mutation (create / edit / + * insert / undo_edit). This keeps the Files tab's list, content view and + * diff view in sync with what the agent has actually written to disk, + * without requiring the user to click refresh manually. + * + * Bash observations also refresh the git-diff queries (`file_changes` / + * `file_diff`) — a `git commit` or `git push` changes what the Diff view + * should display, and shell commands can edit files too. They deliberately + * do NOT touch the workspace file queries or the workspace mutation + * counter: bumping the counter reloads canvas iframes, and doing that for + * every shell command the agent runs would cause constant flicker. + * Invalidation only refetches actively-mounted queries, so the cost is + * limited to when the Files tab is open. + * + * Mount this hook inside any component that should drive auto-refresh — + * the Files tab is the obvious caller. Multiple mounts are safe because + * React Query coalesces overlapping invalidations. + */ +export function useAutoRefreshFilesOnEdit(): void { + const queryClient = useQueryClient(); + const events = useEventStore((state) => state.events); + const bumpWorkspaceMutationCounter = useWorkspaceMutationCounter( + (state) => state.bump, + ); + + // Track which events we've already reacted to. Two parallel stores: + // + // 1. `processedIdsRef` — keys events that *have* an id. The event store + // re-sorts on insert when out-of-order events arrive (an older event + // can land *between* two newer ones already in the array), so we + // cannot use a `slice(processedCount)` trick — it would miss a + // late-arriving older event because the array length grew but the + // tail we just diffed didn't contain it. Using a Set of ids is O(n) + // per render in the worst case but small in practice and immune to + // reordering. Type matches the event store's own dedup set + // (`Set` in `use-event-store.ts`) so a stray + // numeric id (legacy server payload, hand-crafted test event, …) + // can't sneak past. + // + // 2. `processedEventsRef` — keys events that have NO id. We cannot put + // a literal `undefined` into the id Set: that would make the second, + // third, … id-less arrival collide on the same `undefined` key and + // silently skip them. We also cannot just skip dedup for id-less + // events: the events array is rebuilt on every store mutation but + // its element references are stable, so the same id-less event + // appears in the array forever and would re-bump the workspace + // mutation counter on every subsequent re-render. Keying by object + // reference (via a WeakSet) gives us "process each id-less event + // exactly once" — durable, free, no held-onto memory after the + // store clears. + const processedIdsRef = useRef>(new Set()); + const processedEventsRef = useRef>(new WeakSet()); + + useEffect(() => { + let hasNewFileEdits = false; + let hasNewBashCommands = false; + for (const event of events) { + const id: string | number | undefined = + "id" in event ? event.id : undefined; + const alreadyProcessed = + id !== undefined + ? processedIdsRef.current.has(id) + : processedEventsRef.current.has(event); + // Inverted predicate so we avoid `continue` (banned by repo lint). + if (!alreadyProcessed) { + if (id !== undefined) { + processedIdsRef.current.add(id); + } else { + processedEventsRef.current.add(event); + } + if (isFileMutationObservation(event)) hasNewFileEdits = true; + else if (isBashObservation(event)) hasNewBashCommands = true; + } + } + + if (!hasNewFileEdits && !hasNewBashCommands) return; + + // Editor and bash mutations both change what the git Diff view shows — + // including the per-file diff content of an already-expanded file. + // The commits list refreshes too (a `git commit` arrives as a bash + // observation); per-commit queries (`commit_changes` / + // `commit_file_diff`) are sha-addressed and immutable, so they are + // deliberately NOT invalidated. + queryClient.invalidateQueries({ queryKey: ["file_changes"] }); + queryClient.invalidateQueries({ queryKey: ["file_diff"] }); + queryClient.invalidateQueries({ queryKey: ["git_commits"] }); + + if (hasNewFileEdits) { + queryClient.invalidateQueries({ queryKey: ["workspace-files"] }); + queryClient.invalidateQueries({ queryKey: ["workspace-file-content"] }); + // Force iframes / tags pointing at the static workspace + // fileserver to re-fetch. Without this they happily keep showing the + // stale (browser-cached) bytes even after the agent has rewritten the + // file on disk — e.g. tweaking style.css would silently have no + // visible effect on the rendered index.html until the user reloaded + // the whole canvas. + bumpWorkspaceMutationCounter(); + } + }, [events, queryClient, bumpWorkspaceMutationCounter]); +} diff --git a/src/hooks/use-await-context-compaction.test.ts b/src/hooks/use-await-context-compaction.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ed550a3fe9a34ee10d2e5b227097de69ca4ac29 --- /dev/null +++ b/src/hooks/use-await-context-compaction.test.ts @@ -0,0 +1,178 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import useMetricsStore from "#/stores/metrics-store"; +import { useEventStore } from "#/stores/use-event-store"; +import type { OpenHandsEvent } from "#/types/agent-server/core"; +import { + buildContextCompactionResult, + useAwaitContextCompaction, +} from "./use-await-context-compaction"; + +function condensationEvent(id: string): OpenHandsEvent { + return { + id, + timestamp: new Date().toISOString(), + source: "environment", + kind: "Condensation", + forgotten_event_ids: ["a", "b"], + } as OpenHandsEvent; +} + +describe("buildContextCompactionResult", () => { + it("computes tokens saved without going negative", () => { + expect(buildContextCompactionResult(1000, 400, "compacted")).toEqual({ + beforeToken: 1000, + afterToken: 400, + savedToken: 600, + outcome: "compacted", + }); + expect(buildContextCompactionResult(400, 500, "no_change").savedToken).toBe( + 0, + ); + }); +}); + +describe("useAwaitContextCompaction", () => { + beforeEach(() => { + vi.useFakeTimers(); + useEventStore.getState().clearEvents(); + useMetricsStore.getState().setMetrics({ + cost: null, + max_budget_per_task: null, + usage: { + prompt_tokens: 0, + completion_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + context_window: 100_000, + per_turn_token: 10_000, + }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + useEventStore.getState().clearEvents(); + }); + + it("reports tokens freed once condensation lands and metrics drop", () => { + const onComplete = vi.fn(); + + renderHook(() => + useAwaitContextCompaction({ + beforeToken: 10_000, + onComplete, + }), + ); + + act(() => { + useEventStore.getState().addEvent(condensationEvent("cond-1")); + useMetricsStore.getState().setMetrics({ + cost: null, + max_budget_per_task: null, + usage: { + prompt_tokens: 0, + completion_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + context_window: 100_000, + per_turn_token: 4_000, + }, + }); + }); + + expect(onComplete).toHaveBeenCalledWith({ + beforeToken: 10_000, + afterToken: 4_000, + savedToken: 6_000, + outcome: "compacted", + }); + }); + + it("finishes after the settle window when metrics do not drop", () => { + const onComplete = vi.fn(); + + renderHook(() => + useAwaitContextCompaction({ + beforeToken: 10_000, + onComplete, + }), + ); + + act(() => { + useEventStore.getState().addEvent(condensationEvent("cond-2")); + }); + expect(onComplete).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(2_500); + }); + + expect(onComplete).toHaveBeenCalledWith({ + beforeToken: 10_000, + afterToken: 10_000, + savedToken: 0, + outcome: "no_change", + }); + }); + + it("reports timeout when no condensation event arrives in time", () => { + const onComplete = vi.fn(); + + renderHook(() => + useAwaitContextCompaction({ + beforeToken: 10_000, + onComplete, + }), + ); + + act(() => { + vi.advanceTimersByTime(90_000); + }); + + expect(onComplete).toHaveBeenCalledWith({ + beforeToken: 10_000, + afterToken: 10_000, + savedToken: 0, + outcome: "timeout", + }); + }); + + it("detects a condensation that landed before the post-ack effect started", () => { + // The condense POST can return only after the server already emitted its + // Condensation event. The pre-request baseline (captured before the POST + // fired) must not contain it, so it still counts as new. + const baseline = new Set(useEventStore.getState().eventIds); + act(() => { + useEventStore.getState().addEvent(condensationEvent("cond-early")); + useMetricsStore.getState().setMetrics({ + cost: null, + max_budget_per_task: null, + usage: { + prompt_tokens: 0, + completion_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + context_window: 100_000, + per_turn_token: 4_000, + }, + }); + }); + + const onComplete = vi.fn(); + renderHook(() => + useAwaitContextCompaction({ + beforeToken: 10_000, + baselineEventIds: baseline, + onComplete, + }), + ); + + expect(onComplete).toHaveBeenCalledWith({ + beforeToken: 10_000, + afterToken: 4_000, + savedToken: 6_000, + outcome: "compacted", + }); + }); +}); diff --git a/src/hooks/use-chat-input-model-state.ts b/src/hooks/use-chat-input-model-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a81e52505e68aaf2f8e98b2b01660d588b8654e --- /dev/null +++ b/src/hooks/use-chat-input-model-state.ts @@ -0,0 +1,121 @@ +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useSettings } from "#/hooks/query/use-settings"; +import { + type AcpModelContext, + useAcpModelContext, +} from "#/hooks/use-acp-model-context"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useCanManageOrgProfiles } from "#/hooks/use-can-manage-org-profiles"; +import { useActiveAcpProfileDetail } from "#/hooks/query/use-active-acp-profile-detail"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { + getAcpPreferredDefaultModel, + getAcpProvider, + labelForAcpModel, + resolveEffectiveAcpModel, + type ACPModelOption, +} from "#/constants/acp-providers"; + +export interface ChatInputModelState { + isAcpContext: boolean; + displayModel: string | null; + currentModelId: string | null; + availableAcpModels: ACPModelOption[]; + showAcpPicker: boolean; + switchConversationId: string | null; + destinationPath: AcpModelContext["destinationPath"]; + destinationLabel: string; +} + +export function useChatInputModelState(): ChatInputModelState { + const { data: conversation } = useActiveConversation(); + const { data: settings } = useSettings(); + const { conversationId } = useOptionalConversationId(); + const { backend } = useActiveBackend(); + const canManageOrgProfiles = useCanManageOrgProfiles(); + // The active ACP AgentProfile's own fields are the conversation launch + // source (activation never writes agent_settings, so the global settings + // may describe a different provider). Null in a conversation, while + // loading, when the active profile isn't ACP, or on legacy backends + // without the profiles surface — settings are the fallback in that window. + const activeAcpProfile = useActiveAcpProfileDetail(); + const { + isActiveAcpConversation, + isHomeAcp, + isAcpContext, + destinationPath, + destinationLabel, + } = useAcpModelContext(); + + const settingsAcpServerKey = + typeof settings?.agent_settings?.acp_server === "string" + ? settings.agent_settings.acp_server + : null; + const acpServerKey = isActiveAcpConversation + ? conversation?.acp_server + : isHomeAcp + ? (activeAcpProfile?.acp_server ?? settingsAcpServerKey) + : null; + const acpProvider = isAcpContext ? getAcpProvider(acpServerKey) : undefined; + + const settingsAcpModel = + typeof settings?.agent_settings?.acp_model === "string" + ? settings.agent_settings.acp_model + : null; + // Home: read the model from the same source as the server key — mixing the + // profile's provider with a stale settings model could pair e.g. a codex + // provider with a claude model. + const acpConfiguredModel = + isHomeAcp && activeAcpProfile + ? activeAcpProfile.acp_model + : settingsAcpModel; + + let currentModelId: string | null = null; + if (isActiveAcpConversation) { + // ACP conversations store llm_model as the acp_model (persisted at + // creation time). Use it directly if available; fall back to the + // settings-configured model or provider default so the chip stays visible. + currentModelId = + conversation?.llm_model ?? + resolveEffectiveAcpModel({ + configured: acpConfiguredModel, + providerDefault: getAcpPreferredDefaultModel(acpServerKey), + }); + } else if (isHomeAcp) { + currentModelId = resolveEffectiveAcpModel({ + configured: acpConfiguredModel, + // Preferred default (Vertex-safe for Gemini) — must match what the + // start request would substitute for an unconfigured model. + providerDefault: getAcpPreferredDefaultModel(acpServerKey), + }); + } else { + currentModelId = conversation?.llm_model ?? settings?.llm_model ?? null; + } + + const displayModel = + currentModelId && isAcpContext + ? (labelForAcpModel(acpServerKey, currentModelId) ?? currentModelId) + : currentModelId; + const availableAcpModels = acpProvider?.available_models ?? []; + // A home-page pick persists into the active ACP profile, which on cloud is + // org-owned — hide the selectable rows from members who'd only get a 403. + // Conversation-scoped switches (blank or started) stay member-allowed. + const canPersistHomeAcpModel = + !isHomeAcp || backend.kind !== "cloud" || canManageOrgProfiles; + const showAcpPicker = + isAcpContext && availableAcpModels.length > 0 && canPersistHomeAcpModel; + const switchConversationId = isActiveAcpConversation + ? (conversationId ?? null) + : null; + + return { + isAcpContext, + displayModel, + currentModelId, + availableAcpModels, + showAcpPicker, + switchConversationId, + destinationPath, + destinationLabel, + }; +} diff --git a/src/hooks/use-conversation-overview-git-diff-stats.ts b/src/hooks/use-conversation-overview-git-diff-stats.ts new file mode 100644 index 0000000000000000000000000000000000000000..eece09f72cfcfa45ef9b8a5359a7ef0d4478066c --- /dev/null +++ b/src/hooks/use-conversation-overview-git-diff-stats.ts @@ -0,0 +1,81 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import AgentServerGitService from "#/api/git-service/agent-server-git-service.api"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes"; +import { useConversationId } from "#/hooks/use-conversation-id"; +import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; +import { getGitPath } from "#/utils/get-git-path"; +import { + countGitChangeDiffStats, + sumGitDiffLineStats, +} from "#/utils/git-diff-stats"; + +export function useConversationOverviewGitDiffStats() { + const { conversationId } = useConversationId(); + const { data: conversation } = useActiveConversation(); + const runtimeIsReady = useRuntimeIsReady(); + const { + data: gitChanges, + isLoading: isLoadingChanges, + isSuccess: isChangesSuccess, + isError: isChangesError, + } = useUnifiedGetGitChanges(); + + const conversationUrl = conversation?.conversation_url; + const sessionApiKey = conversation?.session_api_key; + const gitPath = useMemo( + () => + getGitPath( + conversation?.selected_repository, + conversation?.workspace?.working_dir?.trim(), + ), + [conversation?.selected_repository, conversation?.workspace?.working_dir], + ); + + const diffCandidates = useMemo( + () => (gitChanges ?? []).filter((change) => change.status !== "D"), + [gitChanges], + ); + + const diffQueries = useQueries({ + queries: diffCandidates.map((change) => ({ + queryKey: [ + "conversation_overview_file_diff", + conversationId, + conversationUrl, + sessionApiKey, + gitPath, + change.path, + ], + queryFn: async () => + AgentServerGitService.getGitChangeDiff( + conversationId!, + conversationUrl, + sessionApiKey, + `${gitPath}/${change.path}`, + ), + enabled: + runtimeIsReady && !!conversationId && isChangesSuccess && !!change.path, + staleTime: 1000 * 60 * 5, + meta: { + disableToast: true, + }, + })), + }); + + const isLoadingDiffs = diffQueries.some((query) => query.isLoading); + const totals = sumGitDiffLineStats( + diffQueries.flatMap((query) => + query.data ? [countGitChangeDiffStats(query.data)] : [], + ), + ); + + return { + additions: totals.additions, + deletions: totals.deletions, + changeCount: gitChanges?.length ?? 0, + isLoading: isLoadingChanges || isLoadingDiffs, + isError: isChangesError, + }; +} diff --git a/src/hooks/use-debounce.ts b/src/hooks/use-debounce.ts new file mode 100644 index 0000000000000000000000000000000000000000..d39df3f066fc92da313ee0a271bd21b31b55100d --- /dev/null +++ b/src/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/use-device-flow.ts b/src/hooks/use-device-flow.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f346329cf1aa43b0e049be2ab857f66f92e152c --- /dev/null +++ b/src/hooks/use-device-flow.ts @@ -0,0 +1,200 @@ +import React from "react"; +import { + startDeviceFlow, + pollForToken, + DeviceFlowError, + type DeviceAuthorizationResponse, +} from "#/api/device-flow-client"; +import { + trackCloudDeviceAuthorizationStarted, + trackCloudDeviceAuthorizationSucceeded, + type CloudConnectionSource, +} from "#/services/cloud-funnel-analytics"; + +export type DeviceFlowStatus = + | "idle" + | "starting" + | "awaiting_authorization" + | "success" + | "error"; + +export interface DeviceFlowState { + status: DeviceFlowStatus; + /** The verification URL to show/open for the user */ + verificationUrl: string | null; + /** User code to display as fallback */ + userCode: string | null; + /** The resulting API key on success */ + apiKey: string | null; + /** Error message if status is "error" */ + error: string | null; + /** Error code for programmatic handling */ + errorCode: string | null; +} + +export interface UseDeviceFlowReturn extends DeviceFlowState { + /** Start the device flow authentication */ + start: StartDeviceFlow; + /** Cancel an in-progress flow */ + cancel: () => void; + /** Reset state back to idle */ + reset: () => void; +} + +type StartDeviceFlow = ( + host: string, + analyticsSource?: CloudConnectionSource, +) => void; + +const initialState: DeviceFlowState = { + status: "idle", + verificationUrl: null, + userCode: null, + apiKey: null, + error: null, + errorCode: null, +}; + +/** + * React hook for managing OAuth 2.0 Device Flow authentication. + * + * Usage: + * ```tsx + * const { status, verificationUrl, apiKey, error, start, cancel, reset } = useDeviceFlow(); + * + * // Start auth + * start("https://app.all-hands.dev"); + * + * // Open browser when awaiting + * if (status === "awaiting_authorization" && verificationUrl) { + * window.open(verificationUrl, "_blank"); + * } + * + * // Use API key on success + * if (status === "success" && apiKey) { + * setApiKeyField(apiKey); + * } + * ``` + */ +export function useDeviceFlow(): UseDeviceFlowReturn { + const [state, setState] = React.useState(initialState); + const abortControllerRef = React.useRef(null); + + const start: StartDeviceFlow = React.useCallback((host, analyticsSource) => { + // Cancel any existing flow + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + setState({ + ...initialState, + status: "starting", + }); + + (async () => { + let authResponse: DeviceAuthorizationResponse; + + try { + authResponse = await startDeviceFlow(host); + } catch (error) { + if (abortController.signal.aborted) return; + + const message = + error instanceof Error + ? error.message + : "Failed to start device flow"; + const code = error instanceof DeviceFlowError ? error.code : undefined; + + setState({ + ...initialState, + status: "error", + error: message, + errorCode: code ?? null, + }); + return; + } + + if (abortController.signal.aborted) return; + + trackCloudDeviceAuthorizationStarted(host, analyticsSource); + + setState({ + ...initialState, + status: "awaiting_authorization", + verificationUrl: authResponse.verification_uri_complete, + userCode: authResponse.user_code, + }); + + try { + const tokenResponse = await pollForToken( + host, + authResponse.device_code, + { + interval: authResponse.interval, + signal: abortController.signal, + }, + ); + + if (abortController.signal.aborted) return; + + trackCloudDeviceAuthorizationSucceeded(host, analyticsSource); + + setState({ + ...initialState, + status: "success", + apiKey: tokenResponse.access_token, + }); + } catch (error) { + // Early return if component unmounted or user cancelled + if (abortController.signal.aborted) return; + + // Defensive: handle cancellation errors that may slip through + // (currently pollForToken only throws "cancelled" when signal.aborted, + // which is caught above, but this guards against future changes) + const isCancel = + error instanceof DeviceFlowError && error.code === "cancelled"; + if (isCancel) { + setState(initialState); + return; + } + + const message = + error instanceof Error ? error.message : "Authorization failed"; + const code = error instanceof DeviceFlowError ? error.code : undefined; + + setState({ + ...initialState, + status: "error", + error: message, + errorCode: code ?? null, + }); + } + })(); + }, []); + + const cancel = React.useCallback(() => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setState(initialState); + }, []); + + const reset = React.useCallback(() => { + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + setState(initialState); + }, []); + + // Cleanup on unmount + React.useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + return { + ...state, + start, + cancel, + reset, + }; +} diff --git a/src/hooks/use-ensure-active-profile.ts b/src/hooks/use-ensure-active-profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..83feb1577421c52645edb3d087203c33caec3d86 --- /dev/null +++ b/src/hooks/use-ensure-active-profile.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from "react"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; +import { useActivateLlmProfile } from "#/hooks/mutation/use-activate-llm-profile"; + +/** + * Local-mode UX policy: keep an LLM profile active whenever at least one + * exists, so the agent always has a usable LLM without a manual "activate" + * step. This lives in the client on purpose — the agent-server is a neutral + * API that returns `active_profile` as stored (possibly pointing at a deleted + * profile); other consumers (e.g. SaaS) own the LLM differently. + * + * When profiles exist but none is the active one — never activated, or the + * active profile was deleted — it activates the first profile that has an API + * key (falling back to the first profile). With zero profiles it does nothing: + * that genuine "no LLM" state is surfaced by the gate/banner. + */ +export function useEnsureActiveProfile(): void { + const { backend } = useActiveBackend(); + const isLocal = backend.kind === "local"; + const { data: profilesData } = useLlmProfiles(); + const { mutate: activate, isPending } = useActivateLlmProfile(); + + // Remember the last profile we tried to activate so we don't re-fire while + // the mutation + refetch settle, or hammer a profile whose activation fails. + const attemptedRef = useRef(null); + + // A backend switch is a clean slate for the above guard. + useEffect(() => { + attemptedRef.current = null; + }, [backend.id]); + + useEffect(() => { + if (!isLocal || isPending || !profilesData) return; + + const { profiles, active_profile: activeProfile } = profilesData; + const activeValid = + activeProfile != null && profiles.some((p) => p.name === activeProfile); + + if (profiles.length === 0 || activeValid) { + attemptedRef.current = null; + return; + } + + // Prefer a profile with a key so the result is immediately usable. + const target = profiles.find((p) => p.api_key_set) ?? profiles[0]; + if (attemptedRef.current === target.name) return; + attemptedRef.current = target.name; + activate(target.name); + }, [isLocal, profilesData, isPending, activate]); +} diff --git a/src/hooks/use-handle-plan-click.ts b/src/hooks/use-handle-plan-click.ts new file mode 100644 index 0000000000000000000000000000000000000000..599084972ed920e6a9c98a1e144f05854dd437ab --- /dev/null +++ b/src/hooks/use-handle-plan-click.ts @@ -0,0 +1,251 @@ +import { useCallback, useEffect, type MouseEvent } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { useConversationStore } from "#/stores/conversation-store"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { + getConversationState, + setConversationState, +} from "#/utils/conversation-local-storage"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import { + CONVERSATION_QUERY_KEYS, + LOCAL_PLANNER_MUTATION_KEYS, +} from "#/hooks/query/query-keys"; +import { useSubConversations } from "#/hooks/query/use-sub-conversations"; +import { findPlannerConversationId } from "#/utils/plan-file"; +import { invalidateConversationQueries } from "#/hooks/mutation/conversation-mutation-utils"; + +function useCreateLocalPlanningConversationMutation(options: { + onCreated: (planningConversationId: string) => void; + onInitialized: () => void; + onFailed: () => void; +}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: LOCAL_PLANNER_MUTATION_KEYS.create, + mutationFn: (variables: { + parentConversationId: string; + initialMessage?: string; + }) => + AgentServerConversationService.createLocalPlanningConversation( + variables.parentConversationId, + variables.initialMessage, + ), + onSuccess: (planningConversation, variables) => { + options.onCreated(planningConversation.id); + invalidateConversationQueries( + queryClient, + variables.parentConversationId, + ); + queryClient.invalidateQueries({ + queryKey: CONVERSATION_QUERY_KEYS.subConversations, + }); + options.onInitialized(); + }, + onError: options.onFailed, + }); +} + +function restorePlanningConversationIds(options: { + conversationId: string; + serverPlanningConversationId: string | null; + subConversationTaskId: string | null; + localPlanningConversationId: string | null; + setSubConversationTaskId: (taskId: string | null) => void; + setLocalPlanningConversationId: (conversationId: string | null) => void; +}) { + const storedState = getConversationState(options.conversationId); + if (storedState.subConversationTaskId && !options.subConversationTaskId) { + options.setSubConversationTaskId(storedState.subConversationTaskId); + } + + // Server first: `sub_conversation_ids` is derived by the agent-server from + // the planner's `parent_conversation_id`, so it survives cleared site data + // and follows the user to another browser. The localStorage hint is only the + // fallback for agent-servers older than 1.37.1, which drop the parent link. + const restoredId = + options.serverPlanningConversationId ?? + getStoredConversationMetadata(options.conversationId) + ?.local_planning_conversation_id ?? + null; + + if (restoredId && restoredId !== options.localPlanningConversationId) { + options.setLocalPlanningConversationId(restoredId); + } +} + +/** + * Custom hook that encapsulates the logic for handling plan creation. + * Returns a function that can be called to create a plan conversation and + * the pending state of the conversation creation. + * + * @returns An object containing handlePlanClick function and isCreatingConversation boolean + */ +export const useHandlePlanClick = () => { + const { t } = useTranslation("openhands"); + const { backend } = useActiveBackend(); + const { + setConversationMode, + setSubConversationTaskId, + subConversationTaskId, + setLocalPlanningConversationId, + localPlanningConversationId, + } = useConversationStore(); + const { data: conversation } = useActiveConversation(); + const { mutate: createConversation, isPending: isCreatingCloudConversation } = + useCreateConversation(); + const { + mutate: createLocalPlanningConversation, + isPending: isCreatingLocalPlanningConversation, + } = useCreateLocalPlanningConversationMutation({ + onCreated: setLocalPlanningConversationId, + onInitialized: () => { + displaySuccessToast( + t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED), + ); + }, + // handlePlanClick sets conversationMode("plan") before this mutation + // starts. On failure, back out of that mode instead of stranding the + // user in plan mode with no planner to talk to. + onFailed: () => { + setConversationMode("code"); + displayErrorToast(t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION)); + }, + }); + + // On local backends the agent-server reports the planner helper back on the + // parent's `sub_conversation_ids` (it was created with + // `parent_conversation_id`), so that is the authoritative handle. Cloud + // sub-conversations are driven by their own task/socket plumbing. + const isLocalBackend = backend.kind !== "cloud"; + const { data: rawSubConversations } = useSubConversations( + isLocalBackend ? conversation?.sub_conversation_ids : undefined, + ); + + const serverPlanningConversationId = isLocalBackend + ? findPlannerConversationId(rawSubConversations, conversation?.id) + : null; + + // Restore planning conversation ids on conversation load. This handles page + // refreshes while cloud or local planning conversation creation is in + // progress, and recovers the local planner after browser storage is lost. + useEffect(() => { + if (!conversation?.id) return; + + restorePlanningConversationIds({ + conversationId: conversation.id, + serverPlanningConversationId, + subConversationTaskId, + localPlanningConversationId, + setSubConversationTaskId, + setLocalPlanningConversationId, + }); + }, [ + conversation?.id, + serverPlanningConversationId, + localPlanningConversationId, + setLocalPlanningConversationId, + subConversationTaskId, + setSubConversationTaskId, + ]); + + const hasCloudPlanner = !!( + (conversation?.sub_conversation_ids && + conversation.sub_conversation_ids.length > 0) || + subConversationTaskId + ); + // Whether a planner helper already exists for this conversation — callers + // (e.g. the `/plan ` interceptor) use this to decide whether they can + // send a message to the planner immediately, or must wait for creation. + const hasPlanner = isLocalBackend + ? !!(localPlanningConversationId || serverPlanningConversationId) + : hasCloudPlanner; + + const handlePlanClick = useCallback( + ( + event?: MouseEvent | KeyboardEvent, + initialMessage?: string, + ) => { + event?.preventDefault(); + event?.stopPropagation(); + + setConversationMode("plan"); + + if (backend.kind !== "cloud") { + // Guard on the server-reported helper, the store, and the mutation's + // own in-flight state — the last one stops two rapid invocations + // (e.g. a double-click) from both passing before either updates and + // creating two planners for the same parent. + if ( + !conversation?.id || + localPlanningConversationId || + serverPlanningConversationId || + isCreatingLocalPlanningConversation + ) { + return; + } + createLocalPlanningConversation({ + parentConversationId: conversation.id, + initialMessage, + }); + return; + } + + if (hasCloudPlanner || !conversation?.id) { + return; + } + + createConversation( + { + parentConversationId: conversation.id, + agentType: "plan", + entryPoint: "plan_sub_conversation", + ...(initialMessage ? { query: initialMessage } : {}), + }, + { + onSuccess: (data) => { + displaySuccessToast( + t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED), + ); + if (data.task_id) { + setSubConversationTaskId(data.task_id); + setConversationState(conversation.id, { + subConversationTaskId: data.task_id, + }); + } + }, + }, + ); + }, + [ + backend.kind, + conversation, + createConversation, + createLocalPlanningConversation, + hasCloudPlanner, + isCreatingLocalPlanningConversation, + localPlanningConversationId, + serverPlanningConversationId, + setConversationMode, + setSubConversationTaskId, + t, + ], + ); + + return { + handlePlanClick, + hasPlanner, + isCreatingConversation: + isCreatingCloudConversation || isCreatingLocalPlanningConversation, + }; +}; diff --git a/src/hooks/use-handle-runtime-active.ts b/src/hooks/use-handle-runtime-active.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcddac3d51e8e3cacca3fe1fd56d91dc341709ed --- /dev/null +++ b/src/hooks/use-handle-runtime-active.ts @@ -0,0 +1,10 @@ +import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; +import { useAgentState } from "#/hooks/use-agent-state"; + +export const useHandleRuntimeActive = () => { + const { curAgentState } = useAgentState(); + + const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState); + + return { runtimeActive }; +}; diff --git a/src/hooks/use-handle-ws-events.ts b/src/hooks/use-handle-ws-events.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b3b78ac7476ef98cc6a4b6c74bff3fa29422d04 --- /dev/null +++ b/src/hooks/use-handle-ws-events.ts @@ -0,0 +1,63 @@ +import React from "react"; +import { generateAgentStateChangeEvent } from "#/services/agent-state-service"; +import { AgentState } from "#/types/agent-state"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { useEventStore } from "#/stores/use-event-store"; +import { useSendMessage } from "#/hooks/use-send-message"; +import { + isAgentErrorEvent, + isAgentServerEvent, +} from "#/types/agent-server/type-guards"; + +interface ServerError { + error: boolean | string; + message: string; + [key: string]: unknown; +} + +const isServerError = (data: object): data is ServerError => "error" in data; + +const isTypedErrorEvent = ( + event: object, +): event is { type: "error"; message?: unknown } => + "type" in event && event.type === "error"; + +export const useHandleWSEvents = () => { + const { send } = useSendMessage(); + const events = useEventStore((state) => state.events); + + React.useEffect(() => { + if (!events.length) { + return; + } + const event = events[events.length - 1]; + + // V1 agent errors are surfaced inline in the chat log (and via the error + // banner), so don't double-notify with a toast. + if (isAgentServerEvent(event) && isAgentErrorEvent(event)) { + return; + } + + if (isServerError(event)) { + if (event.error_code === 401) { + displayErrorToast("Session expired."); + return; + } + + if (typeof event.error === "string") { + displayErrorToast(event.error); + } else { + displayErrorToast(event.message); + } + return; + } + + if (isTypedErrorEvent(event)) { + const message: string = `${event.message ?? ""}`; + if (message.startsWith("Agent reached maximum")) { + // We set the agent state to paused here - if the user clicks resume, it auto updates the max iterations + send(generateAgentStateChangeEvent(AgentState.PAUSED)); + } + } + }, [events.length]); +}; diff --git a/src/hooks/use-llm-configured.test.tsx b/src/hooks/use-llm-configured.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4ae6b0aa186758c61c2d5e378c3c3984f42b944 --- /dev/null +++ b/src/hooks/use-llm-configured.test.tsx @@ -0,0 +1,176 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import SettingsService from "#/api/settings-service/settings-service.api"; +import OptionService from "#/api/option-service/option-service.api"; +import ProfilesService from "#/api/profiles-service/profiles-service.api"; +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 { DEFAULT_SETTINGS } from "#/services/settings"; +import { useLlmConfigured } from "./use-llm-configured"; + +vi.mock("#/api/settings-service/settings-service.api", () => ({ + default: { + getSettings: vi.fn(), + }, +})); + +vi.mock("#/api/option-service/option-service.api", () => ({ + default: { + getConfig: vi.fn(), + }, +})); + +vi.mock("#/api/profiles-service/profiles-service.api", () => ({ + default: { + listProfiles: vi.fn(), + getProfile: vi.fn(), + }, +})); + +const localBackend: Backend = { + id: "test-local", + name: "Test Local", + host: "http://localhost:8001", + apiKey: "test-key", + kind: "local", +}; + +function renderLlmConfiguredHook() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + return renderHook(() => useLlmConfigured(), { wrapper }); +} + +describe("useLlmConfigured", () => { + const mockGetSettings = vi.mocked(SettingsService.getSettings); + const mockGetConfig = vi.mocked(OptionService.getConfig); + const mockListProfiles = vi.mocked(ProfilesService.listProfiles); + const mockGetProfile = vi.mocked(ProfilesService.getProfile); + + beforeEach(() => { + setRegisteredBackends([localBackend]); + setActiveSelection({ backendId: localBackend.id }); + + mockGetSettings.mockResolvedValue({ + ...DEFAULT_SETTINGS, + llm_api_key_set: false, + agent_settings: { + ...(DEFAULT_SETTINGS.agent_settings ?? {}), + agent_kind: "openhands", + llm: { + model: "openai/gpt-5.5", + auth_type: "subscription", + subscription_vendor: "openai", + }, + }, + }); + mockGetConfig.mockResolvedValue({ + feature_flags: { hide_llm_settings: false }, + } as Awaited>); + }); + + afterEach(() => { + vi.clearAllMocks(); + setActiveSelection(null); + setRegisteredBackends([]); + }); + + it("treats an active subscription LLM profile without an API key as configured", async () => { + mockListProfiles.mockResolvedValue({ + active_profile: "gpt-5.5-sub", + profiles: [ + { + name: "gpt-5.5-sub", + model: "gpt-5.5", + base_url: "https://chatgpt.com/backend-api/codex", + api_key_set: false, + }, + ], + }); + mockGetProfile.mockResolvedValue({ + name: "gpt-5.5-sub", + api_key_set: false, + config: { + model: "openai/gpt-5.5", + auth_type: "subscription", + subscription_vendor: "openai", + }, + }); + + const { result } = renderLlmConfiguredHook(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.isConfigured).toBe(true); + expect(mockGetProfile).toHaveBeenCalledWith("gpt-5.5-sub"); + }); + + it("uses the api_key_set fast path for an API-key profile without fetching profile detail", async () => { + mockListProfiles.mockResolvedValue({ + active_profile: "gpt-5.5", + profiles: [ + { + name: "gpt-5.5", + model: "gpt-5.5", + base_url: "https://api.openai.com/v1", + api_key_set: true, + }, + ], + }); + + const { result } = renderLlmConfiguredHook(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.isConfigured).toBe(true); + // The fast path must not fetch active profile detail. + expect(mockGetProfile).not.toHaveBeenCalled(); + }); + + it("treats a no-key, non-subscription local profile as not configured", async () => { + mockListProfiles.mockResolvedValue({ + active_profile: "gpt-5.5", + profiles: [ + { + name: "gpt-5.5", + model: "gpt-5.5", + base_url: "https://api.openai.com/v1", + api_key_set: false, + }, + ], + }); + mockGetProfile.mockResolvedValue({ + name: "gpt-5.5", + api_key_set: false, + config: { + model: "openai/gpt-5.5", + // No auth_type: "subscription" — this is a plain key-less profile, + // not a subscription-authenticated one, so it must be unconfigured. + }, + }); + + const { result } = renderLlmConfiguredHook(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.isConfigured).toBe(false); + // Detail is fetched because the profile is local + active + key-less. + expect(mockGetProfile).toHaveBeenCalledWith("gpt-5.5"); + }); +}); diff --git a/src/hooks/use-migrate-enabled-skills.ts b/src/hooks/use-migrate-enabled-skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2a6300819aebe2f078728480a31b15cafe4fa7a --- /dev/null +++ b/src/hooks/use-migrate-enabled-skills.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react"; +import { isNoBackend } from "#/api/backend-registry/active-store"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { useSettings } from "#/hooks/query/use-settings"; +import { + migrateSkillEnablement, + toSkillEnablement, +} from "#/utils/skill-enablement"; + +/** + * Move a workspace to an explicit `enabled_skills` allow-list, once. + * + * It runs at the app root, not on the Customize page: until it has run the + * resolver falls back to the curated default, which would silently drop + * catalog skills an existing workspace had switched on. Local only — cloud + * never reads the field. + */ +export function useMigrateEnabledSkills(): void { + const { backend } = useActiveBackend(); + const isLocal = backend.kind === "local" && !isNoBackend(backend); + const { data: settings, isLoading, isError } = useSettings(); + const { mutate: saveSettings } = useSaveSettings(); + + // One attempt per backend: the save invalidates the settings query, so + // without this the refetch would re-enter before the write is visible. + const migratedBackendRef = useRef(null); + + useEffect(() => { + migratedBackendRef.current = null; + }, [backend.id]); + + useEffect(() => { + if (!isLocal || isLoading || isError || !settings) return; + if (migratedBackendRef.current === backend.id) return; + + migratedBackendRef.current = backend.id; + const migrated = migrateSkillEnablement(toSkillEnablement(settings)); + // Silent on failure: the resolver's fallback keeps the session working and + // the next app load retries. + if (migrated) saveSettings(migrated); + }, [isLocal, isLoading, isError, settings, backend.id, saveSettings]); +} diff --git a/src/hooks/use-pinned-home-route.ts b/src/hooks/use-pinned-home-route.ts new file mode 100644 index 0000000000000000000000000000000000000000..91347ce84e910d0195edc14158606572e0df35be --- /dev/null +++ b/src/hooks/use-pinned-home-route.ts @@ -0,0 +1,113 @@ +import { useLocalStorage } from "@uidotdev/usehooks"; +import { useCallback, useMemo } from "react"; +import { + getActiveBackend, + isNoBackend, +} from "#/api/backend-registry/active-store"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { + automationListPath, + hasAutomationInterface, +} from "#/manifests/automation-interface"; + +export const PINNED_HOME_ROUTE_KEY = "oh:pinned-home-route"; + +/** Sidebar Customize entry; also the pin target, so the two cannot drift. */ +export const CUSTOMIZE_PATH = "/customize"; + +/** + * The pin is stored per backend + org: it may reference a surface that only + * exists on the backend that set it (e.g. /automations requires that + * deployment's interface manifest) — a shared key would let one backend + * redirect `/` on another. + */ +export function getPinnedHomeRouteKey( + backendId: string, + orgId: string | null, +): string { + return `${PINNED_HOME_ROUTE_KEY}:${backendId}:${orgId ?? "-"}`; +} + +/** + * Whether `path` may serve as the home route right now. Shared by the + * sidebar pin affordance and the `/` loader, so a stored pin that stops + * resolving (backend switch, manifest absent) is ignored rather than an + * error. `/` is never pinnable, which makes a redirect loop impossible. + * Canvas Extensions pages can later add an `/extensions/…` branch here + * without any storage or loader change. + */ +export function isPinnableRoute(path: string): boolean { + if (path === CUSTOMIZE_PATH) return true; + if (path === automationListPath()) return hasAutomationInterface(); + return false; +} + +function sanitizePinnedRoute(value: unknown): string | null { + if (typeof value !== "string" || !value) return null; + return isPinnableRoute(value) ? value : null; +} + +/** + * Synchronous pin read for route loaders (no React context). Reads the key + * `usePinnedHomeRoute` writes; `useLocalStorage` JSON-serializes values, so + * parse defensively and treat anything unreadable as "no pin". An invalid + * pin is ignored, not cleared — it may become valid again (e.g. the + * automations interface returning after a backend switch back). + */ +export function readPinnedHomeRoute(): string | null { + const active = getActiveBackend(); + if (isNoBackend(active.backend)) return null; + + let raw: string | null = null; + try { + raw = window.localStorage.getItem( + getPinnedHomeRouteKey(active.backend.id, active.orgId), + ); + } catch { + return null; + } + if (raw === null) return null; + + try { + return sanitizePinnedRoute(JSON.parse(raw)); + } catch { + return null; + } +} + +/** Pin state for the home route: `/` redirects to the pinned sidebar page. */ +export function usePinnedHomeRoute() { + const active = useActiveBackend(); + const [rawPinnedRoute, setRawPinnedRoute] = useLocalStorage( + getPinnedHomeRouteKey(active.backend.id, active.orgId), + null, + ); + + const pinnedRoute = useMemo( + () => sanitizePinnedRoute(rawPinnedRoute), + [rawPinnedRoute], + ); + + const isPinnedRoute = useCallback( + (path: string) => pinnedRoute === path, + [pinnedRoute], + ); + + const togglePinnedRoute = useCallback( + (path: string) => { + if (pinnedRoute === path) { + setRawPinnedRoute(null); + return; + } + if (!isPinnableRoute(path)) return; + setRawPinnedRoute(path); + }, + [pinnedRoute, setRawPinnedRoute], + ); + + return { + pinnedRoute, + isPinnedRoute, + togglePinnedRoute, + }; +} diff --git a/src/hooks/use-popover-fixed-placement.ts b/src/hooks/use-popover-fixed-placement.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef73ce3f2d9777347edca544536c3869c913b549 --- /dev/null +++ b/src/hooks/use-popover-fixed-placement.ts @@ -0,0 +1,56 @@ +import React from "react"; + +export interface FixedPlacementBox { + top: number; + left: number; + width: number; +} + +/** + * Position a popover with `position: fixed`, anchored below the trigger and + * clamped within the viewport. Used by the conversation-panel "+ New + * conversation" menus when they're rendered inside an overflow-hidden + * sidebar and would otherwise be clipped. + * + * Returns the measured `{ top, left, width }` box (or `null` when the + * popover is closed or fixed placement is disabled). The hook also wires + * up window resize + capture-phase scroll listeners so the box follows the + * trigger as the page moves. + */ +export function usePopoverFixedPlacement( + triggerRef: React.RefObject, + options: { open: boolean; enabled: boolean; targetWidth?: number }, +): FixedPlacementBox | null { + const { open, enabled, targetWidth = 16 * 16 } = options; + const [box, setBox] = React.useState(null); + + const measure = React.useCallback(() => { + const el = triggerRef.current; + if (!el) return; + const r = el.getBoundingClientRect(); + const gutter = 8; + const width = Math.min(targetWidth, window.innerWidth - gutter * 2); + let left = r.right - width; + if (left < gutter) left = gutter; + if (left + width > window.innerWidth - gutter) { + left = Math.max(gutter, window.innerWidth - gutter - width); + } + setBox({ top: r.bottom + 4, left, width }); + }, [triggerRef, targetWidth]); + + React.useLayoutEffect(() => { + if (!open || !enabled) { + setBox(null); + return undefined; + } + measure(); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + return () => { + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + }; + }, [open, enabled, measure]); + + return box; +} diff --git a/src/hooks/use-resizable-drawer-width.ts b/src/hooks/use-resizable-drawer-width.ts new file mode 100644 index 0000000000000000000000000000000000000000..37bded5d4f9faaa0f21ad9b98e2cea7c1a585506 --- /dev/null +++ b/src/hooks/use-resizable-drawer-width.ts @@ -0,0 +1,126 @@ +import { + useCallback, + useLayoutEffect, + useRef, + useState, + type RefObject, +} from "react"; +import { useLocalStorage } from "@uidotdev/usehooks"; + +const PANEL_DRAG_SHIELD_Z_INDEX = 200; + +type ResizableDrawerEdge = "left" | "right"; + +interface UseResizableDrawerWidthOptions { + containerRef: RefObject; + defaultWidth: number; + minWidth: number; + maxWidth: number; + storageKey: string; + enabled?: boolean; + /** + * Which side of the container the panel is anchored to. + * - `"right"` (default): width grows leftward from the container's right edge + * - `"left"`: width grows rightward from the container's left edge + */ + edge?: ResizableDrawerEdge; +} + +export function useResizableDrawerWidth({ + containerRef, + defaultWidth, + minWidth, + maxWidth, + storageKey, + enabled = true, + edge = "right", +}: UseResizableDrawerWidthOptions) { + const [persistedWidth, setPersistedWidth] = useLocalStorage( + storageKey, + defaultWidth, + ); + + const clampWidth = useCallback( + (width: number) => Math.max(minWidth, Math.min(maxWidth, width)), + [minWidth, maxWidth], + ); + + const [drawerWidth, setDrawerWidth] = useState(() => + clampWidth(persistedWidth), + ); + const [isDragging, setIsDragging] = useState(false); + const drawerWidthRef = useRef(drawerWidth); + + drawerWidthRef.current = drawerWidth; + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + if (!enabled) { + return; + } + event.preventDefault(); + setIsDragging(true); + }, + [enabled], + ); + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + if (!isDragging || !containerRef.current) { + return; + } + + const containerRect = containerRef.current.getBoundingClientRect(); + const nextWidth = + edge === "left" + ? event.clientX - containerRect.left + : containerRect.right - event.clientX; + setDrawerWidth(clampWidth(nextWidth)); + }, + [clampWidth, containerRef, edge, isDragging], + ); + + const handleMouseUp = useCallback(() => { + if (!isDragging) { + return; + } + setIsDragging(false); + setPersistedWidth(drawerWidthRef.current); + }, [isDragging, setPersistedWidth]); + + useLayoutEffect(() => { + if (!isDragging) { + return undefined; + } + + const shield = document.createElement("div"); + shield.setAttribute("aria-hidden", "true"); + shield.dataset.panelDragShield = ""; + Object.assign(shield.style, { + position: "fixed", + inset: "0", + zIndex: String(PANEL_DRAG_SHIELD_Z_INDEX), + cursor: "ew-resize", + }); + document.body.appendChild(shield); + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "ew-resize"; + document.body.style.userSelect = "none"; + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + shield.remove(); + }; + }, [handleMouseMove, handleMouseUp, isDragging]); + + return { + drawerWidth, + isDragging, + handleMouseDown, + }; +} diff --git a/src/hooks/use-skill-installs.ts b/src/hooks/use-skill-installs.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbb4b57b3cb1bf76ba678e268b2545ad3eb53dde --- /dev/null +++ b/src/hooks/use-skill-installs.ts @@ -0,0 +1,33 @@ +import { useCallback, useMemo } from "react"; +import { useEventStore } from "#/stores/use-event-store"; +import { useSkillInstallBannerStore } from "#/stores/skill-install-banner-store"; +import { detectSkillInstalls } from "#/utils/skill-install-events"; + +/** + * Skill installs performed by the agent in this conversation (via the + * bundled add-skill flow), minus the ones the user dismissed. The event + * store is global, so results are scoped to the conversation it currently + * holds — a mismatched id (remount race) yields no installs. + */ +export const useSkillInstalls = (conversationId: string | null | undefined) => { + const events = useEventStore((s) => s.events); + const loadedConversationId = useEventStore((s) => s.loadedConversationId); + const dismissedEventIds = useSkillInstallBannerStore( + (s) => s.dismissedEventIds, + ); + const dismiss = useSkillInstallBannerStore((s) => s.dismiss); + + const installs = useMemo(() => { + if (!conversationId || conversationId !== loadedConversationId) return []; + return detectSkillInstalls(events).filter( + (install) => !dismissedEventIds[install.eventId], + ); + }, [conversationId, loadedConversationId, events, dismissedEventIds]); + + const dismissAll = useCallback( + () => dismiss(installs.map((install) => install.eventId)), + [dismiss, installs], + ); + + return { installs, dismissAll }; +}; diff --git a/src/hooks/use-task-list.ts b/src/hooks/use-task-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..d476500f4ea3335834bc9065799d864fffc7d042 --- /dev/null +++ b/src/hooks/use-task-list.ts @@ -0,0 +1,48 @@ +import { useMemo } from "react"; +import { useEventStore } from "#/stores/use-event-store"; +import { isObservationEvent } from "#/types/agent-server/type-guards"; +import type { TaskTrackerObservation } from "#/types/agent-server/core/base/observation"; +import type { ObservationEvent } from "#/types/agent-server/core/events/observation-event"; + +export interface TaskListItem { + id: string; + title: string; + status: "todo" | "in_progress" | "done"; + notes?: string; +} + +function getTaskListFromEvent( + event: ReturnType["events"][number], +): TaskListItem[] | null { + if ( + isObservationEvent(event) && + event.observation.kind === "TaskTrackerObservation" + ) { + const obs = (event as ObservationEvent).observation; + if (obs.command === "plan") { + return obs.task_list.map((t, i) => ({ + id: String(i + 1), + title: t.title, + status: t.status, + notes: t.notes || undefined, + })); + } + } + + return null; +} + +export function useTaskList() { + const events = useEventStore((state) => state.events); + + return useMemo(() => { + for (let i = events.length - 1; i >= 0; i -= 1) { + const taskList = getTaskListFromEvent(events[i]); + if (taskList) { + return { taskList, hasTaskList: taskList.length > 0 }; + } + } + + return { taskList: [] as TaskListItem[], hasTaskList: false }; + }, [events]); +} diff --git a/src/hooks/use-tracking.ts b/src/hooks/use-tracking.ts new file mode 100644 index 0000000000000000000000000000000000000000..03a94dc76edc1d7fad81c7adc1ef9dbe62e56358 --- /dev/null +++ b/src/hooks/use-tracking.ts @@ -0,0 +1,500 @@ +import React from "react"; +import { Provider } from "#/types/settings"; +import type { BackendKind } from "#/api/backend-registry/types"; +import type { WorkspaceMode } from "#/api/conversation-metadata-store"; +import type { CloudConnectionSource } from "#/services/cloud-funnel-analytics"; +import { getCachedAgentServerVersion } from "#/api/agent-server-compatibility"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { useAutomationSdkVersion } from "#/hooks/query/use-automation-sdk-version"; +import { + type BackendConnectionMethod, + getBackendTelemetryProperties, +} from "#/services/telemetry-context"; +import { setTelemetryBackendContext, trackEvent } from "#/services/telemetry"; + +/** + * Stable semantic identifier for an onboarding link or CTA. Identifies the + * destination, never the clicked element or its text. + */ +export type OnboardingLinkId = + | "configure_llm" + | "start_conversation" + | "schedule_task" + | "customize_agent" + | "connect_mcp" + | "join_slack" + | "open_docs"; + +/** Controlled destination category for `onboarding_link_clicked`. */ +export type OnboardingLinkDestinationType = + | "community" + | "integration" + | "documentation" + | "settings" + | "conversation" + | "automation"; + +/** Onboarding surface that presented the link. */ +export type OnboardingLinkSurface = "landing_checklist" | "onboarding_modal"; + +/** + * Hook that provides tracking functions with automatic data collection + * from available hooks (settings, etc.) + * + * All events require explicit user consent. The shared PostHog client enforces + * the canonical consent configured by telemetry.ts; this hook must not gate on + * backend settings because they can be stale while a backend changes. + */ +export const useTracking = () => { + const { backend } = useActiveBackend(); + const automationSdkVersion = useAutomationSdkVersion(); + + const getBackendTelemetryContext = React.useCallback( + () => ({ + backendKind: backend.kind, + agentServerVersion: + backend.kind === "local" + ? getCachedAgentServerVersion(backend.host) + : null, + automationSdkVersion: automationSdkVersion ?? null, + }), + [automationSdkVersion, backend.host, backend.kind], + ); + + React.useEffect(() => { + setTelemetryBackendContext(getBackendTelemetryContext()); + }, [getBackendTelemetryContext]); + + // Common properties included in all tracking events + const commonProperties = { + current_url: window.location.href, + }; + + /** + * PostHog enforces the canonical consent state configured by telemetry.ts. + * Backend settings are not a capture gate because they can be stale while a + * backend is being added or switched. + */ + const track = (event: string, properties: Record = {}) => { + setTelemetryBackendContext(getBackendTelemetryContext()); + void trackEvent(event, { ...commonProperties, ...properties }); + }; + + const trackLoginButtonClick = ({ provider }: { provider: Provider }) => { + track("login_button_clicked", { provider }); + }; + + const trackConversationCreated = ({ + conversationId, + taskId, + hasRepository, + gitProvider, + hasWorkspace, + workspaceMode, + hasInitialQuery, + agentType, + hasParentConversation, + entryPoint, + }: { + conversationId: string; + taskId?: string; + hasRepository: boolean; + gitProvider?: Provider; + hasWorkspace: boolean; + workspaceMode?: WorkspaceMode; + hasInitialQuery: boolean; + agentType?: "default" | "plan"; + hasParentConversation: boolean; + entryPoint?: string; + }) => { + track("conversation_start_requested", { + conversation_id: conversationId, + task_id: taskId, + is_start_task: conversationId.startsWith("task-"), + has_repository: hasRepository, + git_provider: gitProvider, + has_workspace: hasWorkspace, + workspace_mode: workspaceMode, + has_initial_query: hasInitialQuery, + agent_type: agentType, + has_parent_conversation: hasParentConversation, + entry_point: entryPoint, + }); + }; + + const trackPushButtonClick = () => { + track("push_button_clicked"); + }; + + const trackPullButtonClick = () => { + track("pull_button_clicked"); + }; + + const trackCreatePrButtonClick = () => { + track("create_pr_button_clicked"); + }; + + const trackUserSignupCompleted = () => { + track("user_signup_completed", { + signup_timestamp: new Date().toISOString(), + }); + }; + + const trackPrebuiltAutomationEnabled = ({ + automationId, + automationName, + automationCategory, + }: { + automationId?: string; + automationName: string; + automationCategory?: string; + }) => { + track("prebuilt_automation_enabled", { + automation_id: automationId, + automation_name: automationName, + automation_category: automationCategory, + }); + }; + + /** + * The setup funnel: opened → validated → created, or failed. + * + * A manifest declares no analytics of its own, so the stages are the same for + * every entry and only the id it carries varies. + */ + const trackAutomationSetupOpened = ({ + automationId, + }: { + automationId: string; + }) => { + track("automation_setup_opened", { automation_id: automationId }); + }; + + const trackAutomationSetupValidated = ({ + automationId, + }: { + automationId: string; + }) => { + track("automation_setup_validated", { automation_id: automationId }); + }; + + const trackAutomationSetupCreated = ({ + automationId, + setupMode, + }: { + automationId: string; + setupMode: string; + }) => { + track("automation_setup_created", { + automation_id: automationId, + setup_mode: setupMode, + }); + }; + + const trackAutomationSetupFailed = ({ + automationId, + setupMode, + }: { + automationId: string; + setupMode: string; + }) => { + track("automation_setup_failed", { + automation_id: automationId, + setup_mode: setupMode, + }); + }; + + const trackInitialQuerySubmitted = ({ + entryPoint, + queryCharacterLength, + replayJsonSize, + }: { + entryPoint: string; + queryCharacterLength: number; + replayJsonSize?: number; + }) => { + track("initial_query_submitted", { + entry_point: entryPoint, + query_character_length: queryCharacterLength, + replay_json_size: replayJsonSize, + }); + }; + + const trackUserMessageSent = ({ + sessionMessageCount, + currentMessageLength, + }: { + sessionMessageCount: number; + currentMessageLength: number; + }) => { + track("user_message_sent", { + session_message_count: sessionMessageCount, + current_message_length: currentMessageLength, + }); + }; + + const trackDownloadVsCodeButtonClicked = () => { + track("download_via_vscode_button_clicked"); + }; + + const trackSettingsSaved = ({ + llmModel, + llmApiKeySet, + searchApiKeySet, + remoteRuntimeResourceFactor, + }: { + llmModel: unknown; + llmApiKeySet: "SET" | "UNSET"; + searchApiKeySet: "SET" | "UNSET"; + remoteRuntimeResourceFactor?: unknown; + }) => { + track("settings_saved", { + LLM_MODEL: llmModel, + LLM_API_KEY_SET: llmApiKeySet, + SEARCH_API_KEY_SET: searchApiKeySet, + REMOTE_RUNTIME_RESOURCE_FACTOR: remoteRuntimeResourceFactor, + }); + }; + + const trackMcpConfigUpdated = ({ + sseServersCount, + stdioServersCount, + }: { + sseServersCount: number; + stdioServersCount: number; + }) => { + track("mcp_config_updated", { + has_mcp_config: true, + sse_servers_count: sseServersCount, + stdio_servers_count: stdioServersCount, + }); + }; + + const trackDownloadTrajectoryButtonClicked = () => { + track("download_trajectory_button_clicked"); + }; + + const trackConversationExported = (format: "markdown" | "html") => { + track("conversation_exported", { format }); + }; + + const trackAutomationCreatedButton = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("automation_created_button", { backend_kind: backendKind }); + }; + + const trackAutomationExecuted = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("automation_executed", { backend_kind: backendKind }); + }; + + const trackAutomationDeleted = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("automation_deleted", { backend_kind: backendKind }); + }; + + const trackAutomationDisableButton = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("automation_disable_button", { backend_kind: backendKind }); + }; + + const trackAutomationEdited = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("automation_edited", { backend_kind: backendKind }); + }; + + const trackAutomationExported = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("automation_exported", { backend_kind: backendKind }); + }; + + const trackAutomationActivityLogExported = ({ + backendKind, + format, + }: { + backendKind: BackendKind; + format: "json" | "csv"; + }) => { + track("automation_activity_log_exported", { + backend_kind: backendKind, + format, + }); + }; + + const trackAutomationImported = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("automation_imported", { backend_kind: backendKind }); + }; + + const trackGitSyncConfigUpdated = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("git_sync_config_updated", { backend_kind: backendKind }); + }; + + const trackGitSyncTriggered = ({ + backendKind, + }: { + backendKind: BackendKind; + }) => { + track("git_sync_triggered", { backend_kind: backendKind }); + }; + + const trackBackendAdded = ({ + backendKind, + connectionMethod, + hasApiKey, + source, + agentServerVersion, + backendVersion, + }: { + backendKind: BackendKind; + connectionMethod: BackendConnectionMethod; + hasApiKey: boolean; + source?: CloudConnectionSource; + agentServerVersion?: string | null; + backendVersion?: string | null; + }) => { + track("backend_added", { + ...getBackendTelemetryProperties({ + backendKind, + agentServerVersion, + backendVersion, + connectionMethod, + }), + has_api_key: hasApiKey, + source, + }); + }; + + const trackOnboardingStarted = () => { + track("onboarding_started"); + }; + + const trackOnboardingStepViewed = ({ + step, + stepIndex, + totalSteps, + agent, + }: { + step: string; + stepIndex: number; + totalSteps: number; + agent: string; + }) => { + track("onboarding_step_viewed", { + step, + step_index: stepIndex, + total_steps: totalSteps, + agent, + }); + }; + + const trackOnboardingCompleted = ({ agent }: { agent: string }) => { + track("onboarding_completed", { agent }); + }; + + const trackOnboardingSkipped = ({ + step, + stepIndex, + totalSteps, + agent, + }: { + step: string; + stepIndex: number; + totalSteps: number; + agent: string; + }) => { + track("onboarding_skipped", { + step, + step_index: stepIndex, + total_steps: totalSteps, + agent, + }); + }; + + const trackOnboardingLinkClicked = ({ + linkId, + destinationType, + surface, + checklistItem, + stepId, + isExternal, + }: { + linkId: OnboardingLinkId; + destinationType: OnboardingLinkDestinationType; + surface: OnboardingLinkSurface; + checklistItem?: Exclude; + stepId?: string; + isExternal: boolean; + }) => { + track("onboarding_link_clicked", { + link_id: linkId, + destination_type: destinationType, + surface, + checklist_item: checklistItem, + step_id: stepId, + is_external: isExternal, + }); + }; + + return { + trackLoginButtonClick, + trackConversationCreated, + trackPushButtonClick, + trackPullButtonClick, + trackCreatePrButtonClick, + trackUserSignupCompleted, + trackPrebuiltAutomationEnabled, + trackAutomationSetupOpened, + trackAutomationSetupValidated, + trackAutomationSetupCreated, + trackAutomationSetupFailed, + trackInitialQuerySubmitted, + trackUserMessageSent, + trackDownloadVsCodeButtonClicked, + trackSettingsSaved, + trackMcpConfigUpdated, + trackDownloadTrajectoryButtonClicked, + trackConversationExported, + trackAutomationCreatedButton, + trackAutomationExecuted, + trackAutomationDeleted, + trackAutomationDisableButton, + trackAutomationEdited, + trackAutomationExported, + trackAutomationActivityLogExported, + trackAutomationImported, + trackGitSyncConfigUpdated, + trackGitSyncTriggered, + trackBackendAdded, + trackOnboardingStarted, + trackOnboardingStepViewed, + trackOnboardingCompleted, + trackOnboardingSkipped, + trackOnboardingLinkClicked, + }; +}; diff --git a/src/hooks/use-user-providers.ts b/src/hooks/use-user-providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..c09130990bcdc771dfbe1e06a79255d7cd3432fa --- /dev/null +++ b/src/hooks/use-user-providers.ts @@ -0,0 +1,17 @@ +import React from "react"; +import { convertRawProvidersToList } from "#/utils/convert-raw-providers-to-list"; +import { useSettings } from "./query/use-settings"; + +export const useUserProviders = () => { + const { data: settings, isLoading: isLoadingSettings } = useSettings(); + + const providers = React.useMemo( + () => convertRawProvidersToList(settings?.provider_tokens_set), + [settings?.provider_tokens_set], + ); + + return { + providers, + isLoadingSettings, + }; +}; diff --git a/src/styles/agent-server-ui-style-scope.ts b/src/styles/agent-server-ui-style-scope.ts new file mode 100644 index 0000000000000000000000000000000000000000..469becbf9396a52112f4a69194cb45f69427e1ea --- /dev/null +++ b/src/styles/agent-server-ui-style-scope.ts @@ -0,0 +1,117 @@ +export const AGENT_SERVER_UI_SCOPE_ATTRIBUTE = "data-agent-server-ui"; +export const AGENT_SERVER_UI_SCOPE_SELECTOR = `[${AGENT_SERVER_UI_SCOPE_ATTRIBUTE}]`; +export const AGENT_SERVER_UI_DEFAULT_THEME = "dark" as const; + +export type AgentServerUITheme = "dark" | "light" | "default"; + +/** Brand tokens controlled by color themes — not inlined on AgentServerUIRoot. */ +export const AGENT_SERVER_UI_THEMEABLE_BRAND_VARIABLES = [ + "--oh-color-primary", + "--oh-accent", + "--oh-warning", +] as const; + +export type AgentServerUIThemeableBrandVariable = + (typeof AGENT_SERVER_UI_THEMEABLE_BRAND_VARIABLES)[number]; + +export const AGENT_SERVER_UI_DEFAULT_CSS_VARIABLES = { + // Brand/button colors (--oh-color-primary, --oh-accent, --oh-warning) live in + // tailwind.css and are overridden at runtime by applyColorTheme(); keep them + // out of inline defaults so theme tokens are not blocked by element.style. + "--oh-color-logo": "#cfb755", + "--oh-color-base": "var(--cool-grey-950)", + "--oh-color-base-secondary": "var(--cool-grey-925)", + "--oh-color-danger": "#e76a5e", + "--oh-color-success": "#a5e75e", + "--oh-color-basic": "var(--cool-grey-400)", + "--oh-color-tertiary": "var(--cool-grey-800)", + "--oh-color-tertiary-light": "var(--cool-grey-300)", + "--oh-color-content": "var(--cool-grey-100)", + "--oh-color-content-2": "var(--cool-grey-50)", + "--oh-background": "var(--cool-grey-950)", + "--oh-foreground": "var(--cool-grey-100)", + "--oh-surface": "var(--cool-grey-925)", + "--oh-surface-foreground": "var(--cool-grey-100)", + "--oh-surface-raised": "var(--cool-grey-900)", + "--oh-surface-deep": "var(--cool-grey-975)", + "--oh-overlay": "var(--cool-grey-925)", + "--oh-overlay-foreground": "var(--cool-grey-100)", + "--oh-muted": "var(--cool-grey-400)", + "--oh-text-secondary": "var(--cool-grey-300)", + "--oh-text-tertiary": "var(--cool-grey-200)", + "--oh-text-dim": "var(--cool-grey-500)", + "--oh-text-subtle": "var(--cool-grey-600)", + "--oh-interactive-hover": "var(--cool-grey-700)", + "--oh-interactive-hover-low": "var(--cool-grey-900)", + "--oh-interactive-active": "var(--cool-grey-800)", + "--oh-interactive-selected": "var(--cool-grey-600)", + "--oh-scrollbar": "color-mix(in srgb, var(--cool-grey-400) 30%, transparent)", + "--oh-scrollbar-hover": + "color-mix(in srgb, var(--cool-grey-400) 50%, transparent)", + "--oh-default": "var(--cool-grey-800)", + "--oh-default-foreground": "var(--cool-grey-100)", + "--oh-accent-foreground": "var(--cool-grey-950)", + "--oh-success": "#a5e75e", + "--oh-success-foreground": "var(--cool-grey-950)", + "--oh-warning-foreground": "var(--cool-grey-950)", + "--oh-danger": "#e76a5e", + "--oh-danger-foreground": "var(--cool-grey-50)", + "--oh-segment": "var(--cool-grey-925)", + "--oh-segment-foreground": "var(--cool-grey-100)", + "--oh-border-width": "1px", + "--oh-field-border-width": "1px", + "--oh-border": "var(--cool-grey-700)", + "--oh-border-input": "var(--cool-grey-600)", + "--oh-border-subtle": "var(--cool-grey-800)", + "--oh-separator": "rgba(113, 120, 136, 0.5)", + "--oh-focus": "#ffffff", + "--oh-status-success": "#1FBD53", + "--oh-status-error": "#FF684E", + "--oh-link": "var(--cool-grey-100)", + "--oh-radius": "8px", + "--oh-field-radius": "8px", + "--oh-surface-shadow": "none", + "--oh-overlay-shadow": "none", + "--oh-field-shadow": "none", + "--oh-bg-dark": "var(--cool-grey-950)", + "--oh-bg-light": "var(--cool-grey-900)", + "--oh-bg-input": "var(--cool-grey-800)", + "--oh-bg-workspace": "var(--cool-grey-925)", + "--oh-text-editor-base": "var(--cool-grey-400)", + "--oh-text-editor-active": "var(--cool-grey-300)", + "--oh-bg-editor-sidebar": "var(--cool-grey-925)", + "--oh-bg-editor-active": "var(--cool-grey-900)", + "--oh-border-editor-sidebar": "var(--cool-grey-800)", + "--oh-bg-neutral-muted": + "color-mix(in srgb, var(--cool-grey-300) 20%, transparent)", +} as const; + +export type AgentServerUICssVariableName = + | keyof typeof AGENT_SERVER_UI_DEFAULT_CSS_VARIABLES + | AgentServerUIThemeableBrandVariable; + +export type AgentServerUIStyleOverrides = Partial< + Record +>; + +const GLOBAL_SCOPE_SELECTORS = new Set([":root", "body", "html"]); + +export function transformAgentServerUISelector( + prefix: string, + selector: string, + prefixedSelector: string, +): string { + if (selector.includes(prefix)) { + return selector; + } + + if (selector.includes(":host")) { + return selector.replaceAll(":host", prefix); + } + + if (GLOBAL_SCOPE_SELECTORS.has(selector.trim())) { + return prefix; + } + + return prefixedSelector; +} diff --git a/src/utils/acp-auth-display.ts b/src/utils/acp-auth-display.ts new file mode 100644 index 0000000000000000000000000000000000000000..cce5f89ded3f8eac1b78c5911a1c818bd11222a9 --- /dev/null +++ b/src/utils/acp-auth-display.ts @@ -0,0 +1,46 @@ +import type { AcpAuthStatus } from "#/hooks/query/use-acp-auth-status"; + +/** + * Resolved display state for the ACP auth banner. + * + * - ``"signed-in"`` — the host-login probe confirmed a session (native backend). + * - ``"checking"`` — the first probe is still in flight. + * - ``"configured"`` — the probe can't confirm a login (e.g. a container with no + * interactive CLI, or a cloud backend where the probe doesn't run), but a + * credential for the provider exists in the active backend's secret store. + * - ``"none"`` — no confirmed login and no stored credential; show nothing. + */ +export type AcpAuthDisplay = "signed-in" | "checking" | "configured" | "none"; + +interface AcpAuthDisplayInput { + /** Result of the host-login probe (only meaningful on a native backend). */ + status: AcpAuthStatus; + /** True while the first probe for this provider is in flight. */ + isChecking: boolean; + /** + * Whether a credential for the provider exists in the active backend's secret + * store. Unlike {@link status}, this signal is available on Docker and cloud + * backends, so it can convey an accurate state where the probe goes silent. + */ + credentialsConfigured: boolean; +} + +/** + * Decide what the ACP auth banner should show, given the (host-only) login + * probe and the backend-truthful "a credential is stored" signal. + * + * Precedence preserves the existing banner behavior — a probe-confirmed login + * wins, then the in-flight spinner — and only adds ``"configured"`` in the gap + * that previously rendered nothing. A stored credential never reports as + * ``"signed-in"``: only the probe can confirm an actual host login. See #1244. + */ +export function resolveAcpAuthDisplay({ + status, + isChecking, + credentialsConfigured, +}: AcpAuthDisplayInput): AcpAuthDisplay { + if (status === "authenticated") return "signed-in"; + if (isChecking) return "checking"; + if (credentialsConfigured) return "configured"; + return "none"; +} diff --git a/src/utils/acp-command.ts b/src/utils/acp-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b4b8af3288818bbfbbe172e307de9e8eba4c2c9 --- /dev/null +++ b/src/utils/acp-command.ts @@ -0,0 +1,189 @@ +// ``shell-quote`` is a CJS module that does ``module.exports = { parse, quote }``; +// Vite's ESM interop can resolve a default/namespace import but not named +// imports against that shape (the dev server crashes with "does not provide +// an export named 'parse'"). Namespace import works on both the dev server +// and the Rollup-based prod build. Used only for the ``quote`` direction — +// see ``parseCommand`` below for why we don't use ``shell-quote.parse``. +import * as shellQuote from "shell-quote"; + +const { quote } = shellQuote; + +/** + * Parse a single-string command into argv tokens for ``acp_command``. + * + * Used by the Settings → Agent textarea — the user types one human-readable + * command (e.g. ``bash -c "echo hello world"``) and we convert it into the + * ``string[]`` shape that the agent-server's ``ACPAgent.acp_command`` + * expects. The agent-server passes that array straight to + * ``subprocess.create_subprocess_exec``; no shell is involved on the spawn + * side, so this parser only needs to handle argv-style word splitting + * with quote/escape support — *not* shell metasyntax. + * + * Why a custom tokenizer and **not** ``shell-quote.parse``: + * + * ``shell-quote.parse`` treats ``?``, ``*``, ``$VAR``, redirects, and + * comments as shell syntax and emits non-string AST nodes for them. + * Filtering to strings would silently drop entire argv tokens. The + * concrete data-corruption case is a URL with a query string — + * + * node acp.js --endpoint https://example.com/acp?tenant=abc + * + * ``shell-quote`` reads ``?tenant=abc`` as a glob pattern and returns + * ``["node","acp.js","--endpoint",{op:"glob",…}]``, so the saved + * ``acp_command`` becomes ``["node","acp.js","--endpoint"]`` — the URL + * vanishes. The agent-server then spawns a broken command and the user + * gets a confusing runtime error far from the configuration UI. + * + * The replacement tokenizer treats every non-whitespace, non-quote + * character as part of the current token: ``?``, ``*``, ``$``, ``|``, + * ``>``, ``#``, backticks all round-trip verbatim. Shell-only constructs + * (pipes, redirects, env-var expansion, command substitution) would + * land as literal argv entries — which is what the user typed and what + * ``subprocess.create_subprocess_exec`` will see. That's correct: a + * user who types ``foo | bar`` into the Settings → Agent textarea is + * configuring a literal command, not a shell pipeline; ``foo`` doesn't + * actually pipe into ``bar``, but neither does it silently disappear. + * + * Quoting rules supported: + * - whitespace separates tokens + * - single quotes: literal until the next ``'`` (no escapes inside, + * matching POSIX shell) + * - double quotes: literal until the next ``"`` (with ``\\"`` and + * ``\\\\`` honored as escapes; no $-expansion) + * - backslash outside quotes: escapes the next character (whitespace, + * quote, or anything else — turns it into a literal) + * - explicit empty quoted segments (``""`` / ``''``) produce an + * empty-string token, matching the round-trip rule in + * ``formatCommand`` + * + * Unterminated quotes are tolerated: the current token closes at EOF + * with whatever was accumulated. A throw here would crash the + * Settings → Agent page mid-render; the Save button is already gated + * on a non-empty argv so a recoverable miss can't be silently saved + * either way. + */ +export function parseCommand(value: string): string[] { + const tokens: string[] = []; + let i = 0; + let current = ""; + let inToken = false; + + while (i < value.length) { + const ch = value[i]; + + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + if (inToken) { + tokens.push(current); + current = ""; + inToken = false; + } + i += 1; + continue; + } + + if (ch === "'") { + // Single-quoted segment: literal until the next single quote. + // No escapes inside (POSIX shell semantics). + inToken = true; + i += 1; + while (i < value.length && value[i] !== "'") { + current += value[i]; + i += 1; + } + // Skip the closing quote if present. Unterminated → EOF closes. + if (i < value.length) i += 1; + continue; + } + + if (ch === '"') { + // Double-quoted segment: literal with backslash escapes for + // ``\\"`` and ``\\\\``. We intentionally do NOT expand $VAR + // (so a user typing ``"--key=$X"`` keeps it literal — the + // agent-server doesn't run a shell anyway). Other backslash + // sequences pass through verbatim (matches what most users + // expect when copying paths with backslashes from Windows + // examples; corner-case differences from POSIX aren't worth + // the complexity here). + inToken = true; + i += 1; + while (i < value.length && value[i] !== '"') { + if (value[i] === "\\" && i + 1 < value.length) { + const next = value[i + 1]; + if (next === '"' || next === "\\") { + current += next; + i += 2; + continue; + } + } + current += value[i]; + i += 1; + } + if (i < value.length) i += 1; + continue; + } + + if (ch === "\\" && i + 1 < value.length) { + // Unquoted backslash escapes the next character (whitespace, + // quote, glob char, anything). Useful for typing literal spaces + // in a path or escaping a literal quote. + inToken = true; + current += value[i + 1]; + i += 2; + continue; + } + + // Every other character — including ``?``, ``*``, ``$``, ``|``, + // ``>``, ``#``, ``&``, ``;``, ``(``, ``)`` — is a literal part of + // the current token. Shell-metasyntax filtering happens at the + // OS/shell boundary, which we don't cross. + inToken = true; + current += ch; + i += 1; + } + + if (inToken) { + tokens.push(current); + } + return tokens; +} + +// Tokens that need shell-quoting when rendering back to a string — +// whitespace, quotes, backslashes, redirects/pipes/globs, and the +// command-separators. ``@``, ``/``, ``-``, ``.``, ``+``, ``=`` and +// other punctuation that's common in package names and URLs are +// safe in argv-only contexts (the agent-server execs the array, no +// shell intermediary), so we leave them alone — otherwise +// ``npx -y @org/pkg`` would render as ``npx -y \@org/pkg`` and that's +// a hostile read-back in the textarea. +// +// Note: ``?``, ``*``, ``$``, ``|``, ``>``, ``#``, ``&``, ``;``, ``(``, +// ``)`` no longer carry shell meaning in {@link parseCommand} (which +// is purely an argv tokenizer), so a token containing them would +// round-trip fine without quoting. We still quote on output because +// users frequently switch between this textarea and an actual shell +// (copy-paste workflows), and quoting matches the conservative +// expectation "if it would need quoting in a shell, show it quoted." +const SHELL_UNSAFE = /[\s"'\\$`&|;<>(){}*?#!~[\]]/; + +/** + * Render a ``string[]`` argv back into a single string the textarea + * can display. Tokens that *would* need shell quoting (whitespace, + * quotes, redirects, …) go through ``shell-quote.quote`` for correct + * escaping; tokens that are already shell-safe (the overwhelming + * majority of package names and CLI flags) round-trip verbatim. The + * output remains a valid input to {@link parseCommand}. + */ +export function formatCommand(command: readonly string[]): string { + return command + .map((tok) => + // Quote any token that: + // * contains a shell-significant character (whitespace, quotes, + // redirects, …), so it doesn't get re-split on parse, OR + // * is the empty string — without explicit quoting, + // ``["bash", "-c", ""]`` would render as ``"bash -c "`` and + // round-trip back to ``["bash", "-c"]``, silently dropping + // the (rare but valid) empty argument. + SHELL_UNSAFE.test(tok) || tok === "" ? quote([tok]) : tok, + ) + .join(" "); +} diff --git a/src/utils/acp-error-codes.ts b/src/utils/acp-error-codes.ts new file mode 100644 index 0000000000000000000000000000000000000000..4af44228f4024e4315084a80f9c8d8c98baaccbd --- /dev/null +++ b/src/utils/acp-error-codes.ts @@ -0,0 +1,29 @@ +import { I18nKey } from "#/i18n/declaration"; + +/** + * Structured `code` values the SDK's ACPAgent puts on a ConversationErrorEvent + * (see software-agent-sdk `acp_agent.py`). The banner uses them to show a + * code-specific header and, for credential failures, a recovery action. + */ +export const ACP_AUTH_REQUIRED_CODE = "ACPAuthRequired"; + +const ACP_ERROR_HEADER_KEYS: Record = { + ACPAuthRequired: I18nKey.ERROR$ACP_AUTH_REQUIRED_TITLE, + // Spawn/init/prompt/usage-policy failures share the generic "Agent error" + // header; their detail already carries the specific cause. + ACPSpawnError: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE, + ACPInitError: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE, + ACPPromptError: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE, + UsagePolicyRefusal: I18nKey.CHAT_INTERFACE$AGENT_ERROR_MESSAGE, +}; + +/** Localized header key for an error code, or null when the code is unknown. */ +export function getAcpErrorHeaderKey(code?: string | null): I18nKey | null { + if (!code) return null; + return ACP_ERROR_HEADER_KEYS[code] ?? null; +} + +/** Whether the error is a credential failure that warrants a re-auth action. */ +export function isAcpAuthErrorCode(code?: string | null): boolean { + return code === ACP_AUTH_REQUIRED_CODE; +} diff --git a/src/utils/agent-state-emoji.ts b/src/utils/agent-state-emoji.ts new file mode 100644 index 0000000000000000000000000000000000000000..fffb0877a425c4bb9621a5dfa4568edac9ea4bed --- /dev/null +++ b/src/utils/agent-state-emoji.ts @@ -0,0 +1,32 @@ +import { ExecutionStatus } from "#/types/agent-server/core/base/common"; + +/** + * Maps a conversation's execution status to a single emoji that visually + * conveys the agent's state in the browser tab title. + * + * ✅ green check — agent finished or is otherwise waiting on the user + * 🟢 green circle — agent is actively running + * ⚪ gray circle — agent is paused/stopped + * 🔴 red circle — agent is in an error state + * + * Returns null when the status is unknown so callers can omit the prefix. + */ +export function getAgentStateEmoji( + status: ExecutionStatus | null | undefined, +): string | null { + switch (status) { + case ExecutionStatus.RUNNING: + return "🟢"; + case ExecutionStatus.FINISHED: + case ExecutionStatus.IDLE: + case ExecutionStatus.WAITING_FOR_CONFIRMATION: + return "✅"; + case ExecutionStatus.PAUSED: + return "⚪"; + case ExecutionStatus.ERROR: + case ExecutionStatus.STUCK: + return "🔴"; + default: + return null; + } +} diff --git a/src/utils/api-error-message.ts b/src/utils/api-error-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..b927e51cca20b28176c5c72797c58dde00c23895 --- /dev/null +++ b/src/utils/api-error-message.ts @@ -0,0 +1,38 @@ +import axios from "axios"; + +/** + * Extract the parsed response body from a failed API call. + * + * Handles both transports the app uses: local agent-server calls that + * throw an `AxiosError` (body under `error.response.data`) and cloud + * calls through the shared TypeScript client that throw an `HttpError` + * (parsed body directly under `error.response`). + */ +export function getApiErrorBody(error: unknown): unknown { + if (axios.isAxiosError(error)) return error.response?.data; + if (error instanceof Error && "response" in error) { + return (error as { response?: unknown }).response; + } + return undefined; +} + +/** + * Extract a human-readable message from a failed API call. Prefers the + * server-provided `message`/`detail` fields, then the `Error` message, + * then `fallback`. + */ +export function getApiErrorMessage(error: unknown, fallback: string): string { + const body = getApiErrorBody(error); + + if (body && typeof body === "object") { + const { message, detail } = body as { + message?: unknown; + detail?: unknown; + }; + if (typeof message === "string" && message) return message; + if (typeof detail === "string" && detail) return detail; + } + + if (error instanceof Error && error.message) return error.message; + return fallback; +} diff --git a/src/utils/automation-activity-log-export.ts b/src/utils/automation-activity-log-export.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d095106f0fd1c1090ce5f87a3ec4136aa4351de --- /dev/null +++ b/src/utils/automation-activity-log-export.ts @@ -0,0 +1,186 @@ +import type { + ActivityLogExportFormat, + Automation, + AutomationRun, + AutomationRunExportRow, +} from "#/types/automation"; +import { downloadBlob } from "#/utils/utils"; +import AutomationService from "#/api/automation-service/automation-service.api"; + +const EXPORT_PAGE_SIZE = 100; + +const CSV_COLUMNS = [ + "run_id", + "automation_id", + "automation_name", + "trigger", + "start_time", + "end_time", + "duration_seconds", + "status", + "conversation_id", + "conversation_url", + "error", + // Appended rather than grouped with the other run metrics so existing + // consumers that read the CSV by column position keep working. + "cost", + "phase", +] as const; + +export function getActivityLogExportFilename( + automation: Pick, + format: ActivityLogExportFormat, +): string { + const slug = automation.name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `${slug || automation.id}.activity-log.${format}`; +} + +function csvEscape(value: string): string { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function serializeActivityLogRowsCsv( + rows: AutomationRunExportRow[], +): string { + const lines: string[] = [CSV_COLUMNS.join(",")]; + for (const row of rows) { + const values = CSV_COLUMNS.map((key) => { + const raw = row[key]; + if (raw == null) return ""; + if (key === "trigger") { + return csvEscape(JSON.stringify(raw)); + } + return csvEscape(String(raw)); + }); + lines.push(values.join(",")); + } + return `${lines.join("\n")}\n`; +} + +export function buildConversationUrl( + conversationId: string | null, + conversationBaseUrl?: string, +): string | null { + if (!conversationId) return null; + const path = `/conversations/${conversationId}`; + if (!conversationBaseUrl) return path; + return `${conversationBaseUrl.replace(/\/$/, "")}${path}`; +} + +export function mapAutomationRunToExportRow( + run: AutomationRun, + automation: Pick, + conversationBaseUrl?: string, +): AutomationRunExportRow { + const start = run.started_at || null; + const end = run.completed_at; + let duration_seconds: number | null = null; + if (start && end) { + duration_seconds = + (new Date(end).getTime() - new Date(start).getTime()) / 1000; + } + + return { + run_id: run.id, + automation_id: automation.id, + automation_name: automation.name, + trigger: automation.trigger, + start_time: start, + end_time: end, + duration_seconds, + status: run.status, + conversation_id: run.conversation_id, + conversation_url: buildConversationUrl( + run.conversation_id, + conversationBaseUrl, + ), + error: run.error_detail, + // Exports carry the raw number and leave formatting to the consumer; the + // Activity Log row is the only place that renders it as USD. + cost: run.cost ?? null, + // Deliberately not filtered through `shouldShowRunPhase`: that predicate + // decides what is worth a user's attention on screen, and hides the phase + // of a run whose status already says everything. An export is a record, + // not a screen — a completed run's last phase is real data, and dropping + // it here would make the phase of a run that finished indistinguishable + // from one that never reported a phase at all. + // + // Falling back to the label: without it, a phase reported with only a + // label would export as an empty cell, indistinguishable from no phase. + // Trimmed rather than nullish-coalesced: the service stores a + // whitespace-only field as sent — it rejects only a phase blank on + // *both* — so `??` would let a blank code suppress a real label, which + // is exactly the empty cell this fallback exists to prevent. + phase: run.phase_code?.trim() || run.phase_label?.trim() || null, + }; +} + +/** + * Page ``GET /v1/{id}/runs`` until complete, then project export rows locally. + */ +export async function fetchAllActivityLogExportRows( + automation: Pick, + conversationBaseUrl?: string, +): Promise { + const rows: AutomationRunExportRow[] = []; + let offset = 0; + let total = Number.POSITIVE_INFINITY; + + while (offset < total) { + const page = await AutomationService.listAutomationRuns(automation.id, { + limit: EXPORT_PAGE_SIZE, + offset, + }); + + for (const run of page.runs) { + rows.push( + mapAutomationRunToExportRow(run, automation, conversationBaseUrl), + ); + } + total = page.total; + offset += page.runs.length; + if (page.runs.length === 0) break; + } + + return rows; +} + +/** + * Page the runs list endpoint and download one CSV or JSON file. + */ +export async function downloadActivityLogExport(options: { + automation: Pick; + format: ActivityLogExportFormat; + conversationBaseUrl?: string; +}): Promise { + const { automation, format, conversationBaseUrl } = options; + const rows = await fetchAllActivityLogExportRows( + automation, + conversationBaseUrl, + ); + + if (format === "json") { + downloadBlob( + new Blob( + [`${JSON.stringify({ runs: rows, total: rows.length }, null, 2)}\n`], + { type: "application/json" }, + ), + getActivityLogExportFilename(automation, "json"), + ); + return; + } + + downloadBlob( + new Blob([serializeActivityLogRowsCsv(rows)], { + type: "text/csv;charset=utf-8", + }), + getActivityLogExportFilename(automation, "csv"), + ); +} diff --git a/src/utils/automation-catalog.ts b/src/utils/automation-catalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa7ffbeebe9ca5345641ff1960493b0521ea5c06 --- /dev/null +++ b/src/utils/automation-catalog.ts @@ -0,0 +1,185 @@ +import { + AUTOMATION_CATALOG, + type RecommendedAutomation, +} from "@openhands/extensions/automations"; +import { + SKILLS_CATALOG, + type SkillCatalogEntry, +} from "@openhands/extensions/skills"; +import type { LucideIcon } from "lucide-react"; +import { MANIFEST_ICON_BY_SLUG } from "#/components/features/manifest/manifest-icons"; +import { interpolateValues } from "#/manifests/manifest-template"; +import type { Automation } from "#/types/automation"; + +/** + * Reading a published automation catalog entry. + * + * The catalog states each thing once and leaves the rest to be derived, so an + * entry no longer carries its own launch command or a flat list of required + * integration ids. This module is the single place that performs those + * derivations, so the components consuming the catalog stay unaware of how it + * is shaped. + */ + +const SKILL_BY_NAME = new Map( + SKILLS_CATALOG.map((skill) => [skill.name, skill]), +); + +/** + * The glyph a card shows in place of its integration logos, or null when it + * has none to show and the logos speak for it. + * + * An entry naming the service it talks to is recognised by that service's + * logo; one whose identity is the work itself declares a slug instead. The + * lookup is a guard as much as a mapping — the catalog is published elsewhere, + * so a slug this host has no artwork for falls back rather than rendering + * nothing. + */ +export function getAutomationIcon( + automation: RecommendedAutomation, +): LucideIcon | null { + const slug = automation.icon; + if (!slug) return null; + return ( + (MANIFEST_ICON_BY_SLUG as Record)[slug] ?? + null + ); +} + +/** Every integration the entry declares, in declaration order. */ +export function getIntegrationIds(automation: RecommendedAutomation): string[] { + return Object.keys(automation.requires.integrations); +} + +/** + * The integrations that gate this automation. One marked `required: false` can + * be connected later, during setup, so it is still worth showing on the card + * but must never stand between the user and a launch. + */ +export function getRequiredIntegrationIds( + automation: RecommendedAutomation, +): string[] { + return Object.entries(automation.requires.integrations) + .filter(([, requirement]) => requirement.required !== false) + .map(([id]) => id); +} + +/** + * The command that invokes this automation's skill, or null when that skill is + * invoked by description instead. + * + * The command is declared once, in the owning skill's own `triggers`; the + * catalog names that skill only where it differs from the entry id. + */ +export function findAutomationCommand( + automation: Pick, +): string | null { + const skill = SKILL_BY_NAME.get(automation.skill ?? automation.id); + return skill?.triggers[0] ?? null; +} + +/** + * What launching an automation card hands to the agent. + * + * The resolved command is passed through as-is: API routing (host, auth) is + * discovered by the agent at runtime from `` in the system + * prompt, and the skills themselves carry the instructions for reading that + * block. An automation whose skill declares no command has the request spelled + * out instead. Both are instructions to the agent rather than user-facing UI + * copy, so they are intentionally in English and not localized. + */ +export function getAutomationLaunchPrompt( + automation: RecommendedAutomation, +): string { + return ( + findAutomationCommand(automation) ?? + `Set up the ${automation.name} automation` + ); +} + +const CATALOG_ENTRY_BY_ID = new Map( + AUTOMATION_CATALOG.map((entry) => [entry.id, entry]), +); + +/** Rejects markup the same way the catalog schema's copy rule does. */ +const MARKUP_PATTERN = /<[A-Za-z/!]/; + +/** Every `{{` an impact phrase opens must be the count placeholder. */ +const FOREIGN_PLACEHOLDER_PATTERN = /\{\{(?!count\}\})/; + +const COUNT_PLACEHOLDER = "{{count}}"; + +interface AutomationImpactCopy { + one: string; + other: string; +} + +function isImpactPhrase(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + !MARKUP_PATTERN.test(value) && + !FOREIGN_PLACEHOLDER_PATTERN.test(value) + ); +} + +/** + * The entry's `impact` value statement, or null when it declares none this + * host can honor. The catalog is published elsewhere, so the field is read + * off the raw entry and validated here rather than trusted from the package + * types — which also keeps this module compiling against a pinned package + * that predates the field. A basis other than `completed-runs` is a counter + * this host does not know how to compute, so it renders nothing rather than + * a number the phrase was not written for. + */ +function readImpactCopy( + entry: RecommendedAutomation, +): AutomationImpactCopy | null { + const { impact } = entry as unknown as Record; + if (typeof impact !== "object" || impact === null) return null; + const { basis, one, other } = impact as Record; + if (basis !== "completed-runs") return null; + if (!isImpactPhrase(one) || !isImpactPhrase(other)) return null; + if (!other.includes(COUNT_PLACEHOLDER)) return null; + return { one, other }; +} + +/** + * The catalog entry an installed automation was created from, via the + * `template` provenance its setup stored — the only join back to the catalog. + * Null for automations without provenance: imports, agent-built ones, and + * entries whose setup publishes no version. + */ +function getAutomationTemplateEntry( + automation: Automation, +): RecommendedAutomation | null { + const metadata = automation.preset_metadata; + if (typeof metadata !== "object" || metadata === null) return null; + const { template } = metadata as Record; + if (typeof template !== "object" || template === null) return null; + const { id } = template as Record; + if (typeof id !== "string" || id.length === 0) return null; + return CATALOG_ENTRY_BY_ID.get(id) ?? null; +} + +/** + * The value statement an automation card shows for its completed runs, or + * null when there is nothing defensible to say: no template provenance, no + * honored `impact` declaration, an unknown count (older service with more + * history than the sample), or a count of zero — absence, never a zero-value + * claim. Provenance persists across prompt edits, so a reworked automation + * keeps its template's phrase; the phrases are run-shaped, which keeps them + * true for as long as the automation runs at all. + */ +export function resolveAutomationImpactStatement( + automation: Automation, + completedTotal: number | null, +): string | null { + if (completedTotal === null || completedTotal < 1) return null; + const entry = getAutomationTemplateEntry(automation); + if (!entry) return null; + const copy = readImpactCopy(entry); + if (!copy) return null; + const phrase = completedTotal === 1 ? copy.one : copy.other; + return interpolateValues(phrase, { count: completedTotal.toLocaleString() }); +} diff --git a/src/utils/automation-disabled-reason.ts b/src/utils/automation-disabled-reason.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9174c51ea593bd36745bf7cba3269bbc3840b23 --- /dev/null +++ b/src/utils/automation-disabled-reason.ts @@ -0,0 +1,65 @@ +import { I18nKey } from "#/i18n/declaration"; +import type { Automation } from "#/types/automation"; + +/** Detail `source` values written by the automation service on disable. */ +const MANUAL_DETAIL_SOURCES = new Set(["user"]); +/** Top-level `disabled_reason` values written for user-initiated disables. */ +const MANUAL_REASON_VALUES = new Set(["manual", "manual_delete"]); + +/** + * Whether an automation is inactive *because* of a recorded disablement + * reason (vs. simply toggled off by an older client path, or an automation + * service that predates disablement tracking). + * + * The automation service overwrites `disabled_reason`/`disabled_detail`/ + * `disabled_at` on every disable and clears them on every re-enable, so when + * an automation is `enabled === false` and carries a `disabled_reason`, that + * reason is the latest reason it went inactive. + */ +export function hasDisablementReason( + automation: Pick, +): boolean { + return !automation.enabled && Boolean(automation.disabled_reason); +} + +/** + * Whether the recorded disablement was user-initiated (manual toggle / delete) + * rather than an automatic pause for failing runs. + */ +export function isManualDisable( + automation: Pick, +): boolean { + const reason = automation.disabled_reason; + if (reason && MANUAL_REASON_VALUES.has(reason)) return true; + const source = automation.disabled_detail?.source; + if (source && MANUAL_DETAIL_SOURCES.has(source)) return true; + return false; +} + +export interface DisablementReasonDisplay { + /** + * Full reason text to render. For automatic disables this is the backend's + * human-readable `disabled_reason` (already phrased for users, e.g. + * "Paused automatically: auth — ..."). For manual disables the backend + * stores the opaque "manual" string, so we substitute a localized label. + */ + text: string; +} + +/** + * Resolve the disablement reason into display text. Returns `null` when the + * automation is not inactive-for-a-recorded-reason. + */ +export function getDisablementReasonDisplay( + automation: Pick< + Automation, + "enabled" | "disabled_reason" | "disabled_detail" + >, + t: (key: I18nKey) => string, +): DisablementReasonDisplay | null { + if (!hasDisablementReason(automation)) return null; + if (isManualDisable(automation)) { + return { text: t(I18nKey.AUTOMATIONS$DETAIL$DISABLED_MANUAL) }; + } + return { text: automation.disabled_reason as string }; +} diff --git a/src/utils/automation-export.test.ts b/src/utils/automation-export.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6e06ca0ac08997395dd5dbbe1bb8be71a3b285c --- /dev/null +++ b/src/utils/automation-export.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import type { Automation } from "#/types/automation"; +import { + AutomationFileValidationError, + parseAutomationFile, + serializeAutomation, +} from "./automation-export"; + +const cronAutomation: Automation = { + id: "automation-1", + name: "Daily review", + trigger: { + type: "cron", + schedule: "0 9 * * *", + schedule_human: "Daily at 09:00", + }, + enabled: true, + repository: "openhands/agent-canvas", + branch: "main", + model: "fast", + prompt: "Review open pull requests.", + plugins: ["github:openhands/extensions"], + notification: "Post a summary", + timezone: "America/Los_Angeles", + created_at: "2026-07-01T00:00:00Z", + updated_at: "2026-07-02T00:00:00Z", + last_triggered_at: "2026-07-03T00:00:00Z", +}; + +describe("automation export files", () => { + it("round-trips a cron automation without server-assigned fields", () => { + const exported = serializeAutomation(cronAutomation); + + expect(exported).toEqual({ + version: 1, + kind: "automation", + spec: { + name: cronAutomation.name, + trigger: cronAutomation.trigger, + enabled: true, + repository: cronAutomation.repository, + model: cronAutomation.model, + prompt: cronAutomation.prompt, + branch: cronAutomation.branch, + plugins: cronAutomation.plugins, + notification: cronAutomation.notification, + timezone: cronAutomation.timezone, + }, + }); + expect(parseAutomationFile(exported)).toEqual(exported.spec); + expect(exported.spec).not.toHaveProperty("id"); + expect(exported.spec).not.toHaveProperty("created_at"); + expect(exported.spec).not.toHaveProperty("updated_at"); + expect(exported.spec).not.toHaveProperty("last_triggered_at"); + }); + + it("round-trips event trigger fields", () => { + const exported = serializeAutomation({ + ...cronAutomation, + trigger: { + type: "event", + source: "github", + on: ["pull_request.opened", "pull_request.synchronize"], + filter: "repository.full_name == 'openhands/agent-canvas'", + }, + timezone: undefined, + }); + + expect(parseAutomationFile(exported).trigger).toEqual( + exported.spec.trigger, + ); + }); + + it("preserves a positive timeout through an export/import round-trip", () => { + const withTimeout: Automation = { ...cronAutomation, timeout: 900 }; + + const exported = serializeAutomation(withTimeout); + + expect(parseAutomationFile(exported).timeout).toBe(900); + }); + + it("reports every malformed field with its path", () => { + const malformed = { + version: 2, + kind: "workflow", + spec: { + name: "", + prompt: null, + enabled: "yes", + trigger: { type: "event", source: 42, on: [] }, + plugins: ["github:openhands/extensions", 42], + model: false, + timeout: -5, + }, + }; + + expect(() => parseAutomationFile(malformed)).toThrow( + AutomationFileValidationError, + ); + + try { + parseAutomationFile(malformed); + } catch (error) { + expect(error).toBeInstanceOf(AutomationFileValidationError); + expect((error as AutomationFileValidationError).issues).toEqual( + expect.arrayContaining([ + "version: expected 1", + 'kind: expected "automation"', + "spec.name: expected a non-empty string", + "spec.prompt: expected a non-empty string", + "spec.enabled: expected a boolean", + "spec.trigger.source: expected a string", + "spec.trigger.source: required for an event trigger", + "spec.trigger.on: expected a string or a non-empty array of strings", + "spec.trigger.on: required for an event trigger", + "spec.plugins: expected an array of non-empty strings", + "spec.model: expected a non-empty string or null", + "spec.timeout: expected a positive integer", + ]), + ); + } + }); + + it("rejects non-object files and missing specs", () => { + expect(() => parseAutomationFile(null)).toThrow( + "file: expected a JSON object", + ); + expect(() => + parseAutomationFile({ version: 1, kind: "automation" }), + ).toThrow("spec: expected an object"); + }); +}); diff --git a/src/utils/automation-schedule.ts b/src/utils/automation-schedule.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e0d12e1fe986c409fbba268ea8d62f944bb7255 --- /dev/null +++ b/src/utils/automation-schedule.ts @@ -0,0 +1,262 @@ +import { I18nKey } from "#/i18n/declaration"; + +export type SchedulePresetKind = "daily" | "weekdays" | "weekly"; + +export interface PresetSchedule { + kind: SchedulePresetKind; + hour: number; + minute: number; + weekday?: number; +} + +export interface CustomSchedule { + kind: "custom"; + raw: string; + hour?: number; + minute?: number; +} + +export type ParsedSchedule = PresetSchedule | CustomSchedule; + +const SINGLE_INT = /^(\d+)$/; + +function parseSingleInt( + field: string, + min: number, + max: number, +): number | null { + const match = field.match(SINGLE_INT); + if (!match) return null; + const value = Number(match[1]); + if (Number.isNaN(value) || value < min || value > max) return null; + return value; +} + +export function parseCronSchedule( + cron: string | undefined | null, +): ParsedSchedule { + const raw = (cron ?? "").trim(); + if (!raw) return { kind: "custom", raw: "" }; + + const fields = raw.split(/\s+/); + if (fields.length !== 5) return { kind: "custom", raw }; + + const [minuteField, hourField, domField, monthField, dowField] = fields; + + const minute = parseSingleInt(minuteField, 0, 59); + const hour = parseSingleInt(hourField, 0, 23); + + if (minute === null || hour === null) { + return { kind: "custom", raw }; + } + if (domField !== "*" || monthField !== "*") { + return { kind: "custom", raw, hour, minute }; + } + + if (dowField === "*" || dowField === "0-6") { + return { kind: "daily", hour, minute }; + } + if (dowField === "1-5") { + return { kind: "weekdays", hour, minute }; + } + const weekday = parseSingleInt(dowField, 0, 6); + if (weekday !== null) { + return { kind: "weekly", hour, minute, weekday }; + } + return { kind: "custom", raw, hour, minute }; +} + +export function buildCronSchedule(input: PresetSchedule): string { + const { kind, hour, minute, weekday } = input; + switch (kind) { + case "daily": + return `${minute} ${hour} * * *`; + case "weekdays": + return `${minute} ${hour} * * 1-5`; + case "weekly": + return `${minute} ${hour} * * ${weekday ?? 1}`; + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +export function formatTimeOfDay(hour: number, minute: number): string { + const hh = String(hour).padStart(2, "0"); + const mm = String(minute).padStart(2, "0"); + return `${hh}:${mm}`; +} + +export function parseTimeOfDay( + value: string, +): { hour: number; minute: number } | null { + const match = value.match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + const hour = Number(match[1]); + const minute = Number(match[2]); + if ( + Number.isNaN(hour) || + Number.isNaN(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return null; + } + return { hour, minute }; +} + +export function formatEventOn(on: string | string[] | undefined): string { + if (!on) return "—"; + if (Array.isArray(on)) return on.join(", "); + return on; +} + +/** Example expression shown when the cron field is empty. */ +export const CRON_EXPRESSION_EXAMPLE = "*/10 * * * *"; + +// The service validates with croniter, so this must accept everything croniter +// does: five fields, optionally plus seconds and then year, or an alias. +const CRON_ALIASES = new Set([ + "@yearly", + "@annually", + "@monthly", + "@weekly", + "@daily", + "@midnight", + "@hourly", +]); + +const CRON_MIN_FIELDS = 5; +const CRON_MAX_FIELDS = 7; + +// Seconds and year are appended after these, so the positions always hold. +const CRON_FIELD_BOUNDS: readonly (readonly [number, number])[] = [ + [0, 59], // minute + [0, 23], // hour + [1, 31], // day of month + [1, 12], // month + [0, 7], // day of week — croniter spells Sunday 0 and 7 +]; + +const DAY_OF_MONTH_INDEX = 2; +const MONTH_INDEX = 3; + +// Indexed from 1. February is 29: the 29th does fire, in leap years. +const MAX_DAYS_IN_MONTH = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +const MONTH_NAMES = "JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC"; +const DAY_NAMES = "SUN|MON|TUE|WED|THU|FRI|SAT"; + +// croniter does not bound the step: `*/90` is accepted. +const NUMERIC_TERM = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/; + +/** Vocabulary beyond plain numbers, matched but not expanded. */ +const EXTRA_TERM_PATTERNS: readonly (RegExp | null)[] = [ + null, // minute + null, // hour + /^(?:\?|L|\d+W)$/i, // `?`, `L`, `15W` + new RegExp(`^(?:${MONTH_NAMES})(?:-(?:${MONTH_NAMES}))?(?:/\\d+)?$`, "i"), + // `SUN`, `MON-FRI#2`, `5#3`, `?` — but no `L`, which croniter rejects here. + new RegExp( + `^(?:(?:${DAY_NAMES}|\\d+)(?:-(?:${DAY_NAMES}|\\d+))?(?:/\\d+)?(?:#\\d+)?|\\?)$`, + "i", + ), +]; + +type CronFieldParse = + | { kind: "invalid" } + /** Well-formed, but not expanded, so it constrains nothing. */ + | { kind: "unmodelled" } + | { kind: "values"; values: number[] }; + +function parseCronField( + field: string, + [min, max]: readonly [number, number], + extraPattern: RegExp | null, +): CronFieldParse { + const values = new Set(); + + for (const term of field.split(",")) { + const match = term.match(NUMERIC_TERM); + if (!match) { + if (extraPattern?.test(term)) return { kind: "unmodelled" }; + return { kind: "invalid" }; + } + + const [, rangePart, stepPart] = match; + const step = stepPart === undefined ? 1 : Number(stepPart); + if (step < 1) return { kind: "invalid" }; + + if (rangePart === "*") { + for (let value = min; value <= max; value += step) values.add(value); + continue; + } + + const [startPart, endPart] = rangePart.split("-"); + const start = parseSingleInt(startPart, min, max); + if (start === null) return { kind: "invalid" }; + if (endPart === undefined) { + values.add(start); + continue; + } + const end = parseSingleInt(endPart, min, max); + if (end === null) return { kind: "invalid" }; + // croniter wraps a reversed range like `5-1`; that wrap is not modelled. + if (end < start) return { kind: "unmodelled" }; + for (let value = start; value <= end; value += step) values.add(value); + } + + return { kind: "values", values: [...values] }; +} + +export type CronScheduleValidation = + | { schedule: string } + | { errorKey: I18nKey }; + +/** + * Validate a raw cron expression from the edit form — `parseCronSchedule` only + * classifies against the presets, reporting arbitrary text as `custom`. + * + * One-sided by design: rejects only what croniter certainly rejects, and defers + * on anything it does not model. + */ +export function validateCronSchedule(raw: string): CronScheduleValidation { + const schedule = raw.trim(); + if (!schedule) return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID }; + + if (schedule.startsWith("@")) { + return CRON_ALIASES.has(schedule.toLowerCase()) + ? { schedule } + : { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID }; + } + + const fields = schedule.split(/\s+/); + if (fields.length < CRON_MIN_FIELDS || fields.length > CRON_MAX_FIELDS) { + return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID }; + } + + const parsed = CRON_FIELD_BOUNDS.map((bounds, index) => + parseCronField(fields[index], bounds, EXTRA_TERM_PATTERNS[index]), + ); + if (parsed.some((field) => field.kind === "invalid")) { + return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_INVALID }; + } + + // croniter parses `0 0 31 2 *` and then finds no fire time for it. + const days = parsed[DAY_OF_MONTH_INDEX]; + const months = parsed[MONTH_INDEX]; + if ( + days.kind === "values" && + months.kind === "values" && + !months.values.some((month) => + days.values.some((day) => day <= MAX_DAYS_IN_MONTH[month]), + ) + ) { + return { errorKey: I18nKey.AUTOMATIONS$ERROR_CRON_UNREACHABLE }; + } + + return { schedule }; +} diff --git a/src/utils/automation-stack-section.ts b/src/utils/automation-stack-section.ts new file mode 100644 index 0000000000000000000000000000000000000000..7daa6f4ac8abbe272c53b7531ff671fdea132e04 --- /dev/null +++ b/src/utils/automation-stack-section.ts @@ -0,0 +1,2 @@ +/** Shared bottom inset for stacked home sections (recommended rail, pinned). */ +export const AUTOMATION_STACK_SECTION_BOTTOM_CLASS = "pb-12"; diff --git a/src/utils/automation-timeout.ts b/src/utils/automation-timeout.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fd5f7548d06684adcb211df5c0539b2a8773b85 --- /dev/null +++ b/src/utils/automation-timeout.ts @@ -0,0 +1,37 @@ +import { I18nKey } from "#/i18n/declaration"; + +/** + * Per-automation run timeout defaults. The deployment reports the maximum it + * enforces through capability discovery. + */ +export const AUTOMATION_TIMEOUT_DEFAULT_SECONDS = 600; // 10 minutes + +export type AutomationTimeoutValidation = + | { value: number | null } + | { errorKey: I18nKey }; + +/** + * Validate a raw timeout string from the edit form. A blank string means "use + * the server default" (resolved as `null`). Otherwise the value must be a + * positive integer. When capability discovery supplies a ceiling, values above + * it are rejected before the request reaches the service. + */ +export function validateAutomationTimeout( + raw: string, + maxSeconds?: number, +): AutomationTimeoutValidation { + const trimmed = raw.trim(); + if (!trimmed) return { value: null }; + + const seconds = Number(trimmed); + if (!Number.isInteger(seconds)) { + return { errorKey: I18nKey.AUTOMATIONS$ERROR_TIMEOUT_INVALID_NUMBER }; + } + if (seconds <= 0) { + return { errorKey: I18nKey.AUTOMATIONS$ERROR_TIMEOUT_POSITIVE }; + } + if (maxSeconds !== undefined && seconds > maxSeconds) { + return { errorKey: I18nKey.AUTOMATIONS$ERROR_TIMEOUT_MAX_EXCEEDED }; + } + return { value: seconds }; +} diff --git a/src/utils/cache-utils.ts b/src/utils/cache-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..d37a892f351af062dfcca1a1e0f0d554671c350b --- /dev/null +++ b/src/utils/cache-utils.ts @@ -0,0 +1,61 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { ActionEvent } from "#/types/agent-server/core/events/action-event"; +import { useModelStore } from "#/stores/model-store"; +import { stripWorkspacePrefix } from "./path-utils"; + +/** + * Cache invalidation utilities for TanStack Query + */ + +/** + * Handle cache invalidation for ActionEvent + * Invalidates relevant query caches based on the action type + * + * @param event - The ActionEvent to process + * @param conversationId - The conversation ID for cache keys + * @param queryClient - The TanStack Query client instance + */ +export const handleActionEventCacheInvalidation = ( + event: ActionEvent, + conversationId: string, + queryClient: QueryClient, +) => { + const { action } = event; + + // Invalidate file_changes cache for file-related actions + if ( + action.kind === "StrReplaceEditorAction" || + action.kind === "FileEditorAction" || + action.kind === "ExecuteBashAction" + ) { + queryClient.invalidateQueries( + { + queryKey: ["file_changes", conversationId], + }, + { cancelRefetch: false }, + ); + } + + // Invalidate specific file diff cache for file modifications + if ( + (action.kind === "StrReplaceEditorAction" || + action.kind === "FileEditorAction") && + action.path + ) { + const strippedPath = stripWorkspacePrefix(action.path); + queryClient.invalidateQueries({ + queryKey: ["file_diff", conversationId, strippedPath], + }); + } + + // When the agent autonomously swaps the LLM via SwitchLLMTool, refresh the + // conversation so SwitchProfileButton / ChatInputModel pick up the new + // `llm_model`, and drop any previously-set optimistic profile name (it + // would now misrepresent the agent's choice). + if (event.tool_name === "SwitchLLMTool") { + queryClient.invalidateQueries({ + queryKey: ["user", "conversation", conversationId], + }); + useModelStore.getState().clearActiveProfile(conversationId); + } +}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..151d7406ed256992c13727673985df6d5bf20f2c --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,127 @@ +import { SlashCommandItem } from "#/hooks/chat/use-slash-command"; + +export const JSON_VIEW_THEME = { + base00: "transparent", // background + base01: "var(--cool-grey-900)", // lighter background + base02: "var(--cool-grey-700)", // selection background + base03: "var(--cool-grey-600)", // comments, invisibles + base04: "var(--cool-grey-500)", // dark foreground + base05: "var(--cool-grey-200)", // default foreground + base06: "var(--cool-grey-100)", // light foreground + base07: "#ffffff", // light background + base08: "#ff5370", // variables, red + base09: "#f78c6c", // integers, orange + base0A: "#ffcb6b", // booleans, yellow + base0B: "#c3e88d", // strings, green + base0C: "#89ddff", // support, cyan + base0D: "#82aaff", // functions, blue + base0E: "#c792ea", // keywords, purple + base0F: "#ff5370", // deprecated, red +}; + +export const PRODUCT_URL = { + PRODUCTION: "https://app.all-hands.dev", +}; + +export const SETTINGS_FORM = { + LABEL_CLASSNAME: "text-[11px] font-medium leading-4 tracking-[0.11px]", +}; + +// Chat input constants +export const CHAT_INPUT = { + HEIGHT_THRESHOLD: 100, // Height in pixels when suggestions should be hidden +}; + +// UI tolerance constants +export const EPS = 1.5; // px tolerance for "near min" height comparisons + +/** The /btw slash command — asks a side question via the ask_agent endpoint. */ +export const BTW_COMMAND = "/btw"; + +/** The /model slash command — lists or switches the conversation's LLM profile. */ +export const MODEL_COMMAND = "/model"; + +/** The /goal slash command — drives the agent toward an objective, judging completion each round. */ +export const GOAL_COMMAND = "/goal"; + +/** The /plan slash command — switches the conversation into Plan mode. */ +export const PLAN_COMMAND = "/plan"; + +/** The /code slash command — switches the conversation back to Code mode. */ +export const CODE_COMMAND = "/code"; + +/** Built-in slash commands surfaced in the menu for V1 conversations. */ +export const BUILT_IN_COMMANDS: SlashCommandItem[] = [ + { + skill: { + name: "new", + type: "agentskills", + source: null, + content: "Creates a new conversation using the same runtime", + triggers: ["/new"], + }, + command: "/new", + }, + { + skill: { + name: "btw", + type: "agentskills", + source: null, + content: "Ask the agent a side question without derailing the main task", + triggers: [BTW_COMMAND], + }, + command: BTW_COMMAND, + }, + { + skill: { + name: "model", + type: "agentskills", + source: null, + content: + "List saved LLM profiles, or switch the conversation LLM profile with /model ", + triggers: [MODEL_COMMAND], + }, + command: MODEL_COMMAND, + }, + { + skill: { + name: "goal", + type: "agentskills", + source: null, + content: + "Drive the agent toward an objective until a judge says it's done — /goal or /goal --max ", + triggers: [GOAL_COMMAND], + }, + command: GOAL_COMMAND, + }, + { + skill: { + name: "plan", + type: "agentskills", + source: null, + content: + "Switch the conversation into Plan mode, or start planning immediately with /plan ", + triggers: [PLAN_COMMAND], + }, + command: PLAN_COMMAND, + }, + { + skill: { + name: "code", + type: "agentskills", + source: null, + content: + "Switch the conversation back to Code mode, or resume immediately with /code ", + triggers: [CODE_COMMAND], + }, + command: CODE_COMMAND, + }, +]; + +// Skill content metadata prefixes +export const METADATA_PREFIXES: readonly string[] = [ + "The following information has been included", + "It may or may not be relevant", + "Skill location:", + "(Use this path to resolve", +]; diff --git a/src/utils/conversation-archive-status.ts b/src/utils/conversation-archive-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9903657b99079b14e66ec178131ab77b473b4fc --- /dev/null +++ b/src/utils/conversation-archive-status.ts @@ -0,0 +1,7 @@ +import type { SandboxStatus } from "#/api/conversation-service/agent-server-conversation-service.types"; + +export function isArchivedSandboxStatus( + sandboxStatus: SandboxStatus | null | undefined, +): boolean { + return sandboxStatus === "MISSING" || sandboxStatus === "ERROR"; +} diff --git a/src/utils/conversation-local-storage.ts b/src/utils/conversation-local-storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..379e12cc9cad08a7b8ef3c4e7b13df6cd351dc65 --- /dev/null +++ b/src/utils/conversation-local-storage.ts @@ -0,0 +1,437 @@ +import { useEffect, useState } from "react"; +import type { + ConversationTab, + ConversationMode, +} from "#/stores/conversation-store"; +import type { ViewMode } from "#/components/features/files-tab/view-mode"; +import { + DEFAULT_UNPINNED_OVERVIEW_GIT_PARTS, + DEFAULT_UNPINNED_OVERVIEW_SECTIONS, + VALID_CONVERSATION_OVERVIEW_GIT_PARTS, + VALID_CONVERSATION_OVERVIEW_SECTIONS, +} from "#/components/features/conversation/conversation-overview-sections"; + +export const LOCAL_STORAGE_KEYS = { + CONVERSATION_STATE: "conversation-state", + PENDING_TASK_DRAFT: "pending-task-draft", +} as const; + +const CONVERSATION_STATE_UPDATED_EVENT = "conversation-state-updated"; + +type ConversationStateUpdatedDetail = { + conversationId: string; +}; + +/** + * Consolidated conversation state stored in a single localStorage key. + * + * NOTE: the right-drawer open/closed state is intentionally *not* persisted + * here. Users expect the drawer to start closed every time the app is + * (re)loaded and only stay open when they opened it themselves during the + * current session. That state lives in the in-memory Zustand + * `useConversationStore` (`isRightPanelShown` / `hasRightPanelToggled`) + * which survives in-app navigation but resets on full reloads. Older + * builds wrote a `rightPanelShown` field into this blob; we accept and + * silently ignore that field on read so we don't churn old localStorage. + */ +export interface ConversationState { + selectedTab: ConversationTab | null; + unpinnedTabs: string[]; + unpinnedOverviewSections?: string[]; + unpinnedOverviewGitParts?: string[]; + conversationMode: ConversationMode; + subConversationTaskId: string | null; + draftMessage: string | null; + rightPanelShown?: boolean; + /** + * Legacy Files-tab Diff/Files toggle preference. Kept for blob + * compatibility; the Files surface no longer hosts Diff/Commits + * (those live in the sibling Commits conversation tab). + */ + filesTabDiffView: boolean | null; + /** User's persisted choice for the Files tab Rich/Plain content toggle. */ + filesTabContentViewMode: ViewMode; + /** Whether the left-hand file tree is visible in the Files tab. */ + filesTabTreeVisible?: boolean; + /** Open file tabs in the Files quick-row (basenames, closable). */ + filesTabOpenPaths?: string[]; + /** Currently selected path among `filesTabOpenPaths`, if any. */ + filesTabSelectedPath?: string | null; +} + +const DEFAULT_CONVERSATION_STATE: ConversationState = { + selectedTab: "files", + unpinnedTabs: [], + unpinnedOverviewSections: [...DEFAULT_UNPINNED_OVERVIEW_SECTIONS], + unpinnedOverviewGitParts: [...DEFAULT_UNPINNED_OVERVIEW_GIT_PARTS], + conversationMode: "code", + subConversationTaskId: null, + draftMessage: null, + rightPanelShown: false, + filesTabDiffView: null, + filesTabContentViewMode: "rich", + filesTabTreeVisible: true, + filesTabOpenPaths: [], + filesTabSelectedPath: null, +}; + +const VALID_CONVERSATION_TABS: ReadonlySet = new Set([ + "files", + "commits", + "browser", + "terminal", + "planner", + "tasklist", + "usage", +]); + +// Tab keys that *used to* exist and were removed during the Files tab +// refactor. We strip these out of any persisted state on read so that +// returning users don't keep ghost entries (in `selectedTab` or +// `unpinnedTabs`) forever — the UI has no way to surface them again to +// be re-pinned or re-selected. We deliberately use an explicit denylist +// here rather than a strict "whitelist against VALID_CONVERSATION_TABS" +// filter because `unpinnedTabs` is typed `string[]` and other parts of +// the app legitimately store arbitrary tab keys in it (sub-conversation +// tab ids etc.). +const REMOVED_CONVERSATION_TABS: ReadonlySet = new Set([ + "editor", + "served", + "app", + "vscode", +]); + +const VALID_VIEW_MODES: ReadonlySet = new Set(["rich", "plain"]); + +function sanitizeStoredState( + stored: Record, +): Partial { + let result: Partial = stored as Partial; + + if ( + result.selectedTab != null && + (REMOVED_CONVERSATION_TABS.has(result.selectedTab) || + (result.selectedTab as string) === "changes" || + !VALID_CONVERSATION_TABS.has(result.selectedTab as ConversationTab)) + ) { + const selectedTab = result.selectedTab as string; + result = { ...result }; + if (selectedTab === "changes") { + result.selectedTab = "commits"; + } else { + delete result.selectedTab; + } + } + + if (result.unpinnedTabs) { + const filtered = result.unpinnedTabs.filter( + (tab) => tab !== "changes" && !REMOVED_CONVERSATION_TABS.has(tab), + ); + if (filtered.length !== result.unpinnedTabs.length) { + result = { ...result, unpinnedTabs: filtered }; + } + } + + if (result.unpinnedOverviewSections) { + const filtered = result.unpinnedOverviewSections.filter((section) => + VALID_CONVERSATION_OVERVIEW_SECTIONS.has(section), + ); + if (filtered.length !== result.unpinnedOverviewSections.length) { + result = { ...result, unpinnedOverviewSections: filtered }; + } + } + + if (result.unpinnedOverviewGitParts) { + const filtered = result.unpinnedOverviewGitParts.filter((part) => + VALID_CONVERSATION_OVERVIEW_GIT_PARTS.has(part), + ); + if (filtered.length !== result.unpinnedOverviewGitParts.length) { + result = { ...result, unpinnedOverviewGitParts: filtered }; + } + } + + // Drop a corrupt/manually-edited `filesTabContentViewMode` so it falls + // back to the typed default ("rich") instead of leaking a junk string + // into ViewMode-typed consumers. TypeScript can't help us here — the + // value comes from `JSON.parse(localStorage.getItem(...))` which is + // unknown at runtime. + if ( + result.filesTabContentViewMode != null && + !VALID_VIEW_MODES.has(result.filesTabContentViewMode) + ) { + result = { ...result }; + delete result.filesTabContentViewMode; + } + + if ( + result.rightPanelShown != null && + typeof result.rightPanelShown !== "boolean" + ) { + result = { ...result }; + delete result.rightPanelShown; + } + + if ( + result.filesTabTreeVisible != null && + typeof result.filesTabTreeVisible !== "boolean" + ) { + result = { ...result }; + delete result.filesTabTreeVisible; + } + + if (result.filesTabOpenPaths != null) { + if (!Array.isArray(result.filesTabOpenPaths)) { + result = { ...result }; + delete result.filesTabOpenPaths; + } else { + const filtered = result.filesTabOpenPaths.filter( + (path): path is string => typeof path === "string" && path.length > 0, + ); + if (filtered.length !== result.filesTabOpenPaths.length) { + result = { ...result, filesTabOpenPaths: filtered }; + } + } + } + + if ( + result.filesTabSelectedPath != null && + typeof result.filesTabSelectedPath !== "string" + ) { + result = { ...result }; + delete result.filesTabSelectedPath; + } + + return result; +} + +/** + * Check if a conversation ID is a temporary task ID that should not be persisted. + * Task IDs have the format "task-{uuid}" and are used during V1 conversation initialization. + */ +export function isTaskConversationId(conversationId: string): boolean { + return conversationId.startsWith("task-"); +} + +/** + * Whether persistence should be skipped for this conversation id. + * + * Skips: + * - empty string ids (callers outside of a conversation route, e.g. + * rendered inside a unit test without a NavigationProvider) + * - "task-..." ids used as placeholders during V1 conversation + * initialization + */ +function shouldSkipPersistence(conversationId: string): boolean { + return conversationId === "" || isTaskConversationId(conversationId); +} + +/** + * Get the full conversation state from localStorage. + */ +export function getConversationState( + conversationId: string, +): ConversationState { + if (shouldSkipPersistence(conversationId)) { + return DEFAULT_CONVERSATION_STATE; + } + try { + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + const item = localStorage.getItem(key); + if (item !== null) { + return { + ...DEFAULT_CONVERSATION_STATE, + ...sanitizeStoredState(JSON.parse(item)), + }; + } + return DEFAULT_CONVERSATION_STATE; + } catch { + return DEFAULT_CONVERSATION_STATE; + } +} + +/** + * Set the conversation state in localStorage, merging with existing state. + */ +export function setConversationState( + conversationId: string, + updates: Partial, +): void { + if (shouldSkipPersistence(conversationId)) { + return; + } + try { + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + const currentState = getConversationState(conversationId); + const newState = { ...currentState, ...updates }; + localStorage.setItem(key, JSON.stringify(newState)); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent( + CONVERSATION_STATE_UPDATED_EVENT, + { detail: { conversationId } }, + ), + ); + } + } catch (err) { + console.warn("Failed to set conversation localStorage", err); + } +} + +function getPendingTaskDraftKey(taskId: string): string { + return `${LOCAL_STORAGE_KEYS.PENDING_TASK_DRAFT}-${taskId}`; +} + +export function setPendingTaskDraft( + taskId: string | null | undefined, + draftMessage: string, +): void { + if (!taskId) return; + try { + localStorage.setItem(getPendingTaskDraftKey(taskId), draftMessage); + } catch (err) { + console.warn("Failed to store pending task draft", err); + } +} + +export function consumePendingTaskDraft( + taskId: string | null | undefined, +): string | null { + if (!taskId) return null; + try { + const key = getPendingTaskDraftKey(taskId); + const draft = localStorage.getItem(key); + localStorage.removeItem(key); + return draft; + } catch (err) { + console.warn("Failed to consume pending task draft", err); + return null; + } +} + +export function clearConversationLocalStorage(conversationId: string) { + try { + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + localStorage.removeItem(key); + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent( + CONVERSATION_STATE_UPDATED_EVENT, + { detail: { conversationId } }, + ), + ); + } + } catch (err) { + console.warn( + "Failed to clear conversation localStorage", + conversationId, + err, + ); + } +} + +/** + * React hook for conversation-scoped localStorage state. + * Returns the full state and individual setters for each property. + * + * The right-drawer open state is deliberately not represented here — + * see the note on `ConversationState` for the rationale. + */ +export function useConversationLocalStorageState(conversationId: string): { + state: ConversationState; + setSelectedTab: (tab: ConversationTab | null) => void; + setUnpinnedTabs: (tabs: string[]) => void; + setUnpinnedOverviewSections?: (sections: string[]) => void; + setUnpinnedOverviewGitParts?: (parts: string[]) => void; + setConversationMode: (mode: ConversationMode) => void; + setDraftMessage: (message: string | null) => void; + setRightPanelShown?: (shown: boolean) => void; + setFilesTabDiffView: (diffView: boolean | null) => void; + setFilesTabContentViewMode: (mode: ViewMode) => void; + setFilesTabTreeVisible?: (visible: boolean) => void; + setFilesTabOpenState?: ( + openPaths: string[], + selectedPath: string | null, + ) => void; +} { + const [state, setState] = useState(() => + getConversationState(conversationId), + ); + + useEffect(() => { + if (typeof window === "undefined") { + return undefined; + } + + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + const syncState = () => { + setState(getConversationState(conversationId)); + }; + + const handleStorage = (event: StorageEvent) => { + if (event.key === key) { + syncState(); + } + }; + + const handleConversationStateUpdated = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail?.conversationId === conversationId) { + syncState(); + } + }; + + // Ensure this hook reflects latest state for the current conversation ID. + syncState(); + + window.addEventListener("storage", handleStorage); + window.addEventListener( + CONVERSATION_STATE_UPDATED_EVENT, + handleConversationStateUpdated, + ); + + return () => { + window.removeEventListener("storage", handleStorage); + window.removeEventListener( + CONVERSATION_STATE_UPDATED_EVENT, + handleConversationStateUpdated, + ); + }; + }, [conversationId]); + + const updateState = (updates: Partial) => { + if (shouldSkipPersistence(conversationId)) { + // No durable storage for this id (empty / task placeholder), but the + // hook is still useful as ephemeral in-memory state — update the + // local React mirror directly so toggles in the UI behave normally + // until a real conversation id arrives. + setState((prev) => ({ ...prev, ...updates })); + return; + } + setConversationState(conversationId, updates); + }; + + return { + state, + setSelectedTab: (tab) => updateState({ selectedTab: tab }), + setUnpinnedTabs: (tabs) => updateState({ unpinnedTabs: tabs }), + setUnpinnedOverviewSections: (sections) => + updateState({ unpinnedOverviewSections: sections }), + setUnpinnedOverviewGitParts: (parts) => + updateState({ unpinnedOverviewGitParts: parts }), + setConversationMode: (mode) => updateState({ conversationMode: mode }), + setDraftMessage: (message) => updateState({ draftMessage: message }), + setRightPanelShown: (shown) => updateState({ rightPanelShown: shown }), + setFilesTabDiffView: (diffView) => + updateState({ filesTabDiffView: diffView }), + setFilesTabContentViewMode: (mode) => + updateState({ filesTabContentViewMode: mode }), + setFilesTabTreeVisible: (visible) => + updateState({ filesTabTreeVisible: visible }), + setFilesTabOpenState: (openPaths, selectedPath) => + updateState({ + filesTabOpenPaths: openPaths, + filesTabSelectedPath: selectedPath, + }), + }; +} diff --git a/src/utils/conversation-metrics.ts b/src/utils/conversation-metrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..71adc57f2f93766b5274f5669bd4a807f0f559ee --- /dev/null +++ b/src/utils/conversation-metrics.ts @@ -0,0 +1,79 @@ +import type { + MetricsSnapshot, + RuntimeConversationInfo, + RuntimeConversationStats, + TokenUsage, +} from "#/api/conversation-service/agent-server-conversation-service.types"; + +/** + * TypeScript equivalent of the get_combined_metrics method from the Python SDK + * Combines metrics from all LLM usage IDs in a conversation's stats + */ +export function combineUsageMetrics( + stats: RuntimeConversationStats | null | undefined, +): MetricsSnapshot { + if (!stats?.usage_to_metrics) { + return { + accumulated_cost: 0, + max_budget_per_task: null, + accumulated_token_usage: null, + }; + } + + let totalCost = 0; + let maxBudgetPerTask: number | null = null; + let combinedTokenUsage: TokenUsage | null = null; + + // Iterate through all metrics and combine them + for (const metrics of Object.values(stats.usage_to_metrics)) { + // Add up costs + totalCost += metrics.accumulated_cost; + + // Keep the max budget per task if any is set + if (maxBudgetPerTask === null && metrics.max_budget_per_task !== null) { + maxBudgetPerTask = metrics.max_budget_per_task; + } + + // Combine token usage + if (metrics.accumulated_token_usage) { + if (combinedTokenUsage === null) { + combinedTokenUsage = { ...metrics.accumulated_token_usage }; + } else { + combinedTokenUsage = { + prompt_tokens: + combinedTokenUsage.prompt_tokens + + metrics.accumulated_token_usage.prompt_tokens, + completion_tokens: + combinedTokenUsage.completion_tokens + + metrics.accumulated_token_usage.completion_tokens, + cache_read_tokens: + combinedTokenUsage.cache_read_tokens + + metrics.accumulated_token_usage.cache_read_tokens, + cache_write_tokens: + combinedTokenUsage.cache_write_tokens + + metrics.accumulated_token_usage.cache_write_tokens, + context_window: Math.max( + combinedTokenUsage.context_window, + metrics.accumulated_token_usage.context_window, + ), + per_turn_token: Math.max( + combinedTokenUsage.per_turn_token, + metrics.accumulated_token_usage.per_turn_token, + ), + }; + } + } + } + + return { + accumulated_cost: totalCost, + max_budget_per_task: maxBudgetPerTask, + accumulated_token_usage: combinedTokenUsage, + }; +} + +export function getCombinedMetrics( + conversationInfo: RuntimeConversationInfo, +): MetricsSnapshot { + return combineUsageMetrics(conversationInfo.stats); +} diff --git a/src/utils/conversation-overview-project-scope.ts b/src/utils/conversation-overview-project-scope.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0a1aa8205f253f7b3684e2a4d1f086b4ac8be4f --- /dev/null +++ b/src/utils/conversation-overview-project-scope.ts @@ -0,0 +1,116 @@ +import type { Automation } from "#/types/automation"; +import type { SkillInfo } from "#/types/settings"; +import { + getSkillScope, + SKILL_SCOPE_ORDER, + type SkillScope, +} from "#/utils/skill-scope"; + +export const CONVERSATION_OVERVIEW_PROJECT_SCOPE = { + project: "project", + all: "all", +} as const; + +export type ConversationOverviewProjectScope = + (typeof CONVERSATION_OVERVIEW_PROJECT_SCOPE)[keyof typeof CONVERSATION_OVERVIEW_PROJECT_SCOPE]; + +export function normalizeRepositoryName( + repository: string | null | undefined, +): string | null { + if (!repository?.trim()) { + return null; + } + return repository + .trim() + .toLowerCase() + .replace(/\.git$/, ""); +} + +export function isAutomationForRepository( + automation: Pick, + repository: string | null | undefined, +): boolean { + const target = normalizeRepositoryName(repository); + const automationRepo = normalizeRepositoryName(automation.repository); + if (!target || !automationRepo) { + return false; + } + return automationRepo === target; +} + +export function isSkillForProject( + skill: SkillInfo, + projectDir?: string | null, +): boolean { + return getSkillScope(skill, projectDir) === "project"; +} + +export function filterAutomationsByProjectScope( + automations: Automation[], + scope: ConversationOverviewProjectScope, + repository: string | null | undefined, +): Automation[] { + if (scope === CONVERSATION_OVERVIEW_PROJECT_SCOPE.all) { + return automations; + } + return automations.filter((automation) => + isAutomationForRepository(automation, repository), + ); +} + +export function filterSkillsByProjectScope( + skills: SkillInfo[], + scope: ConversationOverviewProjectScope, + projectDir?: string | null, +): SkillInfo[] { + if (scope === CONVERSATION_OVERVIEW_PROJECT_SCOPE.all) { + return skills; + } + return skills.filter((skill) => isSkillForProject(skill, projectDir)); +} + +export function sortAutomationsByProjectRelevance( + automations: Automation[], + repository: string | null | undefined, +): Automation[] { + return [...automations].sort((left, right) => { + const leftMatch = isAutomationForRepository(left, repository) ? 0 : 1; + const rightMatch = isAutomationForRepository(right, repository) ? 0 : 1; + if (leftMatch !== rightMatch) { + return leftMatch - rightMatch; + } + return left.name.localeCompare(right.name); + }); +} + +export function sortSkillsByProjectRelevance( + skills: SkillInfo[], + projectDir?: string | null, +): SkillInfo[] { + return [...skills].sort((left, right) => { + const leftScope = getSkillScope(left, projectDir); + const rightScope = getSkillScope(right, projectDir); + const leftOrder = SKILL_SCOPE_ORDER.indexOf(leftScope as SkillScope); + const rightOrder = SKILL_SCOPE_ORDER.indexOf(rightScope as SkillScope); + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return left.name.localeCompare(right.name); + }); +} + +export function countAutomationsForRepository( + automations: readonly Automation[], + repository: string | null | undefined, +): number { + return automations.filter((automation) => + isAutomationForRepository(automation, repository), + ).length; +} + +export function countSkillsForProject( + skills: readonly SkillInfo[], + projectDir?: string | null, +): number { + return skills.filter((skill) => isSkillForProject(skill, projectDir)).length; +} diff --git a/src/utils/convert-image-to-base-64.ts b/src/utils/convert-image-to-base-64.ts new file mode 100644 index 0000000000000000000000000000000000000000..61cf7534952c62e8b98c750caa3568688f7be7d7 --- /dev/null +++ b/src/utils/convert-image-to-base-64.ts @@ -0,0 +1,9 @@ +export const convertImageToBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result as string); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); diff --git a/src/utils/convert-raw-providers-to-list.ts b/src/utils/convert-raw-providers-to-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..365d5be8a1c3ef69b2b94b1143edcb41ceff8ccb --- /dev/null +++ b/src/utils/convert-raw-providers-to-list.ts @@ -0,0 +1,14 @@ +import { Provider } from "#/types/settings"; + +export const convertRawProvidersToList = ( + raw: Partial> | undefined, +): Provider[] => { + if (!raw) return []; + const list: Provider[] = []; + for (const key of Object.keys(raw)) { + if (key) { + list.push(key as Provider); + } + } + return list; +}; diff --git a/src/utils/custom-toast-handlers.tsx b/src/utils/custom-toast-handlers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ffc37f8243759fc30b03dfb67d9e82a79bc2c5a --- /dev/null +++ b/src/utils/custom-toast-handlers.tsx @@ -0,0 +1,150 @@ +import React, { CSSProperties, ReactNode } from "react"; +import { CircleX } from "lucide-react"; +import toast, { ToastOptions } from "react-hot-toast"; +import { OH_STATUS_ERROR_COLOR } from "#/constants/status-colors"; +import { calculateToastDuration } from "./toast-duration"; +import { cn } from "#/utils/utils"; +import i18n from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; +import { + isBackendRequestTimeoutMessage, + isCorsOrNetworkErrorMessage, +} from "./user-facing-error"; + +// react-hot-toast accepts only CSSProperties via the style option — cannot use className +const TOAST_STYLE: CSSProperties = { + background: "var(--oh-color-tertiary)", + border: "1px solid var(--oh-border-input)", + color: "#fff", + borderRadius: "var(--oh-radius)", + maxWidth: "400px", + wordBreak: "break-word", + overflowWrap: "anywhere", + whiteSpace: "pre-wrap", +}; + +export const TOAST_OPTIONS: ToastOptions = { + position: "top-right", + style: TOAST_STYLE, +}; + +const ERROR_TOAST_STYLE: CSSProperties = { + ...TOAST_STYLE, + color: "var(--oh-muted)", +}; + +/** Icon + message row; center icon for single-line text, top-align when wrapped. */ +export function ErrorToastContent({ message }: { message: ReactNode }) { + const contentRef = React.useRef(null); + const [isMultiLine, setIsMultiLine] = React.useState(false); + + 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(); + }, [message]); + + return ( +
+ + + {message} + +
+ ); +} + +export const ERROR_TOAST_OPTIONS: ToastOptions = { + ...TOAST_OPTIONS, + icon: null, + style: ERROR_TOAST_STYLE, +}; + +export const displayErrorToast = (error: string | null | undefined) => { + let errorMessage = error || i18n.t(I18nKey.STATUS$ERROR); + if (isCorsOrNetworkErrorMessage(errorMessage)) { + errorMessage = i18n.t(I18nKey.ERROR$CORS_OR_NETWORK); + } else if (isBackendRequestTimeoutMessage(errorMessage)) { + errorMessage = i18n.t(I18nKey.ERROR$BACKEND_REQUEST_TIMEOUT); + } + const duration = calculateToastDuration(errorMessage, 4000); + toast(, { + ...ERROR_TOAST_OPTIONS, + duration, + }); +}; + +export const displaySuccessToast = (message: string) => { + const duration = calculateToastDuration(message, 5000); + toast.success( + + {message} + , + { ...TOAST_OPTIONS, duration }, + ); +}; + +export const displaySuccessToastWithLink = ( + message: string, + linkLabel: string, + href: string, +) => { + const duration = calculateToastDuration(`${message} ${linkLabel}`, 5000); + toast.success( + + {message}{" "} + + {linkLabel} + + , + { ...TOAST_OPTIONS, duration }, + ); +}; + +/** + * Neutral, non-success notice — used when an action completed but the outcome + * is qualified (e.g. a secret was saved but the active backend can't consume it + * yet). Renders without the success checkmark so it doesn't read as "all good". + */ +export const displayWarningToast = (message: string) => { + const duration = calculateToastDuration(message, 6000); + toast( + + {message} + , + { ...TOAST_OPTIONS, icon: "⚠️", duration }, + ); +}; diff --git a/src/utils/derive-profile-name.ts b/src/utils/derive-profile-name.ts new file mode 100644 index 0000000000000000000000000000000000000000..2aaa3e31dd7c0d18a9e2268db8c26d076754abf7 --- /dev/null +++ b/src/utils/derive-profile-name.ts @@ -0,0 +1,49 @@ +/** + * Profile name validation pattern. + * Profile names: 1-64 chars, must start with alphanumeric, then alphanumerics + * or '.', '_', '-'. Blocks empty names, path separators, leading dots + * (hidden files / path traversal), and shell-special characters. + */ +export const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +/** + * Shared profile name validation. Any whitespace (including leading/trailing) + * makes the value invalid. When `isRequired`, empty values are invalid; + * otherwise empty is valid. + */ +export function isProfileNameValid( + value: string, + { isRequired = false }: { isRequired?: boolean } = {}, +): boolean { + if (value === "") return !isRequired; + return PROFILE_NAME_PATTERN.test(value); +} + +/** + * Derive a profile name from a model string. + * Extracts the model portion after the provider prefix (e.g., "openai/gpt-4" -> "gpt-4") + * and sanitizes it to match the profile name pattern. + */ +export function deriveProfileNameFromModel(model: string): string { + // Extract the model name after the last slash (provider/model format) + const parts = model.split("/"); + const modelName = parts[parts.length - 1] || model; + + // Sanitize: replace invalid characters with dashes + let sanitized = modelName + .replace(/[^A-Za-z0-9._-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + + // Ensure it starts with alphanumeric + if (sanitized && !/^[A-Za-z0-9]/.test(sanitized)) { + sanitized = `profile-${sanitized}`; + } + + // Truncate to 64 characters + if (sanitized.length > 64) { + sanitized = sanitized.substring(0, 64).replace(/-+$/, ""); + } + + return sanitized || "default-profile"; +} diff --git a/src/utils/dropdown-classes.ts b/src/utils/dropdown-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..c449d42f5abe826cf7346434d016423c08551698 --- /dev/null +++ b/src/utils/dropdown-classes.ts @@ -0,0 +1,90 @@ +import { cn } from "#/utils/utils"; +import { formControlShellClassName } from "#/utils/form-control-classes"; + +/** Snap hover colors instantly — no transition-colors delay on menus/dropdowns. */ +export const dropdownInstantColorClassName = "transition-none"; + +/** 2px vertical gap between rows in a dropdown/context menu list. */ +export const dropdownMenuListGapClassName = "gap-0.5"; + +/** Standard horizontal gap between a row icon and its label. */ +export const dropdownMenuRowGapClassName = "gap-2"; + +/** Flex column shell for a dropdown menu item list. */ +export const dropdownMenuListClassName = cn( + "flex flex-col", + dropdownMenuListGapClassName, +); + +/** Combobox/select trigger shell with instant hover colors. */ +export const dropdownTriggerShellClassName = cn( + "bg-tertiary border border-[var(--oh-border-input)] rounded p-2", + "flex items-center gap-2", + formControlShellClassName, + dropdownInstantColorClassName, + "group w-full gap-2 px-3 text-[var(--oh-muted)] hover:text-white", +); + +/** Standard white-label menu row. */ +export const dropdownMenuRowClassName = cn( + "group flex w-full cursor-pointer items-center rounded px-2 py-2 text-left text-sm font-normal text-white", + dropdownMenuRowGapClassName, + "hover:bg-[var(--oh-interactive-hover)] disabled:cursor-not-allowed disabled:opacity-60", + dropdownInstantColorClassName, +); + +/** Menu row using foreground token (context menus). */ +export const dropdownMenuRowForegroundClassName = cn( + "group flex w-full cursor-pointer items-center rounded px-2 py-2 text-start text-sm font-normal", + dropdownMenuRowGapClassName, + "text-[var(--oh-foreground)] hover:bg-[var(--oh-interactive-hover)]", + "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent", + dropdownInstantColorClassName, +); + +/** Icon inside a menu row — muted until row hover/focus. */ +export const dropdownMenuRowIconClassName = cn( + "shrink-0 text-[var(--oh-muted)] group-hover:text-white group-focus-visible:text-white", + dropdownInstantColorClassName, +); + +/** Wrapper for SVG/React icon nodes inside a menu row. */ +export const dropdownMenuRowIconWrapperClassName = cn( + "flex size-4 shrink-0 items-center justify-center [&_svg]:text-current", + dropdownMenuRowIconClassName, +); + +/** Enum/filter dropdown trigger chip. */ +export const dropdownFilterTriggerClassName = cn( + "inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium", + "border-[var(--oh-border)] bg-base-secondary text-white", + "focus-visible:border-white/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/20", + dropdownInstantColorClassName, +); + +/** + * Scroll cap for the switch-profile menu: four two-line rows + * (p-2 + leading-5 title + gap-0.5 + leading-4 subtitle) plus list gaps. + */ +export const switchProfileMenuListScrollClassName = cn( + dropdownMenuListClassName, + "overflow-y-auto custom-scrollbar-always", + "max-h-[13.875rem]", +); + +/** + * Tall sidebar/context menus: cap height to the viewport and scroll with the + * custom scrollbar when content overflows (no scrollbar when everything fits). + */ +export const dropdownMenuViewportScrollClassName = cn( + "overflow-y-auto custom-scrollbar", + "max-h-[min(60vh,calc(100dvh-5rem))]", +); + +/** Footer action row inside a dropdown panel. */ +export const dropdownFooterActionClassName = cn( + "group flex w-full items-center rounded-md px-2 py-2 text-sm font-normal text-white", + dropdownMenuRowGapClassName, + "hover:bg-[var(--oh-interactive-hover)]", + dropdownInstantColorClassName, +); diff --git a/src/utils/enqueue-home-task-pending-message.ts b/src/utils/enqueue-home-task-pending-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..e576d91eaee1eae4955351dc04cec7656b23fe7f --- /dev/null +++ b/src/utils/enqueue-home-task-pending-message.ts @@ -0,0 +1,30 @@ +import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; + +/** + * Shows the user's first message immediately on cloud start-task routes + * (`/conversations/task-{uuid}`) while the sandbox provisions. + */ +export async function enqueueHomeTaskPendingMessage(options: { + conversationId: string; + text: string; + images: File[]; + imagesMarkedUploadAsFile: string[]; +}): Promise { + const { imagesToEmbed } = partitionImagesForUpload( + options.images, + options.imagesMarkedUploadAsFile, + ); + const imageUrls = await Promise.all( + imagesToEmbed.map((image) => convertImageToBase64(image)), + ); + + useOptimisticUserMessageStore.getState().enqueuePendingMessage({ + conversationId: options.conversationId, + text: options.text, + content: options.text, + imageUrls, + fileUrls: [], + }); +} diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..d95304246d93f7ad69f67fa12fe7143bed0ccb9b --- /dev/null +++ b/src/utils/error-handler.ts @@ -0,0 +1,47 @@ +import { trackEvent } from "#/services/telemetry"; +import type { ErrorClassification } from "@openhands/typescript-client"; + +interface ErrorDetails { + source?: string; + metadata?: Record; + classification?: ErrorClassification | null; +} + +const RESERVED_ERROR_KEYS = new Set([ + "error_source", + "error_kind", + "error_id", + "error_telemetry", +]); + +export function trackError({ + source, + metadata = {}, + classification, +}: ErrorDetails) { + // Reserved outcome fields are derived from `source`/`classification` and + // must not be overridable through arbitrary caller metadata. + const extra = Object.fromEntries( + Object.entries(metadata).filter(([key]) => !RESERVED_ERROR_KEYS.has(key)), + ); + const kind = classification?.kind || "unknown"; + // Promote a caller-provided `eventId` to the reserved `error_id` dimension as + // a fallback so unclassified errors stay correlatable with server-side logs. + const promotedEventId = + typeof metadata.eventId === "string" ? metadata.eventId : undefined; + if (promotedEventId != null) { + delete extra.eventId; + } + const errorId = classification?.error_id ?? promotedEventId; + + void trackEvent("error_outcome", { + ...extra, + current_url: window.location.href, + error_source: source || "unknown", + error_kind: kind, + // Keep diagnostic errors correlatable without capturing raw messages. + ...(errorId != null ? { error_id: errorId } : {}), + error_telemetry: + kind === "internal" || kind === "unknown" ? "diagnostic" : "outcome", + }); +} diff --git a/src/utils/event-logger.ts b/src/utils/event-logger.ts new file mode 100644 index 0000000000000000000000000000000000000000..c03c63d4475fecec0c9a03af3579afd3b0017c5a --- /dev/null +++ b/src/utils/event-logger.ts @@ -0,0 +1,49 @@ +/** + * A utility class for logging events. This class will only log events in development mode. + */ +class EventLogger { + static isDevMode = process.env.NODE_ENV === "development"; + + /** + * Format and log a message event + * @param event The raw event object + */ + static message(event: MessageEvent) { + if (this.isDevMode) { + console.warn(JSON.stringify(JSON.parse(event.data.toString()), null, 2)); + } + } + + /** + * Log an event with a name + * @param event The raw event object + * @param name The name of the event + */ + static event(event: Event, name?: string) { + if (this.isDevMode) { + console.warn(name || "EVENT", event); + } + } + + /** + * Log a warning message + * @param warning The warning message + */ + static warning(warning: string) { + if (this.isDevMode) { + console.warn(warning); + } + } + + /** + * Log an error message + * @param error The error message + */ + static error(error: string) { + if (this.isDevMode) { + console.error(error); + } + } +} + +export default EventLogger; diff --git a/src/utils/extract-model-and-provider.ts b/src/utils/extract-model-and-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3bba0340788f50975458d8706a65752bdd66043 --- /dev/null +++ b/src/utils/extract-model-and-provider.ts @@ -0,0 +1,20 @@ +/** + * Given a model string, extract the provider and model name. + * + * Provider assignment for bare model names is handled by the backend before + * the model list reaches the frontend. This function only parses slash-based + * ``provider/model`` strings and leaves everything else as a bare model. + * + * @example + * extractModelAndProvider("azure/ada") + * // returns { provider: "azure", model: "ada", separator: "/" } + */ +export const extractModelAndProvider = (model: string) => { + const [provider, ...modelId] = model.split("/"); + + if (!provider || modelId.length === 0) { + return { provider: "", model, separator: "" }; + } + + return { provider, model: modelId.join("/"), separator: "/" }; +}; diff --git a/src/utils/file-language.ts b/src/utils/file-language.ts new file mode 100644 index 0000000000000000000000000000000000000000..4aaa4f229896a570d9f70cd527dd0caf3bf22bd3 --- /dev/null +++ b/src/utils/file-language.ts @@ -0,0 +1,190 @@ +// Map a workspace-file path to a Prism grammar name (or `null` when we +// don't have a registered grammar for it, in which case the caller should +// fall back to plain pre/text rendering). +// +// We rely on the language allowlist already registered in +// `src/components/features/markdown/syntax-highlighter.ts` — every entry +// here MUST resolve to either a language name or an alias that file +// registers, otherwise PrismLight will fall back to no highlighting at +// all (and log a warning). +// +// Two lookup keys are tried, in order: +// 1. The lowercased final-segment extension (`tsx`, `py`, `yaml`, …). +// 2. A few well-known no-extension filenames (`Dockerfile`, `Makefile`). +// We deliberately keep this list tight: every entry maps a real file +// users see in workspaces to a grammar Prism actually understands. +const EXTENSION_TO_LANGUAGE: Record = { + // JS / TS family + js: "javascript", + jsx: "jsx", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + tsx: "tsx", + + // Python + py: "python", + pyi: "python", + pyw: "python", + + // Web + html: "markup", + htm: "markup", + svg: "markup", + xml: "markup", + css: "css", + scss: "scss", + sass: "sass", + less: "less", + + // Data / config + json: "json", + json5: "json5", + yaml: "yaml", + yml: "yaml", + toml: "toml", + ini: "ini", + cfg: "ini", + properties: "properties", + conf: "ini", + env: "bash", + + // Markdown / docs + md: "markdown", + markdown: "markdown", + mdx: "markdown", + + // Shell / scripts + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "bash", + bat: "batch", + cmd: "batch", + ps1: "powershell", + ps: "powershell", + + // Systems + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + hh: "cpp", + cs: "csharp", + java: "java", + kt: "kotlin", + kts: "kotlin", + scala: "scala", + rs: "rust", + go: "go", + rb: "ruby", + php: "php", + swift: "swift", + m: "objectivec", + mm: "objectivec", + + // Functional + hs: "haskell", + ex: "elixir", + exs: "elixir", + erl: "erlang", + fs: "fsharp", + fsx: "fsharp", + ml: "ocaml", + mli: "ocaml", + clj: "clojure", + cljs: "clojure", + cljc: "clojure", + + // Scripting / data + lua: "lua", + pl: "perl", + pm: "perl", + r: "r", + jl: "julia", + dart: "dart", + groovy: "groovy", + gradle: "groovy", + + // Infrastructure + tf: "hcl", + hcl: "hcl", + dockerfile: "docker", + + // Misc text + sql: "sql", + graphql: "graphql", + gql: "graphql", + proto: "protobuf", + diff: "diff", + patch: "diff", + nix: "nix", + sol: "solidity", +}; + +const FILENAME_TO_LANGUAGE: Record = { + dockerfile: "docker", + makefile: "makefile", + gnumakefile: "makefile", + ".bashrc": "bash", + ".zshrc": "bash", + ".profile": "bash", + ".env": "bash", + ".gitignore": "bash", + ".dockerignore": "bash", +}; + +function getExtension(path: string): string { + const slashIdx = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + const basename = path.slice(slashIdx + 1); + const dotIdx = basename.lastIndexOf("."); + if (dotIdx <= 0) return ""; + return basename.slice(dotIdx + 1).toLowerCase(); +} + +function getBasename(path: string): string { + const slashIdx = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return path.slice(slashIdx + 1).toLowerCase(); +} + +/** + * Resolve a path (and optionally a MIME type) to a Prism grammar name. + * Returns `null` when no grammar matches — the caller should then render + * the source as plain text rather than feeding "unknown" to Prism (which + * still produces an extra wrapper with no highlighting). + */ +export function getPrismLanguageForFile( + path: string, + mimeType?: string, +): string | null { + const basename = getBasename(path); + const fromBasename = FILENAME_TO_LANGUAGE[basename]; + if (fromBasename) return fromBasename; + + const ext = getExtension(path); + if (ext && EXTENSION_TO_LANGUAGE[ext]) { + return EXTENSION_TO_LANGUAGE[ext]; + } + + // MIME-type fallbacks for the handful of cases where the path lacks a + // useful extension but the server told us what it is. + if (mimeType) { + if (mimeType === "text/html") return "markup"; + if (mimeType === "text/css") return "css"; + if (mimeType === "application/json") return "json"; + if (mimeType === "text/markdown") return "markdown"; + if ( + mimeType === "application/javascript" || + mimeType === "text/javascript" + ) { + return "javascript"; + } + if (mimeType === "application/x-yaml" || mimeType === "text/yaml") { + return "yaml"; + } + } + + return null; +} diff --git a/src/utils/file-priority.ts b/src/utils/file-priority.ts new file mode 100644 index 0000000000000000000000000000000000000000..49d2ad55ecefb22752f165491b700458b4a4f20d --- /dev/null +++ b/src/utils/file-priority.ts @@ -0,0 +1,106 @@ +/** + * Utilities for ranking workspace files by "importance" so that the file-tab + * top-row surfaces the entry points (`index.html`, `README.md`, `package.json`, + * etc.) ahead of nested utility modules. + */ + +/** + * File basenames (lowercased) that are almost always the entrypoint for a + * project. Lower index = higher priority. + */ +const HIGH_PRIORITY_BASENAMES: string[] = [ + "index.html", + "index.htm", + "readme.md", + "readme", + "main.html", + "app.html", + "index.js", + "index.ts", + "index.tsx", + "index.jsx", + "main.py", + "app.py", + "main.go", + "main.rs", + "main.java", + "main.c", + "main.cpp", + "package.json", + "pyproject.toml", + "cargo.toml", + "go.mod", + "pom.xml", + "dockerfile", + "makefile", +]; + +/** + * Filenames that are useful but typically of secondary interest compared to + * the entrypoints above. + */ +const SECONDARY_BASENAMES: string[] = [ + "license", + "license.md", + "license.txt", + "changelog.md", + "agents.md", + "tsconfig.json", + ".env.sample", + ".env.example", +]; + +function getBasename(path: string): string { + const idx = path.lastIndexOf("/"); + return (idx === -1 ? path : path.slice(idx + 1)).toLowerCase(); +} + +function pathDepth(path: string): number { + // Filter empty segments so leading/trailing/double slashes don't inflate + // depth (e.g. `/src/index.html`, `src//index.html`, `src/` should all + // count the same as `src/index.html`). Matches the convention already + // used by `buildFileTree` in `file-tree.ts`. + return path.split("/").filter(Boolean).length - 1; +} + +/** + * Rank a path *within its own depth bucket*: high-priority entrypoints + * first (in the order listed), then secondary supporting files, then + * everything else. Depth is the primary sort axis applied by + * {@link sortFilesByPriority}; this score is the tie-breaker for paths at + * the same depth. + */ +export function filePriorityScore(path: string): number { + const base = getBasename(path); + + const highIdx = HIGH_PRIORITY_BASENAMES.indexOf(base); + if (highIdx !== -1) return highIdx; + + const secondaryIdx = SECONDARY_BASENAMES.indexOf(base); + if (secondaryIdx !== -1) return 1000 + secondaryIdx; + + return 10000; +} + +/** + * Returns a copy of `paths` sorted so the most likely "landing files" come + * first. + * + * Sort order: + * 1. Shallower paths beat deeper ones, unconditionally. A top-level + * `README.md` outranks `foo/bar/index.html` even though `index.html` + * is a more "important" basename — the user almost always cares more + * about top-level files when they first open a project. + * 2. Within the same depth, basenames are ordered by importance + * (`index.html` before `README.md` before random utility modules). + * 3. Final tie-breaker is alphabetical. + */ +export function sortFilesByPriority(paths: string[]): string[] { + return [...paths].sort((a, b) => { + const depthDiff = pathDepth(a) - pathDepth(b); + if (depthDiff !== 0) return depthDiff; + const scoreDiff = filePriorityScore(a) - filePriorityScore(b); + if (scoreDiff !== 0) return scoreDiff; + return a.localeCompare(b); + }); +} diff --git a/src/utils/file-processing.ts b/src/utils/file-processing.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1cc15985f82b3bcbff193fa6e122d480992e9c7 --- /dev/null +++ b/src/utils/file-processing.ts @@ -0,0 +1,109 @@ +/** + * Real file processing utilities using FileReader API + * These functions perform actual file reading operations that take time for large files + */ + +/** + * Process a regular file by reading its content into memory + * For large files (1GB+), this will take significant time + */ +const processFile = async (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + // File has been fully read into memory + resolve(); + }; + + reader.onerror = () => { + reject(new Error(`Failed to read file: ${file.name}`)); + }; + + reader.onabort = () => { + reject(new Error(`File reading was aborted: ${file.name}`)); + }; + + // Read the file as ArrayBuffer - this takes time for large files + // For a 1GB file, this can take several seconds + reader.readAsArrayBuffer(file); + }); + +/** + * Process an image file by reading its content + * This validates the image can be read and prepares it for display + */ +const processImage = async (image: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + // Image has been read and is ready for display + resolve(); + }; + + reader.onerror = () => { + reject(new Error(`Failed to read image: ${image.name}`)); + }; + + reader.onabort = () => { + reject(new Error(`Image reading was aborted: ${image.name}`)); + }; + + // Read the image as data URL - this takes time for large images + reader.readAsDataURL(image); + }); + +/** + * Process multiple files concurrently with individual error handling + */ +export const processFiles = async ( + files: File[], +): Promise<{ successful: File[]; failed: { file: File; error: Error }[] }> => { + const results = await Promise.allSettled( + files.map(async (file) => { + await processFile(file); + return file; + }), + ); + + const successful: File[] = []; + const failed: { file: File; error: Error }[] = []; + + results.forEach((result, index) => { + if (result.status === "fulfilled") { + successful.push(result.value); + } else { + failed.push({ file: files[index], error: result.reason }); + } + }); + + return { successful, failed }; +}; + +/** + * Process multiple images concurrently with individual error handling + */ +export const processImages = async ( + images: File[], +): Promise<{ successful: File[]; failed: { file: File; error: Error }[] }> => { + const results = await Promise.allSettled( + images.map(async (image) => { + await processImage(image); + return image; + }), + ); + + const successful: File[] = []; + const failed: { file: File; error: Error }[] = []; + + results.forEach((result, index) => { + if (result.status === "fulfilled") { + successful.push(result.value); + } else { + failed.push({ file: images[index], error: result.reason }); + } + }); + + return { successful, failed }; +}; diff --git a/src/utils/file-tree.ts b/src/utils/file-tree.ts new file mode 100644 index 0000000000000000000000000000000000000000..280d80892482bae1bc58587be01f0e6f004fc90f --- /dev/null +++ b/src/utils/file-tree.ts @@ -0,0 +1,98 @@ +export interface FileTreeNode { + name: string; + path: string; // full path relative to workspace root + isDirectory: boolean; + children: FileTreeNode[]; +} + +function sortTreeInPlace(node: FileTreeNode): void { + node.children.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + for (const child of node.children) { + if (child.isDirectory) sortTreeInPlace(child); + } +} + +/** + * Build a tree representation of a flat list of relative file paths. + * Directories are sorted before files; siblings are sorted alphabetically. + * + * Implementation notes: + * - We keep a side-table mapping each parent node to its own + * `Map` so adding a segment is O(1) instead of the + * O(n) `children.find(...)` linear scan we used to do. With a + * directory containing 1000 siblings, the old code did ~500k string + * comparisons just to bucket them all in; the Map cuts that to + * ~1000. The side-table lives only for the duration of the build + * call so the public FileTreeNode shape stays clean. + * - If we encounter a path like `"src"` followed by `"src/index.ts"`, + * the first entry creates `src` as a file (`isDirectory: false`), + * then the second needs to add a child to it. We promote the leaf + * to a directory in that case rather than dropping the child on + * the floor. (The flat input shouldn't normally contain both, but + * defensive coding here is cheap and keeps the tree well-formed.) + */ + +function getOrCreateChild( + parent: FileTreeNode, + childMap: Map>, + segment: string, + prefix: string, + isLast: boolean, +): FileTreeNode { + let map = childMap.get(parent); + if (!map) { + map = new Map(); + childMap.set(parent, map); + } + const existing = map.get(segment); + if (existing) { + // Promote a leaf to a directory if we're about to descend into it. + if (!isLast && !existing.isDirectory) { + existing.isDirectory = true; + } + return existing; + } + const node: FileTreeNode = { + name: segment, + path: prefix, + isDirectory: !isLast, + children: [], + }; + parent.children.push(node); + map.set(segment, node); + return node; +} + +export function buildFileTree(paths: string[]): FileTreeNode { + const root: FileTreeNode = { + name: "", + path: "", + isDirectory: true, + children: [], + }; + + // Side-table mapping a parent node to its child lookup map. Keeping the + // accelerator out of the FileTreeNode shape itself means consumers never + // see it — no post-build cleanup pass, and the public type stays clean. + const childMap = new Map>(); + + for (const path of paths) { + const segments = path.split("/").filter(Boolean); + if (segments.length > 0) { + let cursor: FileTreeNode = root; + let prefix = ""; + for (let i = 0; i < segments.length; i += 1) { + const segment = segments[i]; + prefix = prefix ? `${prefix}/${segment}` : segment; + const isLast = i === segments.length - 1; + cursor = getOrCreateChild(cursor, childMap, segment, prefix, isLast); + } + } + } + + sortTreeInPlace(root); + return root; +} diff --git a/src/utils/file-validation.ts b/src/utils/file-validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f3736af3a21a4260f774fabc15dacc1fbd698bd --- /dev/null +++ b/src/utils/file-validation.ts @@ -0,0 +1,70 @@ +const MAX_FILE_SIZE = 3 * 1024 * 1024; // 3MB maximum file size +const MAX_TOTAL_SIZE = 3 * 1024 * 1024; // 3MB maximum total size for all files combined + +export interface FileValidationResult { + isValid: boolean; + errorMessage?: string; + oversizedFiles?: string[]; +} + +/** + * Validates individual file sizes + */ +export function validateIndividualFileSizes( + files: File[], +): FileValidationResult { + const oversizedFiles = files.filter((file) => file.size > MAX_FILE_SIZE); + + if (oversizedFiles.length > 0) { + const fileNames = oversizedFiles.map((f) => f.name); + return { + isValid: false, + errorMessage: `Files exceeding 3MB are not allowed: ${fileNames.join(", ")}`, + oversizedFiles: fileNames, + }; + } + + return { isValid: true }; +} + +/** + * Validates total file size including existing files + */ +export function validateTotalFileSize( + newFiles: File[], + existingFiles: File[] = [], +): FileValidationResult { + const currentTotalSize = existingFiles.reduce( + (sum, file) => sum + file.size, + 0, + ); + const newFilesSize = newFiles.reduce((sum, file) => sum + file.size, 0); + const totalSize = currentTotalSize + newFilesSize; + + if (totalSize > MAX_TOTAL_SIZE) { + const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(1); + return { + isValid: false, + errorMessage: `Total file size would be ${totalSizeMB}MB, exceeding the 3MB limit. Please select fewer or smaller files.`, + }; + } + + return { isValid: true }; +} + +/** + * Validates both individual and total file sizes + */ +export function validateFiles( + newFiles: File[], + existingFiles: File[] = [], +): FileValidationResult { + // First check individual file sizes + const individualValidation = validateIndividualFileSizes(newFiles); + if (!individualValidation.isValid) { + return individualValidation; + } + + // Then check total size + return validateTotalFileSize(newFiles, existingFiles); +} diff --git a/src/utils/flush-pending-task-attachments.ts b/src/utils/flush-pending-task-attachments.ts new file mode 100644 index 0000000000000000000000000000000000000000..f00357cd2fb786abfb16795dbbd203db368692e5 --- /dev/null +++ b/src/utils/flush-pending-task-attachments.ts @@ -0,0 +1,33 @@ +import i18n, { OPENHANDS_I18N_NAMESPACE, waitForI18n } from "#/i18n"; +import { consumePendingTaskAttachments } from "#/stores/pending-task-attachments-store"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments"; + +/** + * Sends attachments queued during cloud start-task provisioning once the + * real conversation UUID is available. + */ +export async function flushPendingTaskAttachments( + taskId: string, + conversationId: string, +): Promise { + const pending = consumePendingTaskAttachments(taskId); + if (!pending) { + return; + } + + try { + await waitForI18n(); + await sendMessageWithAttachments({ + conversationId, + content: pending.content, + images: pending.images, + files: pending.files, + imagesMarkedUploadAsFile: pending.imagesMarkedUploadAsFile, + t: i18n.getFixedT(null, OPENHANDS_I18N_NAMESPACE), + }); + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + throw error; + } +} diff --git a/src/utils/form-control-classes.ts b/src/utils/form-control-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d3c0da9a2b44b45c66f38d115999ae6e4650c1c --- /dev/null +++ b/src/utils/form-control-classes.ts @@ -0,0 +1,146 @@ +import { cn } from "#/utils/utils"; + +/** 36px control height — shared by fields, dropdowns, and buttons. */ +export const formControlHeightClassName = "h-9 min-h-9"; + +export const formControlRadiusClassName = "rounded-lg"; + +export const formControlBorderClassName = "border border-[var(--oh-border)]"; + +export const formControlSurfaceClassName = "bg-base-secondary"; + +/** Shared transition duration for form controls and chrome buttons. */ +export const formControlTransitionDurationClassName = "duration-75"; + +export const formControlHeroUiTransitionDurationClassName = "!duration-75"; + +export const formControlMotionReduceClassName = "motion-reduce:transition-none"; + +/** Shell properties that animate on hover/focus; foreground color snaps instantly. */ +export const formControlTransitionPropertiesClassName = + "transition-[background-color,border-color,box-shadow,opacity]"; + +/** Animate shell chrome on hover/focus; foreground color snaps instantly. */ +export const formControlTransitionClassName = cn( + formControlTransitionPropertiesClassName, + formControlTransitionDurationClassName, + formControlMotionReduceClassName, +); + +/** HeroUI input wrappers ship `transition-colors`; override so caret/text hover is instant. */ +export const formControlHeroUiWrapperTransitionClassName = cn( + "!transition-[background-color,border-color,box-shadow,opacity]", + formControlHeroUiTransitionDurationClassName, + formControlMotionReduceClassName, +); + +/** Transform-only transitions (e.g. combobox carets). */ +export const formControlTransformTransitionClassName = cn( + "transition-[transform] ease", + formControlTransitionDurationClassName, + formControlMotionReduceClassName, +); + +/** Muted icon/pill controls: instant foreground, fading shell on hover. */ +export const formControlMutedHoverClassName = + "hover:text-white hover:bg-white/10"; + +/** Text/icon pill triggers in the chat input actions row. */ +export const chatInputPillButtonClassName = cn( + "inline-flex items-center gap-1 rounded-[100px] border border-transparent px-1.5", + "text-sm font-normal leading-5 text-[var(--oh-muted)] whitespace-nowrap min-w-0 cursor-pointer", + formControlTransitionClassName, + formControlMutedHoverClassName, +); + +/** Circular icon triggers in the chat input actions row. */ +export const chatInputIconButtonClassName = cn( + "flex items-center justify-center rounded-full text-[var(--oh-muted)] cursor-pointer", + formControlTransitionClassName, + formControlMutedHoverClassName, +); + +export const formControlFocusClassName = + "focus:border-white/40 focus:ring-1 focus:ring-white/20 focus:outline-none"; + +export const formControlFocusWithinClassName = + "focus-within:border-white/40 focus-within:ring-1 focus-within:ring-white/20"; + +export const formControlDisabledClassName = + "disabled:cursor-not-allowed disabled:opacity-60"; + +/** Native text inputs and HeroUI Autocomplete wrappers. */ +export const formControlFieldClassName = cn( + formControlHeightClassName, + formControlRadiusClassName, + formControlBorderClassName, + formControlSurfaceClassName, + formControlTransitionClassName, + formControlFocusClassName, + formControlDisabledClassName, + "w-full min-w-0 px-3 text-sm text-white placeholder:text-tertiary-alt", +); + +/** Settings screens keep italic placeholders on form controls. */ +export const formControlSettingsFieldClassName = cn( + formControlFieldClassName, + "placeholder:italic", + formControlHeroUiWrapperTransitionClassName, +); + +/** Multiline fields share border/radius/focus styling without a fixed height. */ +export const formControlMultilineFieldClassName = cn( + formControlRadiusClassName, + formControlBorderClassName, + formControlSurfaceClassName, + formControlTransitionClassName, + formControlFocusClassName, + formControlDisabledClassName, + "w-full min-w-0 px-3 py-2 text-sm text-white placeholder:text-tertiary-alt", +); + +/** Combobox / search shell (icon + input), e.g. skills toolbar. */ +export const formControlShellClassName = cn( + formControlHeightClassName, + "relative flex min-w-0 items-center", + formControlRadiusClassName, + formControlBorderClassName, + formControlSurfaceClassName, + formControlTransitionClassName, + formControlFocusWithinClassName, +); + +/** Borderless input nested inside {@link formControlShellClassName}. */ +export const formControlInlineInputClassName = cn( + "min-w-0 flex-1 border-0 bg-transparent px-3 text-sm outline-none", + "placeholder:text-tertiary-alt", + "[&::-webkit-search-cancel-button]:hidden", +); + +/** Primary/secondary/danger action buttons. */ +export const formControlButtonClassName = cn( + formControlHeightClassName, + "inline-flex w-fit cursor-pointer items-center justify-center gap-2 px-3", + formControlRadiusClassName, + formControlTransitionClassName, + "text-sm font-normal disabled:cursor-not-allowed disabled:opacity-30", +); + +/** Helper text under a left-aligned {@link SettingsSwitch} (40px track + gap-2). */ +export const formControlSwitchDescriptionClassName = "pl-12"; + +/** Filter / enum dropdown triggers beside search toolbars. */ +export const formControlFilterTriggerClassName = cn( + formControlButtonClassName, + formControlBorderClassName, + formControlSurfaceClassName, + "shrink-0 text-white", +); + +/** Muted back navigation control with tertiary hover fill (settings sub-pages, detail views). */ +export const formControlBackNavButtonClassName = cn( + "inline-flex items-center gap-2 self-start rounded-lg p-2", + "text-sm font-normal leading-5 text-[var(--oh-muted)] cursor-pointer", + formControlTransitionClassName, + "hover:bg-tertiary hover:text-white", +); diff --git a/src/utils/format-event-timestamp.ts b/src/utils/format-event-timestamp.ts new file mode 100644 index 0000000000000000000000000000000000000000..65b9f164af102e12dae6e93382b3809daa287dce --- /dev/null +++ b/src/utils/format-event-timestamp.ts @@ -0,0 +1,20 @@ +export function formatEventTimestamp( + timestamp?: string, + locale?: string, +): string | null { + if (!timestamp) return null; + + // Agent-server events carry datetime.now().isoformat(), which has no offset. + // Left alone the browser reads that wall-clock as its own local time, so an + // event recorded in a UTC sandbox renders hours off for anyone outside UTC. + const utc = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(timestamp) + ? timestamp + : `${timestamp}Z`; + const date = new Date(utc); + if (Number.isNaN(date.getTime())) return null; + + return date.toLocaleString(locale, { + dateStyle: "medium", + timeStyle: "short", + }); +} diff --git a/src/utils/format-model-name.test.ts b/src/utils/format-model-name.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e933b94971085d28839a4c3f57997eb8da5312a --- /dev/null +++ b/src/utils/format-model-name.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { formatModelPillLabel } from "./format-model-name"; + +describe("formatModelPillLabel", () => { + it("keeps the pretty OpenHands label without a free suffix unless backend marks it free", () => { + expect(formatModelPillLabel("openhands/deepseek-v4-flash")).toBe( + "OpenHands DeepSeek V4 Flash", + ); + + expect( + formatModelPillLabel( + "openhands/deepseek-v4-flash", + new Set(["openhands/deepseek-v4-flash"]), + ), + ).toBe("OpenHands DeepSeek V4 Flash (free)"); + }); +}); diff --git a/src/utils/format-relative-time.ts b/src/utils/format-relative-time.ts new file mode 100644 index 0000000000000000000000000000000000000000..b773755062e04734a2646e172d1c1e3aba31b083 --- /dev/null +++ b/src/utils/format-relative-time.ts @@ -0,0 +1,47 @@ +import { I18nKey } from "#/i18n/declaration"; + +export function formatDate(dateStr: string, locale: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString(locale, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +/** + * Whether a timestamp is one nothing can be computed from. Besides an + * unparseable string, that includes the epoch: the backend leaves unset + * datetimes at zero, and formatting one produces "Jan 1, 1970" rather than + * an honest "unknown". + */ +export function isInvalidTimestamp( + dateStr: string | null | undefined, +): boolean { + if (!dateStr) return true; + const parsed = new Date(dateStr).getTime(); + return Number.isNaN(parsed) || parsed === 0; +} + +export function formatRelativeTime( + dateStr: string, + locale: string, + t: (key: I18nKey, options?: Record) => string, +): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffMins < 1) return t(I18nKey.AUTOMATIONS$DETAIL$TIME_JUST_NOW); + if (diffMins < 60) + return t(I18nKey.AUTOMATIONS$DETAIL$TIME_MINUTES_AGO, { count: diffMins }); + if (diffHours < 24) + return t(I18nKey.AUTOMATIONS$DETAIL$TIME_HOURS_AGO, { count: diffHours }); + if (diffDays === 1) return t(I18nKey.AUTOMATIONS$DETAIL$TIME_YESTERDAY); + if (diffDays < 7) + return t(I18nKey.AUTOMATIONS$DETAIL$TIME_DAYS_AGO, { count: diffDays }); + return formatDate(dateStr, locale); +} diff --git a/src/utils/format-time-delta.ts b/src/utils/format-time-delta.ts new file mode 100644 index 0000000000000000000000000000000000000000..1501e8fa92b9dbe4a6787d9fb048e1d5ff98362e --- /dev/null +++ b/src/utils/format-time-delta.ts @@ -0,0 +1,57 @@ +/** + * Parses a date string as UTC if it doesn't have a timezone indicator. + * This fixes the issue where ISO strings without timezone info are interpreted as local time. + * @param dateString ISO 8601 date string + * @returns Date object parsed as UTC + * + * @example + * parseDateAsUTC("2025-12-01T11:53:37.273886"); // Parsed as UTC + * parseDateAsUTC("2025-12-01T11:53:37.273886Z"); // Already has timezone, parsed correctly + * parseDateAsUTC("2025-12-01T11:53:37+00:00"); // Already has timezone, parsed correctly + */ +const parseDateAsUTC = (dateString: string): Date => { + // Check if the string already has a timezone indicator + // Look for 'Z' (UTC), '+' (positive offset), or '-' after the time part (negative offset) + const hasTimezone = + dateString.includes("Z") || dateString.match(/[+-]\d{2}:\d{2}$/) !== null; + + if (hasTimezone) { + // Already has timezone info, parse normally + return new Date(dateString); + } + + // No timezone indicator - append 'Z' to force UTC parsing + return new Date(`${dateString}Z`); +}; + +/** + * Formats a date into a compact string representing the time delta between the given date and the current date. + * @param date The date to format (Date object or ISO 8601 string) + * @returns A compact string representing the time delta between the given date and the current date + * + * @example + * // now is 2024-01-01T00:00:00Z + * formatTimeDelta(new Date("2023-12-31T23:59:59Z")); // "1s" + * formatTimeDelta("2023-12-31T23:59:59Z"); // "1s" + * formatTimeDelta("2025-12-01T11:53:37.273886"); // Parsed as UTC automatically + */ +export const formatTimeDelta = (date: Date | string) => { + // Parse string dates as UTC if needed, or use Date object directly + const dateObj = typeof date === "string" ? parseDateAsUTC(date) : date; + const now = new Date(); + const delta = now.getTime() - dateObj.getTime(); + + const seconds = Math.floor(delta / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(months / 12); + + if (seconds < 60) return `${seconds}s`; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 30) return `${days}d`; + if (months < 12) return `${months}mo`; + return `${years}y`; +}; diff --git a/src/utils/get-git-path.ts b/src/utils/get-git-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..a950a3b52994f30b040e9cb009ce169d3f9e93cf --- /dev/null +++ b/src/utils/get-git-path.ts @@ -0,0 +1,20 @@ +import { DEFAULT_WORKING_DIR } from "#/api/agent-server-config"; + +export function getGitPath( + selectedRepository: string | null | undefined, + workingDir?: string | null, +): string { + const normalizedWorkingDir = workingDir?.trim(); + if (normalizedWorkingDir) { + return normalizedWorkingDir; + } + + if (!selectedRepository) { + return DEFAULT_WORKING_DIR; + } + + const parts = selectedRepository.split("/"); + const repoName = parts[parts.length - 1]; + + return `${DEFAULT_WORKING_DIR}/${repoName}`; +} diff --git a/src/utils/get-language-from-path.ts b/src/utils/get-language-from-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d5b3c7163c6305a0c7391cb56ef8b3d78ab2ce0 --- /dev/null +++ b/src/utils/get-language-from-path.ts @@ -0,0 +1,49 @@ +export const getLanguageFromPath = (path: string): string => { + const extension = path.split(".").pop()?.toLowerCase(); + switch (extension) { + case "js": + case "jsx": + return "javascript"; + case "ts": + case "tsx": + return "typescript"; + case "py": + return "python"; + case "html": + return "html"; + case "css": + return "css"; + case "json": + return "json"; + case "md": + return "markdown"; + case "yml": + case "yaml": + return "yaml"; + case "sh": + case "bash": + return "bash"; + case "dockerfile": + return "dockerfile"; + case "rs": + return "rust"; + case "go": + return "go"; + case "java": + return "java"; + case "cpp": + case "cc": + case "cxx": + return "cpp"; + case "c": + return "c"; + case "rb": + return "ruby"; + case "php": + return "php"; + case "sql": + return "sql"; + default: + return "text"; + } +}; diff --git a/src/utils/git-control-bar-classes.ts b/src/utils/git-control-bar-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..be0ff70ee867779943c64bc1dd3a2e354c3ca296 --- /dev/null +++ b/src/utils/git-control-bar-classes.ts @@ -0,0 +1,20 @@ +import { cn } from "#/utils/utils"; + +export const gitControlBarActionButtonBaseClassName = + "flex flex-row gap-1 items-center justify-center rounded-[100px]"; + +export function gitControlBarActionButtonClassName(isEnabled: boolean) { + return cn( + gitControlBarActionButtonBaseClassName, + isEnabled + ? "bg-[var(--oh-surface)] hover:bg-tertiary cursor-pointer text-white" + : "bg-[var(--oh-surface)] cursor-not-allowed opacity-50 text-[var(--oh-muted)]", + ); +} + +export function gitControlBarActionIconColor(isEnabled: boolean) { + return isEnabled ? "white" : "var(--oh-muted)"; +} + +export const gitControlBarActionLabelClassName = + "font-normal text-sm leading-5 truncate"; diff --git a/src/utils/git-diff-stats.ts b/src/utils/git-diff-stats.ts new file mode 100644 index 0000000000000000000000000000000000000000..df8e896cb13317082be4dfa349b85c0b0343ef74 --- /dev/null +++ b/src/utils/git-diff-stats.ts @@ -0,0 +1,121 @@ +import type { GitChangeDiff } from "#/api/open-hands.types"; + +export interface GitDiffLineStats { + additions: number; + deletions: number; +} + +export function countUnifiedDiffStats(diff: string): { + additions: number; + deletions: number; +} { + let additions = 0; + let deletions = 0; + let inHunk = false; + + for (const line of diff.split("\n")) { + if (line.startsWith("diff --git") || line.startsWith("index ")) { + inHunk = false; + continue; + } + + if (line.startsWith("@@")) { + inHunk = true; + continue; + } + + if (!inHunk) { + if (line.startsWith("---") || line.startsWith("+++")) { + continue; + } + } + + if (inHunk) { + if (line.startsWith("+")) { + additions += 1; + } else if (line.startsWith("-")) { + deletions += 1; + } + } + } + + return { additions, deletions }; +} + +function countTextDiffStats( + original: string, + modified: string, +): GitDiffLineStats { + const originalLines = original.length === 0 ? [] : original.split("\n"); + const modifiedLines = modified.length === 0 ? [] : modified.split("\n"); + + if (originalLines.length === 0) { + return { additions: modifiedLines.length, deletions: 0 }; + } + + if (modifiedLines.length === 0) { + return { additions: 0, deletions: originalLines.length }; + } + + let additions = 0; + let deletions = 0; + let originalIndex = 0; + let modifiedIndex = 0; + + while ( + originalIndex < originalLines.length || + modifiedIndex < modifiedLines.length + ) { + const originalLine = originalLines[originalIndex]; + const modifiedLine = modifiedLines[modifiedIndex]; + + if ( + originalIndex < originalLines.length && + modifiedIndex < modifiedLines.length && + originalLine === modifiedLine + ) { + originalIndex += 1; + modifiedIndex += 1; + continue; + } + + if ( + modifiedIndex < modifiedLines.length && + (originalIndex >= originalLines.length || + !originalLines.slice(originalIndex).includes(modifiedLine)) + ) { + additions += 1; + modifiedIndex += 1; + continue; + } + + if (originalIndex < originalLines.length) { + deletions += 1; + originalIndex += 1; + } + } + + return { additions, deletions }; +} + +export function countGitChangeDiffStats( + diff: GitChangeDiff & { diff?: string }, +): GitDiffLineStats { + if (typeof diff.diff === "string" && diff.diff.length > 0) { + return countUnifiedDiffStats(diff.diff); + } + + return countTextDiffStats(diff.original ?? "", diff.modified ?? ""); +} + +export function sumGitDiffLineStats( + stats: GitDiffLineStats[], +): GitDiffLineStats { + return stats.reduce( + (totals, stat) => ({ + additions: totals.additions + stat.additions, + deletions: totals.deletions + stat.deletions, + }), + { additions: 0, deletions: 0 }, + ); +} diff --git a/src/utils/git-status-mapper.ts b/src/utils/git-status-mapper.ts new file mode 100644 index 0000000000000000000000000000000000000000..44e522867126881486e3e1dee7b3f4634c9dbb7a --- /dev/null +++ b/src/utils/git-status-mapper.ts @@ -0,0 +1,35 @@ +import type { + GitChangeStatus, + AgentServerGitChangeStatus, +} from "#/api/open-hands.types"; + +type ClientGitChangeStatus = "added" | "modified" | "deleted" | "renamed"; + +type SupportedGitStatus = AgentServerGitChangeStatus | ClientGitChangeStatus; + +export function mapAnyGitStatusToClientStatus( + status: SupportedGitStatus, +): GitChangeStatus { + switch (status) { + case "ADDED": + case "added": + return "A"; + case "DELETED": + case "deleted": + return "D"; + case "UPDATED": + case "modified": + return "M"; + case "MOVED": + case "renamed": + return "R"; + default: + return "M"; + } +} + +export function mapAgentServerToClientGitStatus( + agentServerStatus: AgentServerGitChangeStatus, +): GitChangeStatus { + return mapAnyGitStatusToClientStatus(agentServerStatus); +} diff --git a/src/utils/handle-event-for-ui.ts b/src/utils/handle-event-for-ui.ts new file mode 100644 index 0000000000000000000000000000000000000000..250db116351a4d709fbf560a4ec5167a6a85d582 --- /dev/null +++ b/src/utils/handle-event-for-ui.ts @@ -0,0 +1,448 @@ +import { + ActionEvent, + ImageContent, + OpenHandsEvent, + TextContent, +} from "#/types/agent-server/core"; +import { + isACPToolCallEvent, + isActionEvent, + isMessageEvent, + isObservationEvent, + isStreamingDeltaEvent, +} from "#/types/agent-server/type-guards"; +import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event"; +import { + getReasoningContent, + splitInlineThink, +} from "#/components/conversation-events/chat/event-thought-helpers"; + +export const mergeStreamingDeltaEvent = ( + incoming: StreamingDeltaEvent, + existing: StreamingDeltaEvent, +): StreamingDeltaEvent => ({ + ...existing, + content: `${existing.content ?? ""}${incoming.content ?? ""}` || null, + reasoning_content: + `${existing.reasoning_content ?? ""}${incoming.reasoning_content ?? ""}` || + null, +}); + +// Deltas only merge into one bubble when they share a sender; the planning flag +// is the only discriminator (every delta has `source: "agent"`). Prevents a +// planning-agent delta concatenating onto a main-agent one, misattributed (#1656). +export const isSameStreamingSender = ( + a: OpenHandsEvent & { isFromPlanningAgent?: boolean }, + b: OpenHandsEvent & { isFromPlanningAgent?: boolean }, +): boolean => Boolean(a.isFromPlanningAgent) === Boolean(b.isFromPlanningAgent); + +const findLastUserMessageIndex = (events: OpenHandsEvent[]): number => { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (isMessageEvent(event) && event.source === "user") { + return index; + } + } + return -1; +}; + +// Join text blocks WITHOUT a separator: streaming deltas concatenate content +// tokens directly with no separator between LLM content blocks, so using "\n" +// here would cause startsWith/findTextSegmentsInOrder to miss when reconciling +// a multi-block message/thought against the already-rendered streaming delta. +const joinTextBlocks = (blocks: (TextContent | ImageContent)[]): string => + blocks + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); + +const findTextSegmentsInOrder = ( + text: string, + segments: string[], +): { matched: boolean; lastMatchEnd: number } => { + let searchStart = 0; + let lastMatchEnd = 0; + + for (const segment of segments) { + const index = text.indexOf(segment, searchStart); + if (index === -1) { + return { matched: false, lastMatchEnd }; + } + lastMatchEnd = index + segment.length; + searchStart = lastMatchEnd; + } + + return { matched: true, lastMatchEnd }; +}; + +// Content-bearing streaming deltas of the current turn (after the last user +// message) that share the final event's sender. Reasoning-only deltas are +// excluded: reasoning renders in its own collapsed bubble and never overlaps +// the message text being reconciled. Sender scoping matters because the main +// and planning sockets share this event store — without it, one agent's final +// event would strip the other agent's still-live streamed deltas (#1656). +const getCurrentTurnContentDeltas = ( + uiEvents: OpenHandsEvent[], + finalEvent: OpenHandsEvent, +): { event: StreamingDeltaEvent; index: number }[] => { + const lastUserMessageIndex = findLastUserMessageIndex(uiEvents); + return uiEvents + .map((event, index) => ({ event, index })) + .filter( + (item): item is { event: StreamingDeltaEvent; index: number } => + item.index > lastUserMessageIndex && + isStreamingDeltaEvent(item.event) && + (item.event.content?.length ?? 0) > 0 && + isSameStreamingSender(finalEvent, item.event), + ); +}; + +// The current step's streaming delta(s): the trailing run at the end of +// `uiEvents`. Earlier steps' deltas are separated by their observations, so +// this never folds an earlier step's delta into the current one. +const getTrailingDeltas = ( + uiEvents: OpenHandsEvent[], + selects: (event: StreamingDeltaEvent) => boolean, +): { event: StreamingDeltaEvent; index: number }[] => { + const deltas: { event: StreamingDeltaEvent; index: number }[] = []; + for (let index = uiEvents.length - 1; index >= 0; index -= 1) { + const event = uiEvents[index]; + if (!isStreamingDeltaEvent(event)) { + break; + } + if (selects(event)) { + deltas.unshift({ event, index }); + } + } + return deltas; +}; + +// Sender-scoped for the same reason as `getTrailingReasoningDeltas` (#1656): +// a main-agent action must not strip the planning agent's live content. +const getTrailingContentDeltas = ( + uiEvents: OpenHandsEvent[], + finalEvent: OpenHandsEvent, +) => + getTrailingDeltas( + uiEvents, + (event) => + (event.content?.length ?? 0) > 0 && + isSameStreamingSender(finalEvent, event), + ); + +// Sender-scoped: the main and planning sockets share this event store, so a +// main-agent action must not strip the planning agent's live reasoning (#1656). +const getTrailingReasoningDeltas = ( + uiEvents: OpenHandsEvent[], + finalEvent: OpenHandsEvent, +) => + getTrailingDeltas( + uiEvents, + (event) => + Boolean(event.reasoning_content) && + isSameStreamingSender(finalEvent, event), + ); + +// Strip the streamed content deltas, keeping a delta only when it carries +// reasoning the replacement itself won't render (many models stream reasoning +// solely through the delta). Shared by the finalize and intermediate-action +// reconciliation paths. +const supersedeStreamingContent = ( + uiEvents: OpenHandsEvent[], + contentDeltas: { event: StreamingDeltaEvent; index: number }[], + replacementRendersReasoning: boolean, +): OpenHandsEvent[] => { + const indexesToStrip = new Set(contentDeltas.map(({ index }) => index)); + const nextUiEvents: OpenHandsEvent[] = []; + uiEvents.forEach((event, index) => { + if (!indexesToStrip.has(index) || !isStreamingDeltaEvent(event)) { + nextUiEvents.push(event); + return; + } + + // Keep the delta only to render reasoning the replacement itself lacks. + if (!replacementRendersReasoning && event.reasoning_content) { + nextUiEvents.push({ ...event, content: null }); + } + }); + return nextUiEvents; +}; + +// Whether the streamed `segments` (in order) reconcile against `targetText`. +// `lastMatchEnd` is the offset past the matched text, so callers can recover +// any not-yet-streamed suffix. The SDK strips the finalized text, so tolerate +// leading and trailing whitespace that appeared only in the stream. +const matchStreamedSegments = ( + targetText: string, + segments: string[], +): { matched: boolean; lastMatchEnd: number } => { + const streamedText = segments.join(""); + const candidates = new Set([ + streamedText, + streamedText.trimEnd(), + streamedText.trimStart(), + streamedText.trim(), + ]); + for (const candidate of candidates) { + if (candidate && targetText.startsWith(candidate)) { + return { matched: true, lastMatchEnd: candidate.length }; + } + } + // Segments may be interleaved with not-yet-streamed text; locate them in + // order, trimming the last segment's trailing whitespace. + const lastIndex = segments.length - 1; + const searchSegments = segments.map((segment, index) => + index === lastIndex ? segment.trimEnd() : segment, + ); + return findTextSegmentsInOrder(targetText, searchSegments); +}; + +// A ` + segments.some((segment) => segment.includes(" block in its content. Decides if a replaced delta's reasoning must +// be preserved separately. +const eventRendersReasoning = (event: OpenHandsEvent): boolean => { + if (isActionEvent(event)) { + return getReasoningContent(event).trim().length > 0; + } + + if (isMessageEvent(event) && event.source === "agent") { + return ( + splitInlineThink(joinTextBlocks(event.llm_message.content)).reasoning + .length > 0 + ); + } + + return false; +}; + +// The final MessageEvent/FinishAction is authoritative for the turn's text. Drop +// the provisional streamed deltas and render the canonical final event instead, +// so the message is rendered exactly once (never holey or duplicated) and its +// metadata — critic_result, activated_skills — renders too. Stream-only +// reasoning is preserved. Returns null when there is no streamed content to +// reconcile, leaving the caller to append the final event normally. +const finalizeStreamingDeltasInPlace = ( + finalEvent: OpenHandsEvent, + uiEvents: OpenHandsEvent[], +): OpenHandsEvent[] | null => { + const contentStreamingDeltas = getCurrentTurnContentDeltas( + uiEvents, + finalEvent, + ); + if (contentStreamingDeltas.length === 0) { + return null; + } + + const nextUiEvents = supersedeStreamingContent( + uiEvents, + contentStreamingDeltas, + eventRendersReasoning(finalEvent), + ); + nextUiEvents.push(finalEvent); + return nextUiEvents; +}; + +/** + * Reconcile the current turn's streaming delta when an intermediate + * (tool-calling) `ActionEvent` arrives. With `stream=true` the step's + * pre-tool-call text is streamed as delta `content`, then the action's + * `thought` repeats it and the chat hoists that into its own message (see + * `group-events.ts`), so the text would render twice (issue #1534). + * + * The action must stay because it owns the tool call. The streamed text is + * cleared from the delta, and the delta is kept only to carry reasoning the + * action itself lacks (for many models the delta is the sole reasoning + * carrier), otherwise dropped. + * + * Only the current step's trailing delta run is considered. Returns the updated + * array, or `null` when there is nothing to reconcile. + */ +const supersedeStreamedThoughtWithAction = ( + action: ActionEvent, + uiEvents: OpenHandsEvent[], +): OpenHandsEvent[] | null => { + const thoughtText = joinTextBlocks(action.thought); + if (!thoughtText) { + return null; + } + + const contentDeltas = getTrailingContentDeltas(uiEvents, action); + if (contentDeltas.length === 0) { + return null; + } + + const streamingSegments = contentDeltas.map( + ({ event }) => event.content ?? "", + ); + + // Strip on a thought match, or on an unstripped `` marker whose + // streamed text is a superset of `thought` that the match can't reconcile. + const matchedThought = matchStreamedSegments( + thoughtText, + streamingSegments, + ).matched; + if (!matchedThought && !hasUnstrippedFunctionCallMarker(streamingSegments)) { + return null; + } + + // Keeping the delta's reasoning would duplicate the action's own "Thinking". + return supersedeStreamingContent( + uiEvents, + contentDeltas, + getReasoningContent(action).trim().length > 0, + ); +}; + +// Drop the current step's streamed reasoning when the action renders that +// reasoning itself, so "Thinking" renders once. Unlike +// `supersedeStreamedThoughtWithAction` this does not require the streamed text +// to match the thought, which models streaming reasoning-only deltas never do. +const supersedeStreamedReasoningWithAction = ( + action: ActionEvent, + uiEvents: OpenHandsEvent[], +): OpenHandsEvent[] | null => { + if (getReasoningContent(action).trim().length === 0) { + return null; + } + + const reasoningDeltas = getTrailingReasoningDeltas(uiEvents, action); + if (reasoningDeltas.length === 0) { + return null; + } + + const indexesToStrip = new Set(reasoningDeltas.map(({ index }) => index)); + const nextUiEvents: OpenHandsEvent[] = []; + uiEvents.forEach((event, index) => { + if (!indexesToStrip.has(index) || !isStreamingDeltaEvent(event)) { + nextUiEvents.push(event); + return; + } + + // Keep the delta only for streamed text the action itself lacks. + if (event.content) { + nextUiEvents.push({ ...event, reasoning_content: null }); + } + }); + return nextUiEvents; +}; + +/** + * Handles adding an event to the UI events array + * Replaces actions with observations when they arrive (so UI shows observation instead of action) + * Exception: ThinkAction is NOT replaced because the thought content is in the action, not in the observation + * + * ACPToolCallEvent merge: the SDK emits two events per ``tool_call_id`` — an + * early ``started`` event (``pending`` / ``in_progress``) and one terminal + * (completed / failed) event, the action->observation pair for a tool call. + * Replace the started entry in place with the terminal one so a single card + * updates from running to its result, exactly like an observation superseding + * its action below. + */ +export const handleEventForUI = ( + event: OpenHandsEvent, + uiEvents: OpenHandsEvent[], +): OpenHandsEvent[] => { + const newUiEvents = [...uiEvents]; + + if (isStreamingDeltaEvent(event)) { + if (event.content === null && event.reasoning_content === null) { + return newUiEvents; + } + + const lastIndex = newUiEvents.length - 1; + const lastEvent = newUiEvents[lastIndex]; + if ( + lastEvent && + isStreamingDeltaEvent(lastEvent) && + isSameStreamingSender(event, lastEvent) + ) { + newUiEvents[lastIndex] = mergeStreamingDeltaEvent(event, lastEvent); + return newUiEvents; + } + + newUiEvents.push(event); + return newUiEvents; + } + + if ( + (isActionEvent(event) && event.action.kind === "FinishAction") || + (isMessageEvent(event) && event.source === "agent") + ) { + const finalizedUiEvents = finalizeStreamingDeltasInPlace( + event, + newUiEvents, + ); + if (finalizedUiEvents) { + return finalizedUiEvents; + } + } + + // Intermediate tool-calling action whose thought was streamed: clear the + // duplicated text from the delta (issue #1534). ThinkAction is excluded — its + // thought renders through its own collapsible, not a hoisted thought. + if ( + isActionEvent(event) && + event.action.kind !== "FinishAction" && + event.action.kind !== "ThinkAction" + ) { + const reconciledUiEvents = + supersedeStreamedThoughtWithAction(event, newUiEvents) ?? + supersedeStreamedReasoningWithAction(event, newUiEvents); + if (reconciledUiEvents) { + reconciledUiEvents.push(event); + return reconciledUiEvents; + } + } + + if (isACPToolCallEvent(event)) { + const existingIndex = newUiEvents.findIndex( + (uiEvent) => + isACPToolCallEvent(uiEvent) && + uiEvent.tool_call_id === event.tool_call_id, + ); + if (existingIndex !== -1) { + newUiEvents[existingIndex] = event; + } else { + newUiEvents.push(event); + } + return newUiEvents; + } + + if (isObservationEvent(event)) { + // Don't add ThinkObservation at all - we keep the ThinkAction instead + // The thought content is in the action, not the observation + if (event.observation.kind === "ThinkObservation") { + return newUiEvents; + } + + // Don't add FinishObservation at all - we keep the FinishAction instead + // Both contain the same message content, so we only need to display one + // This also prevents duplicate messages when events arrive out of order due to React batching + if (event.observation.kind === "FinishObservation") { + return newUiEvents; + } + + // Find and replace the corresponding action from uiEvents + const actionIndex = newUiEvents.findIndex( + (uiEvent) => uiEvent.id === event.action_id, + ); + if (actionIndex !== -1) { + newUiEvents[actionIndex] = event; + } else { + // Action not found in uiEvents, just add the observation + newUiEvents.push(event); + } + } else { + // For non-observation events, just add them to uiEvents + newUiEvents.push(event); + } + + return newUiEvents; +}; diff --git a/src/utils/hover-reveal-classes.ts b/src/utils/hover-reveal-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8ac8f5ccddc50e0ba8a56559d360437490b839a --- /dev/null +++ b/src/utils/hover-reveal-classes.ts @@ -0,0 +1,94 @@ +import { cn } from "#/utils/utils"; + +/** + * Full Tailwind candidates for fine-hover media — devices where CSS `:hover` + * is a reliable primary interaction (mouse/trackpad). Coarse-pointer / + * touch-primary devices match the inverse and keep overflow actions always + * visible + clickable. Kept as complete string literals so the production CSS + * scanner can emit the arbitrary-variant rules (dynamic prefix concatenation + * is not discoverable). + */ +export const FINE_HOVER_ACTION_CLASSES = [ + "[@media(hover:hover)_and_(pointer:fine)]:pointer-events-none", + "[@media(hover:hover)_and_(pointer:fine)]:invisible", + "[@media(hover:hover)_and_(pointer:fine)]:opacity-0", + "[@media(hover:hover)_and_(pointer:fine)]:group-hover:pointer-events-auto", + "[@media(hover:hover)_and_(pointer:fine)]:group-hover:visible", + "[@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100", + "[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:pointer-events-auto", + "[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:visible", + "[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:opacity-100", +] as const; + +export const FINE_HOVER_YIELD_CLASSES = [ + "[@media(hover:hover)_and_(pointer:fine)]:opacity-100", + "[@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-0", + "[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:opacity-0", +] as const; + +export const FINE_HOVER_RESERVE_CLASSES = [ + "[@media(hover:hover)_and_(pointer:fine)]:min-w-0", + "[@media(hover:hover)_and_(pointer:fine)]:group-hover:min-w-[3.75rem]", + "[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:min-w-[3.75rem]", +] as const; + +export const FINE_HOVER_PINNED_TIMESTAMP_CLASSES = [ + "[@media(hover:hover)_and_(pointer:fine)]:flex", + "[@media(hover:hover)_and_(pointer:fine)]:group-hover:hidden", + "[@media(hover:hover)_and_(pointer:fine)]:group-focus-within:hidden", +] as const; + +/** + * Overlay action chrome (ellipsis, pin, etc.): always interactable on touch; + * hover/focus-reveal only on fine-pointer hover devices. + */ +export function hoverRevealActionClassName(forceVisible = false): string { + if (forceVisible) { + return "pointer-events-auto visible opacity-100"; + } + + return cn( + "pointer-events-auto visible opacity-100", + ...FINE_HOVER_ACTION_CLASSES, + ); +} + +/** + * Companion for timestamps that yield space to hover-reveal actions: + * hidden on touch (actions stay visible); on fine-pointer devices, visible + * until the row is hovered / focused / menu-open. + */ +export function hoverRevealYieldClassName(forceHidden = false): string { + if (forceHidden) { + return "opacity-0"; + } + + return cn("opacity-0", ...FINE_HOVER_YIELD_CLASSES); +} + +/** + * Reserve trailing space for hover-reveal actions. Always reserved on touch; + * on fine-pointer devices, reserved on hover / focus / open. + */ +export function hoverRevealReserveClassName(forceReserved = false): string { + if (forceReserved) { + return "min-w-[3.75rem]"; + } + + return cn("min-w-[3.75rem]", ...FINE_HOVER_RESERVE_CLASSES); +} + +/** + * Absolute-positioned timestamp that sits under a pinned-card ellipsis slot: + * shown at rest on fine-pointer devices, hidden when the row reveals actions + * (or when the menu is open / on touch where the ellipsis stays visible). + */ +export function hoverRevealPinnedTimestampClassName( + forceHidden = false, +): string { + if (forceHidden) { + return "hidden"; + } + + return cn("hidden", ...FINE_HOVER_PINNED_TIMESTAMP_CLASSES); +} diff --git a/src/utils/is-file-image.ts b/src/utils/is-file-image.ts new file mode 100644 index 0000000000000000000000000000000000000000..c72c485e97a389daf1f9b671c9d99a9d41cfbe87 --- /dev/null +++ b/src/utils/is-file-image.ts @@ -0,0 +1,7 @@ +/** + * Check if a file is an image. + * @param file - The File object to check. + * @returns True if the file is an image, false otherwise. + */ +export const isFileImage = (file: File): boolean => + file.type.startsWith("image/"); diff --git a/src/utils/llm-subscription-schema.ts b/src/utils/llm-subscription-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..f599164afec64deb2bd9b558440af9d8be6f5b45 --- /dev/null +++ b/src/utils/llm-subscription-schema.ts @@ -0,0 +1,30 @@ +import type { SettingsSchema } from "#/types/settings"; +import { LLM_SUBSCRIPTION_SCHEMA_FIELDS } from "#/constants/llm-subscription"; + +const LLM_SECTION_KEY = "llm"; + +export function withLlmSubscriptionSchemaFields( + schema: SettingsSchema | null | undefined, +): SettingsSchema | null | undefined { + if (!schema?.sections) return schema; + + let changed = false; + const sections = schema.sections.map((section) => { + if (section.key !== LLM_SECTION_KEY) return section; + + const existingKeys = new Set(section.fields.map((field) => field.key)); + const missingFields = LLM_SUBSCRIPTION_SCHEMA_FIELDS.filter( + (field) => !existingKeys.has(field.key), + ); + + if (missingFields.length === 0) return section; + changed = true; + return { + ...section, + fields: [...section.fields, ...missingFields], + }; + }); + + if (!changed) return schema; + return { ...schema, sections }; +} diff --git a/src/utils/map-provider.ts b/src/utils/map-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..d68a8e72ca0016a3443f5176552e6b5b122e0723 --- /dev/null +++ b/src/utils/map-provider.ts @@ -0,0 +1,42 @@ +// These are provider names, not user-facing text +export const MAP_PROVIDER = { + openai: "OpenAI", + azure: "Azure", + azure_ai: "Azure AI Studio", + vertex_ai: "VertexAI", + palm: "PaLM", + gemini: "Gemini", + anthropic: "Anthropic", + sagemaker: "AWS SageMaker", + bedrock: "AWS Bedrock", + mistral: "Mistral AI", + anyscale: "Anyscale", + databricks: "Databricks", + ollama: "Ollama", + perlexity: "Perplexity AI", + friendliai: "FriendliAI", + groq: "Groq", + fireworks_ai: "Fireworks AI", + cloudflare: "Cloudflare Workers AI", + deepinfra: "DeepInfra", + ai21: "AI21", + replicate: "Replicate", + voyage: "Voyage AI", + openrouter: "OpenRouter", + openhands: "OpenHands", + lemonade: "Lemonade", + clarifai: "Clarifai", + moonshot: "Moonshot", +}; + +export const mapProvider = (provider: string) => + Object.keys(MAP_PROVIDER).includes(provider) + ? MAP_PROVIDER[provider as keyof typeof MAP_PROVIDER] + : provider; + +export const getProviderId = (displayName: string): string => { + const entry = Object.entries(MAP_PROVIDER).find( + ([, value]) => value === displayName, + ); + return entry ? entry[0] : displayName; +}; diff --git a/src/utils/mcp-credential-validation.ts b/src/utils/mcp-credential-validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1558fe366f9e8a7c6a26a9754a895ae1bfb9387 --- /dev/null +++ b/src/utils/mcp-credential-validation.ts @@ -0,0 +1,99 @@ +import { INTEGRATION_CATALOG as MCP_MARKETPLACE } from "@openhands/extensions/integrations"; +import { + findCatalogEntryForServer, + getMcpMarketplaceCatalog, +} from "#/utils/mcp-marketplace-utils"; +import type { + MCPServerConfig, + MCPTestToolCall, + MCPTestToolResult, +} from "#/types/mcp-server"; + +/** + * Credential validation specs for marketplace MCP servers whose credentials + * are only exercised on tool invocation (listing tools succeeds with any + * credentials). The test endpoint runs `toolCall` after listing and reports + * the outcome verbatim; `interpret` decides whether that outcome proves the + * credentials are invalid. + * + * `toolCall` MUST be read-only — it runs on every "Test connection" / + * pre-save validation. + */ +export interface CredentialValidation { + toolCall: MCPTestToolCall; + /** Returns the provider's error code/message, or null when creds pass. */ + interpret: (toolResult: MCPTestToolResult) => string | null; +} + +/** + * Slack error codes that mean the credentials themselves are bad. Anything + * else (e.g. `missing_scope`) proves the token *authenticated* — a valid + * token lacking a scope must not be reported as invalid credentials. + */ +const SLACK_AUTH_FAILURES = new Set([ + "invalid_auth", + "not_authed", + "account_inactive", + "token_revoked", + "token_expired", +]); + +/** + * Interpreter for hosted HTTP API servers (GitHub, Linear). The service only + * calls `interpret` when the probe tool was advertised by the server AND + * invoked, so `is_error` here means the read-only call itself failed — for a + * "who am I" / "list teams" probe that is an auth/permission failure. + */ +const interpretReadOnlyToolResult = ( + toolResult: MCPTestToolResult, +): string | null => + toolResult.is_error ? toolResult.text || "tool call failed" : null; + +const VALIDATION_BY_ENTRY_ID: Record = { + github: { + // Official GitHub MCP server (api.githubcopilot.com): read-only + // "get details of the authenticated user". + toolCall: { name: "get_me", arguments: {} }, + interpret: interpretReadOnlyToolResult, + }, + linear: { + // Linear hosted MCP (mcp.linear.app): read-only teams listing with no + // required arguments. + toolCall: { name: "list_teams", arguments: {} }, + interpret: interpretReadOnlyToolResult, + }, + slack: { + // Read-only: lists at most one channel. The Slack MCP server returns + // the raw Slack API JSON ({ok: boolean, error?: string}) as text. + toolCall: { name: "slack_list_channels", arguments: { limit: 1 } }, + interpret: (toolResult) => { + if (toolResult.is_error) { + return toolResult.text || "tool call failed"; + } + try { + const parsed = JSON.parse(toolResult.text); + if (parsed?.ok === false && SLACK_AUTH_FAILURES.has(parsed.error)) { + return String(parsed.error); + } + } catch { + // Non-JSON payload — make no claim about the credentials. + } + return null; + }, + }, +}; + +/** + * Look up the credential validation for a server by matching it to its + * marketplace catalog entry (same matching the MCP page uses for icons). + * Returns undefined for custom servers — their test behaves as before. + */ +export function getCredentialValidationForServer( + server: MCPServerConfig, +): CredentialValidation | undefined { + const entry = findCatalogEntryForServer( + server, + getMcpMarketplaceCatalog(MCP_MARKETPLACE), + ); + return entry ? VALIDATION_BY_ENTRY_ID[entry.id] : undefined; +} diff --git a/src/utils/mcp-installed-server-display.test.ts b/src/utils/mcp-installed-server-display.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7292ff9fd19cea6bc4d29f0cece3b8dd34c0193 --- /dev/null +++ b/src/utils/mcp-installed-server-display.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { IntegrationCatalogEntry as MarketplaceEntry } from "@openhands/extensions/integrations"; +import type { MCPServerConfig } from "#/types/mcp-server"; +import { getInstalledServerTitle } from "./mcp-installed-server-display"; + +const githubCatalog = { + id: "github", + name: "GitHub", +} as MarketplaceEntry; + +describe("getInstalledServerTitle", () => { + it("uses a configured remote server name as the installed card title", () => { + const server: MCPServerConfig = { + id: "shttp-0", + type: "shttp", + name: "work_github", + url: "https://api.githubcopilot.com/mcp/", + }; + + expect(getInstalledServerTitle(server, githubCatalog)).toBe("work_github"); + }); + + it("uses the catalog title when the stored name is only the marketplace slug", () => { + const server: MCPServerConfig = { + id: "shttp-0", + type: "shttp", + name: "github", + url: "https://api.githubcopilot.com/mcp/", + }; + + expect(getInstalledServerTitle(server, githubCatalog)).toBe("GitHub"); + }); + + it("falls back to the URL for unnamed remote servers", () => { + const server: MCPServerConfig = { + id: "shttp-0", + type: "shttp", + url: "https://example.com/mcp", + }; + + expect(getInstalledServerTitle(server)).toBe("https://example.com/mcp"); + }); + + it("falls back to the command for unnamed stdio servers", () => { + const server: MCPServerConfig = { + id: "stdio-0", + type: "stdio", + command: "npx", + args: ["-y", "some-mcp"], + }; + + expect(getInstalledServerTitle(server)).toBe("npx"); + }); + + it("trims surrounding whitespace so a stored name equal to the catalog slug falls back to the catalog title", () => { + const server: MCPServerConfig = { + id: "shttp-0", + type: "shttp", + name: " github ", + url: "https://api.githubcopilot.com/mcp/", + }; + + expect(getInstalledServerTitle(server, githubCatalog)).toBe("GitHub"); + }); + + it("treats a whitespace-only name as no name and falls back to the URL", () => { + const server: MCPServerConfig = { + id: "shttp-0", + type: "shttp", + name: " ", + url: "https://example.com/mcp", + }; + + expect(getInstalledServerTitle(server)).toBe("https://example.com/mcp"); + }); +}); diff --git a/src/utils/mcp-installed-server-display.ts b/src/utils/mcp-installed-server-display.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9951c1a5a9d1f209a40f803754546fdc8625466 --- /dev/null +++ b/src/utils/mcp-installed-server-display.ts @@ -0,0 +1,32 @@ +import type { IntegrationCatalogEntry as MarketplaceEntry } from "@openhands/extensions/integrations"; +import type { MCPServerConfig } from "#/types/mcp-server"; + +function configuredServerName(server: MCPServerConfig): string | undefined { + const name = server.name?.trim(); + return name ? name : undefined; +} + +/** + * Marketplace installs persist the catalog slug as the MCP server name so the + * backend has a stable reference key. Treat that implicit slug as metadata, + * while still honoring names the user entered explicitly. + */ +export function getInstalledServerTitle( + server: MCPServerConfig, + catalog?: MarketplaceEntry, +): string { + const name = configuredServerName(server); + if (name && name !== catalog?.id) { + return name; + } + + if (catalog?.name) { + return catalog.name; + } + + if (server.type === "stdio") { + return name ?? server.command ?? ""; + } + + return server.url ?? ""; +} diff --git a/src/utils/mcp-installed-servers.ts b/src/utils/mcp-installed-servers.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcee63ba66e4f67220d91b199b7b97f38bbf320c --- /dev/null +++ b/src/utils/mcp-installed-servers.ts @@ -0,0 +1,28 @@ +import type { MCPConfig } from "@openhands/typescript-client"; +import type { MCPServerConfig } from "#/types/mcp-server"; +import { getMcpServerEnabled } from "./mcp-config"; + +export function flattenMcpConfig(config: MCPConfig): MCPServerConfig[] { + return Object.entries(config).map(([settingsKey, server]) => + server.transport === "stdio" + ? { + id: settingsKey, + type: "stdio", + name: settingsKey, + command: server.command, + args: server.args ?? undefined, + env: server.env ?? undefined, + enabled: getMcpServerEnabled(server), + } + : { + id: settingsKey, + type: server.transport === "sse" ? "sse" : "shttp", + name: settingsKey, + url: server.url, + headers: server.headers ?? undefined, + timeout: server.timeout ?? undefined, + auth: server.auth ?? undefined, + enabled: getMcpServerEnabled(server), + }, + ); +} diff --git a/src/utils/mcp-marketplace-utils.ts b/src/utils/mcp-marketplace-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..7452ff1304bafb8b6eeb76d092e5960a0d607474 --- /dev/null +++ b/src/utils/mcp-marketplace-utils.ts @@ -0,0 +1,302 @@ +import { MCPServerConfig } from "#/types/mcp-server"; +import type { + MCPAuthenticationConfig, + MCPOAuthClientAuthMethod, +} from "#/types/mcp-auth"; +import type { + IntegrationAuthConfig, + IntegrationCatalogEntry as MarketplaceEntry, + IntegrationConnectionOption, + IntegrationOAuthConfig, + IntegrationTransport, +} from "@openhands/extensions/integrations"; + +export type { MarketplaceEntry }; + +type McpIntegrationAuthConfig = IntegrationAuthConfig & { + credentialSecretName?: string; + saveCredentialAsSecretByDefault?: boolean; +}; + +export type McpMarketplaceConnectionOption = Omit< + IntegrationConnectionOption, + "auth" | "provider" | "transport" +> & { + provider: "mcp"; + transport: IntegrationTransport; + auth: McpIntegrationAuthConfig; +}; + +export function getMcpConnectionOptions( + entry: MarketplaceEntry, +): McpMarketplaceConnectionOption[] { + return entry.connectionOptions.filter( + (option): option is McpMarketplaceConnectionOption => + option.provider === "mcp" && !!option.transport, + ); +} + +export function getDefaultMcpConnectionOption( + entry: MarketplaceEntry, +): McpMarketplaceConnectionOption | undefined { + return getMcpConnectionOptions(entry)[0]; +} + +function isLocallyInstallableMcpOption( + option: McpMarketplaceConnectionOption, +): boolean { + if (option.auth.strategy !== "oauth2") return true; + + const oauth = option.auth.oauth; + if (!oauth) return false; + + // Local agent-server installs only support OAuth flows initiated by the MCP + // server itself through fastmcp. Catalog entries that specify provider OAuth + // endpoints still need the hosted integration-auth flow and should not be + // exposed as locally installable static MCP configs. + return ( + !oauth.authorizationUrl && + !oauth.tokenUrl && + !oauth.registrationUrl && + !oauth.additionalAuthorizationParams && + !oauth.additionalTokenParams + ); +} + +export function getInstallableMcpConnectionOption( + entry: MarketplaceEntry, +): McpMarketplaceConnectionOption | undefined { + return getMcpConnectionOptions(entry).find(isLocallyInstallableMcpOption); +} + +export function getDefaultMcpTransport( + entry: MarketplaceEntry, +): IntegrationTransport | undefined { + return getDefaultMcpConnectionOption(entry)?.transport; +} + +function toMcpOAuthClientAuthMethod( + value: IntegrationOAuthConfig["clientAuthentication"] | undefined, +): MCPOAuthClientAuthMethod | undefined { + switch (value) { + case "none": + return "none"; + case "body": + return "client_secret_post"; + case "basic": + return "client_secret_basic"; + default: + return undefined; + } +} + +export function getMcpOAuthAuthenticationConfig( + option: McpMarketplaceConnectionOption, +): MCPAuthenticationConfig | undefined { + if (option.auth.strategy !== "oauth2") return undefined; + const authentication: MCPAuthenticationConfig = { type: "oauth" }; + const clientAuthMethod = toMcpOAuthClientAuthMethod( + option.auth.oauth?.clientAuthentication, + ); + if (clientAuthMethod) { + authentication.client_auth_method = clientAuthMethod; + } + if (option.auth.oauth?.scopes?.length) { + authentication.scopes = option.auth.oauth.scopes; + } + return Object.keys(authentication).length > 1 ? authentication : undefined; +} + +export function getMcpMarketplaceCatalog( + catalog: MarketplaceEntry[], +): MarketplaceEntry[] { + return catalog.filter(isMcpInstallableEntry); +} + +/** + * Whether this backend can install/configure the entry as an MCP server. + * Entries whose only connection options use another provider or transport + * (e.g. Jira's HTTP/OpenAPI option) exist in the catalog but cannot go + * through the local MCP install flow. + */ +export function isMcpInstallableEntry(entry: MarketplaceEntry): boolean { + return !!getDefaultMcpConnectionOption(entry); +} + +const tryUrl = (raw: string): URL | null => { + try { + return new URL(raw); + } catch { + return null; + } +}; + +/** + * Loose URL match that ignores query strings, trailing slashes, and + * default ports. We want clicking "Linear" to flag the entry as + * installed even if the user pasted the URL with extra trailing slash + * or a different port-equivalent variant. + * + * Defensive against runtime data that doesn't match the static type: + * if either input is not a string (e.g. parsed from an older settings + * blob), we fall through the URL parsing path and the safe trim + * fallback below, never calling `.replace` on undefined. + */ +export function urlsMatch(a: unknown, b: unknown): boolean { + const aStr = typeof a === "string" ? a : ""; + const bStr = typeof b === "string" ? b : ""; + if (!aStr || !bStr) return false; + const left = tryUrl(aStr); + const right = tryUrl(bStr); + if (!left || !right) { + return aStr.replace(/\/+$/, "") === bStr.replace(/\/+$/, ""); + } + return ( + left.protocol === right.protocol && + left.host === right.host && + left.pathname.replace(/\/+$/, "") === right.pathname.replace(/\/+$/, "") + ); +} + +/** + * Decide whether a marketplace template is already represented by one + * of the installed MCP servers. Used to render an "Installed" badge on + * the marketplace tile. Returns the first matching server, or null. + */ +export function findInstalledMatch( + transport: IntegrationTransport, + servers: MCPServerConfig[], +): MCPServerConfig | null { + return ( + servers.find((server) => transportMatchesServer(transport, server)) ?? null + ); +} + +export function findInstalledEntryMatch( + entry: MarketplaceEntry, + servers: MCPServerConfig[], +): MCPServerConfig | null { + for (const option of getMcpConnectionOptions(entry)) { + const match = findInstalledMatch(option.transport, servers); + if (match) return match; + } + return null; +} + +function transportMatchesServer( + transport: IntegrationTransport, + server: MCPServerConfig, +): boolean { + if (transport.kind === "shttp") { + const tplUrl = transport.url; + return ( + server.type === "shttp" && !!server.url && urlsMatch(server.url, tplUrl) + ); + } + + if (transport.kind === "sse") { + const tplUrl = transport.url; + return ( + server.type === "sse" && !!server.url && urlsMatch(server.url, tplUrl) + ); + } + + // stdio: match on the registered server name. + return server.type === "stdio" && server.name === transport.serverName; +} + +function normalize(query: string): string { + return query.trim().toLowerCase(); +} + +/** + * Case-insensitive substring match against the catalog entry's + * user-visible identity (name, description, id, keywords). Empty + * queries always match. + */ +export function getMarketplaceEntriesByPopularity( + catalog: MarketplaceEntry[], +): MarketplaceEntry[] { + return catalog + .map((entry, index) => ({ entry, index })) + .sort((a, b) => { + const byPopularity = + (b.entry.popularityRank ?? 0) - (a.entry.popularityRank ?? 0); + return byPopularity || a.index - b.index; + }) + .map(({ entry }) => entry); +} + +export function getMarketplaceEntryById( + id: string, + catalog: MarketplaceEntry[], +): MarketplaceEntry | undefined { + return catalog.find((entry) => entry.id === id); +} + +export function marketplaceEntryMatchesQuery( + entry: MarketplaceEntry, + rawQuery: string, +): boolean { + const q = normalize(rawQuery); + if (!q) return true; + const haystack = [ + entry.name, + entry.description, + entry.id, + ...(entry.keywords ?? []), + ] + .join(" ") + .toLowerCase(); + return haystack.includes(q); +} + +/** + * Search match for an installed (already-configured) server. We + * search the server's own identifying fields and — if it's a catalog + * entry — its catalog name/keywords too, so typing "Slack" matches + * the installed Slack tile even though the persisted server is just + * `{ type: "stdio", name: "slack", ... }`. + */ +export function installedServerMatchesQuery( + server: MCPServerConfig, + catalogEntry: MarketplaceEntry | undefined, + rawQuery: string, +): boolean { + const q = normalize(rawQuery); + if (!q) return true; + const haystack = [ + server.type, + "name" in server ? server.name : undefined, + "command" in server ? server.command : undefined, + "args" in server ? server.args?.join(" ") : undefined, + "url" in server ? server.url : undefined, + catalogEntry?.name, + catalogEntry?.description, + catalogEntry?.id, + ...(catalogEntry?.keywords ?? []), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(q); +} + +/** + * Look up the catalog entry that best matches an installed server. + * Mirrors the lookup used in `installed-server-card.tsx` for + * rendering the friendly icon. + */ +export function findCatalogEntryForServer( + server: MCPServerConfig, + catalog: MarketplaceEntry[], +): MarketplaceEntry | undefined { + return catalog.find((entry) => { + // Check every MCP option rather than only the default. Some unified + // integration entries default to OAuth-hosted MCP while still exposing + // an API/stdio option; existing installed servers should match either. + return getMcpConnectionOptions(entry).some((option) => + transportMatchesServer(option.transport, server), + ); + }); +} diff --git a/src/utils/mcp-server-health-key.ts b/src/utils/mcp-server-health-key.ts new file mode 100644 index 0000000000000000000000000000000000000000..be224dfdeb246cb0e9c51a16517c6c7bc698fa78 --- /dev/null +++ b/src/utils/mcp-server-health-key.ts @@ -0,0 +1,41 @@ +import type { MCPServerConfig } from "#/types/mcp-server"; + +const sortedKeys = (record?: Record | null): string[] => + Object.keys(record ?? {}).sort(); + +/** + * Stable identity for a server's health entry. + * + * Built from the server's structural, non-secret fields. Names are included + * because persisted configs are keyed by name, which makes the key unique per + * stored server and stable across list reordering. Secret VALUES are excluded: + * the same credential legitimately appears as plaintext at install time, + * `**********` in redacted settings, and ciphertext in test requests, so any + * of them would make the key flap. A structural edit (URL, command, header + * names, auth strategy, ...) therefore produces a new key, orphaning the old + * health entry instead of misattributing it. + */ +export function getMcpServerHealthKey(server: MCPServerConfig): string { + if (server.type === "stdio") { + return JSON.stringify({ + type: server.type, + name: server.name ?? "", + command: server.command ?? "", + args: server.args ?? [], + envKeys: sortedKeys(server.env), + }); + } + return JSON.stringify({ + type: server.type, + name: server.name ?? "", + url: server.url ?? "", + headerKeys: sortedKeys(server.headers), + authStrategy: server.auth?.strategy ?? "", + authHeaderName: + server.auth?.strategy === "api_key" + ? (server.auth.header_name ?? "") + : "", + authHeaderKeys: + server.auth?.strategy === "header" ? sortedKeys(server.auth.headers) : [], + }); +} diff --git a/src/utils/mcp-test-error-message.ts b/src/utils/mcp-test-error-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..18e1f15440b0aca74e5dc933d44c1a680614a56d --- /dev/null +++ b/src/utils/mcp-test-error-message.ts @@ -0,0 +1,25 @@ +import type { TFunction } from "i18next"; +import { I18nKey } from "#/i18n/declaration"; +import type { ExtendedMCPTestFailureKind } from "#/types/mcp-server"; + +/** + * Kind-specific, localized guidance for a failed MCP connection test. + * `error` is interpolated for the kinds whose message surfaces the provider + * detail — callers pass display-safe (redacted) text. + */ +export function makeMcpTestErrorMessage( + t: TFunction<"openhands">, + errorKind: ExtendedMCPTestFailureKind, + error: string, +): string { + switch (errorKind) { + case "timeout": + return t(I18nKey.MCP$TEST_ERROR_TIMEOUT); + case "connection": + return t(I18nKey.MCP$TEST_ERROR_CONNECTION); + case "credentials": + return t(I18nKey.MCP$TEST_ERROR_CREDENTIALS, { error }); + default: + return t(I18nKey.MCP$TEST_ERROR_UNKNOWN, { error }); + } +} diff --git a/src/utils/mobile-section-nav.ts b/src/utils/mobile-section-nav.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbbe756b7ef796bba229b319f681972315108d81 --- /dev/null +++ b/src/utils/mobile-section-nav.ts @@ -0,0 +1,62 @@ +import { I18nKey } from "#/i18n/declaration"; + +const SETTINGS_PREFIX = "/settings"; +const CUSTOMIZE_HUB = "/customize"; +const EXTENSIONS_DETAIL_PATHS = ["/skills", "/mcp", "/plugins"] as const; +// Exact match only: /extensions/:name/* routes are extension pages, which are +// rail-level destinations rather than Customize details. +const APPS_MANAGEMENT_PATH = "/apps"; + +export type MobileTopBarMode = "menu" | "back"; + +export interface MobileTopBarState { + mode: MobileTopBarMode; + backTo?: string; + backLabelKey?: I18nKey; +} + +export function getMobileTopBarState(pathname: string): MobileTopBarState { + if (pathname === SETTINGS_PREFIX) { + return { mode: "menu" }; + } + + if ( + pathname.startsWith(`${SETTINGS_PREFIX}/`) && + pathname.length > SETTINGS_PREFIX.length + ) { + return { + mode: "back", + backTo: SETTINGS_PREFIX, + backLabelKey: I18nKey.SETTINGS$TITLE, + }; + } + + if (pathname === CUSTOMIZE_HUB) { + return { mode: "menu" }; + } + + if ( + pathname === APPS_MANAGEMENT_PATH || + EXTENSIONS_DETAIL_PATHS.some( + (path) => pathname === path || pathname.startsWith(`${path}/`), + ) + ) { + return { + mode: "back", + backTo: CUSTOMIZE_HUB, + backLabelKey: I18nKey.NAV$CUSTOMIZE, + }; + } + + return { mode: "menu" }; +} + +export function isExtensionsSectionPath(pathname: string): boolean { + return ( + pathname === CUSTOMIZE_HUB || + pathname === APPS_MANAGEMENT_PATH || + EXTENSIONS_DETAIL_PATHS.some( + (path) => pathname === path || pathname.startsWith(`${path}/`), + ) + ); +} diff --git a/src/utils/mobile-top-bar-icon-button-classes.ts b/src/utils/mobile-top-bar-icon-button-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..92a7054c3c0ed2702ad462d6d375d2c9b398ab66 --- /dev/null +++ b/src/utils/mobile-top-bar-icon-button-classes.ts @@ -0,0 +1,20 @@ +import { cn } from "#/utils/utils"; +import { + formControlTransitionClassName, + formControlMutedHoverClassName, +} from "#/utils/form-control-classes"; + +/** + * Shared control for icon-only buttons in mobile top bars (global nav, chat + * header, full-screen panel chrome). Matches {@link RightPanelToggle}: `p-1` + * with a `size-5` icon yields a 28×28px hit target. + */ +export const mobileTopBarIconButtonClassName = cn( + "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-md p-1", + "text-[var(--oh-muted)]", + formControlTransitionClassName, + formControlMutedHoverClassName, +); + +/** Lucide / inline SVG wrapper size aligned with `BlockDrawerLeftIcon` (`w-5 h-5`). */ +export const mobileTopBarIconClassName = "size-5 shrink-0"; diff --git a/src/utils/modal-classes.ts b/src/utils/modal-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..837fe851dbbda5ba4092a7dc1b46caa7502440ea --- /dev/null +++ b/src/utils/modal-classes.ts @@ -0,0 +1,19 @@ +import { cn } from "#/utils/utils"; + +/** Primary modal heading on dark overlay surfaces. */ +export const modalTitleClassName = cn( + "text-xl leading-6 -tracking-[0.01em] font-medium text-[var(--oh-modal-title-foreground)]", +); + +/** Compact modal header bar title (folder browser, manage workspaces). */ +export const modalTitleSmClassName = cn( + "text-sm font-medium text-[var(--oh-modal-title-foreground)]", +); + +/** Large modal title used by form-style dialogs. */ +export const modalTitleLgClassName = cn( + "text-lg font-medium text-[var(--oh-modal-title-foreground)]", +); + +/** Alias of {@link modalTitleLgClassName} for historical call sites. */ +export const modalTitleLgMediumClassName = modalTitleLgClassName; diff --git a/src/utils/parse-terminal-output.ts b/src/utils/parse-terminal-output.ts new file mode 100644 index 0000000000000000000000000000000000000000..1cd54eb8581a958119d44e9c6c4c7791f68d0e95 --- /dev/null +++ b/src/utils/parse-terminal-output.ts @@ -0,0 +1,28 @@ +const START = "[Python Interpreter: "; + +/** + * Parses the raw output from the terminal into the command and symbol + * @param raw The raw output to be displayed in the terminal + * @returns The parsed output + * + * @example + * const raw = + * "web_scraper.py\r\n\r\n[Python Interpreter: /openhands/poetry/openhands-5O4_aCHf-py3.12/bin/python]\nopenhands@659478cb008c:/workspace $ "; + * + * const parsed = parseTerminalOutput(raw); + * + * console.log(parsed.output); // web_scraper.py + * console.log(parsed.symbol); // openhands@659478cb008c:/workspace $ + */ +export const parseTerminalOutput = (raw: string) => { + const start = raw.indexOf(START); + if (start < 0) { + return raw; + } + const offset = start + START.length; + const end = raw.indexOf("]", offset); + if (end <= offset) { + return raw; + } + return raw.substring(0, start).trim(); +}; diff --git a/src/utils/path-utils.ts b/src/utils/path-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5e81b6e508ad57fcf9b2fec07f9bb130050830a --- /dev/null +++ b/src/utils/path-utils.ts @@ -0,0 +1,122 @@ +/** + * Path manipulation utilities + */ +import { DEFAULT_WORKING_DIR } from "#/api/agent-server-config"; + +/** + * Strip workspace prefix from file paths + * Removes /workspace/ and the next directory level from paths + * + * @param path - The file path to process + * @returns The path with workspace prefix removed + * + * @example + * stripWorkspacePrefix("/workspace/repo/src/file.py") // returns "src/file.py" + * stripWorkspacePrefix("/workspace/my-project/components/Button.tsx") // returns "components/Button.tsx" + */ +export const stripWorkspacePrefix = (path: string): string => { + // Strip /workspace/ and the next directory level + const workspaceMatch = path.match(/^\/workspace\/[^/]+\/(.*)$/); + return workspaceMatch ? workspaceMatch[1] : path; +}; + +function normalizeWorkspaceRoot(root: string): string { + const trimmed = root.trim().replace(/\\/g, "/").replace(/\/+$/, ""); + if (!trimmed) return ""; + // Keep Windows drive roots (`C:` / `C:/repo`) and POSIX absolutes as-is. + if (trimmed.startsWith("/") || /^[A-Za-z]:(\/|$)/.test(trimmed)) { + return trimmed; + } + return `/${trimmed}`; +} + +/** + * Convert an agent/chat file path into a workspace-relative path for the Files + * drawer / workspace file APIs. + * + * Strips editor `:line` / `:start-end` suffixes, then removes the conversation + * working directory (or `DEFAULT_WORKING_DIR`) when present as a prefix. + * Nested roots must match `workingDir` first — the generic `/workspace//` + * heuristic is only a fallback when no root matches. + */ +export const toFilesTabPath = ( + path: string, + workingDir?: string | null, +): string => { + let result = path.trim().replace(/\\/g, "/"); + if (!result) return ""; + + // Strip editor `:12` / `:12-40` suffixes. Safe on Windows paths too — + // the drive colon is at the start (`C:`), never at the end. + result = result.replace(/:(\d+)(-\d+)?$/, ""); + + const roots = [workingDir, DEFAULT_WORKING_DIR].filter( + (value): value is string => Boolean(value?.trim()), + ); + + for (const root of roots) { + const normalizedRoot = normalizeWorkspaceRoot(root); + if (!normalizedRoot) continue; + if (result === normalizedRoot) { + return ""; + } + if (result.startsWith(`${normalizedRoot}/`)) { + return result.slice(normalizedRoot.length + 1); + } + } + + if (!result.startsWith("/")) { + return result.replace(/^\.\//, ""); + } + + return stripWorkspacePrefix(result).replace(/^\.\//, ""); +}; + +const WORKSPACE_FILE_EXTENSION = + /\.(md|txt|ts|tsx|js|jsx|mjs|cjs|py|json|html?|css|scss|ya?ml|toml|rs|go|java|kt|swift|c|cc|cpp|h|hpp|sh|bash|zsh|sql|xml|svg|pdf|env|rb|php|vue|svelte|lock|ini|cfg|docx?|xlsx?|pptx?|odt|rtf)$/i; + +/** + * Conservative check for inline chat tokens that should open in Files. + * Rejects URLs, MIME types, versions, and dotted identifiers like `console.log`. + */ +export const looksLikeWorkspaceFilePath = (text: string): boolean => { + const trimmed = text.trim(); + if (!trimmed || /\s/.test(trimmed) || /^https?:\/\//i.test(trimmed)) { + return false; + } + + let path = trimmed.replace(/\\/g, "/").replace(/^\.\//, ""); + path = path.replace(/:(\d+)(-\d+)?$/, ""); + + if ( + /^(application|audio|image|text|video|font|multipart|message|model)\/[\w.+-]+$/i.test( + path, + ) + ) { + return false; + } + if (/^v?\d+(\.\d+){1,3}([-+][\w.]+)?$/i.test(path)) { + return false; + } + + const lastSegment = path.includes("/") ? (path.split("/").pop() ?? "") : path; + return WORKSPACE_FILE_EXTENSION.test(lastSegment); +}; + +/** + * Returns the basename (top-level folder/file name) from a path string, + * tolerating POSIX and Windows separators and trailing slashes. + */ +export const getPathBasename = (path: string): string => { + const trimmed = path.trim(); + if (!trimmed) return ""; + + const normalized = trimmed.replace(/[\\/]+$/, ""); + if (!normalized || /^[A-Za-z]:$/.test(normalized)) return ""; + + const idx = Math.max( + normalized.lastIndexOf("/"), + normalized.lastIndexOf("\\"), + ); + return idx >= 0 ? normalized.slice(idx + 1) : normalized; +}; diff --git a/src/utils/pending-task-message-link.ts b/src/utils/pending-task-message-link.ts new file mode 100644 index 0000000000000000000000000000000000000000..62f4c7da07a6b5319141751e1afba23460bdb69b --- /dev/null +++ b/src/utils/pending-task-message-link.ts @@ -0,0 +1,61 @@ +/** + * While a cloud start task redirects from `/conversations/task-{uuid}` to the + * real conversation id, optimistic pending messages stay keyed to the task id + * until a layout effect can reassign them. This map lets the chat UI keep + * matching those bubbles against the real conversation route. + */ +const taskSourceByRealConversationId = new Map(); + +let scheduledPendingReassign: { + fromConversationId: string; + toConversationId: string; +} | null = null; + +export function linkPendingTaskMessages( + realConversationId: string, + taskConversationId: string, +): void { + taskSourceByRealConversationId.set(realConversationId, taskConversationId); +} + +export function clearPendingTaskMessageLink(realConversationId: string): void { + taskSourceByRealConversationId.delete(realConversationId); +} + +export function schedulePendingTaskMessageReassign( + fromConversationId: string, + toConversationId: string, +): void { + scheduledPendingReassign = { fromConversationId, toConversationId }; +} + +export function consumeScheduledPendingTaskMessageReassign( + conversationId: string, +): { fromConversationId: string; toConversationId: string } | null { + if (scheduledPendingReassign?.toConversationId !== conversationId) { + return null; + } + + const value = scheduledPendingReassign; + scheduledPendingReassign = null; + return value; +} + +export function matchesPendingConversationId( + activeConversationId: string, + pendingConversationId: string, +): boolean { + if (pendingConversationId === activeConversationId) { + return true; + } + + const linkedTaskConversationId = + taskSourceByRealConversationId.get(activeConversationId); + return linkedTaskConversationId === pendingConversationId; +} + +/** Test helper */ +export function resetPendingTaskMessageLinkState(): void { + taskSourceByRealConversationId.clear(); + scheduledPendingReassign = null; +} diff --git a/src/utils/plan-file.ts b/src/utils/plan-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c3c6bde516a337bbdc3c3f445b2d0c93f196171 --- /dev/null +++ b/src/utils/plan-file.ts @@ -0,0 +1,101 @@ +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; + +export const PLAN_RELATIVE_PATH = ".agents_tmp/PLAN.md"; +export const PLANNING_SYSTEM_PROMPT_FILENAME = "system_prompt_planning.j2"; +export const PLANNING_FILE_EDITOR_TOOL_NAME = "planning_file_editor"; +export const LOCAL_PLANNER_PARENT_TAG_KEY = "plannerparent"; + +const PLAN_FILENAME_UPPER = "PLAN.MD"; + +// Mirrors the SDK planning preset's `format_plan_structure()` output +// (software-agent-sdk openhands-tools/openhands/tools/preset/planning.py, +// `PLAN_STRUCTURE`). The planning agent loads `system_prompt_planning.j2` with +// this injected as `{{plan_structure}}`; keep it byte-identical to that SDK +// output so the local planner matches the canonical planning agent. +export const PLAN_STRUCTURE_TEXT = [ + "The plan must follow this structure exactly:", + "", + "1. OBJECTIVE", + " * Summarize the goal of the plan in one or two sentences.", + " * Restate the problem in clear operational terms.", + "", + "2. CONTEXT SUMMARY", + " * Briefly describe the relevant system components, files, or data involved.", + " * Mention any dependencies or constraints (technical, organizational, or external).", + "", + "3. APPROACH OVERVIEW", + " * Outline the chosen approach at a high level.", + " * Mention why it was selected (short rationale) if alternatives were considered.", + "", + "4. IMPLEMENTATION STEPS", + " * Provide a step-by-step plan for execution.", + " * Each step should include:", + " - a **goal** (what this step accomplishes),", + " - a **method** (how to do it, briefly),", + " - and optionally a **reference** (file, module, or function impacted).", + "", + "5. TESTING AND VALIDATION", + " * Describe how the implementation can be verified or validated.", + " * This section should describe what success looks like — expected outputs, behaviors, or conditions.", +].join("\n"); + +// System-prompt suffix for the local planning agent (mirrors the OpenHands +// app-server's PLANNING_AGENT_INSTRUCTION). The planner's directive + boundaries +// live in the system prompt; the planning conversation is created idle, so the +// user types the first message themselves and nothing is injected into the chat. +export const PLANNING_AGENT_INSTRUCTION = [ + "", + "You are a Planning Agent that can ONLY create plans - you CANNOT execute code or make changes.", + "", + "Create or update the plan for the current task in the configured PLAN.md file.", + "", + "After you finalize the plan in PLAN.md:", + '- Do NOT ask "Ready to proceed?" or offer to execute the plan', + "- Do NOT attempt to run any implementation commands", + "- Instead, tell the user they can click the **Build** button below the plan preview to switch to the code agent and execute the plan.", + "", + "Your role ends when the plan is finalized. Implementation is handled by the code agent.", + "", +].join("\n"); + +export function buildPlanPath(workingDir: string): string { + const normalized = workingDir.replace(/\/+$/, ""); + return `${normalized}/${PLAN_RELATIVE_PATH}`; +} + +export function isPlanFilePath(path: string | null | undefined): boolean { + if (!path) return false; + const normalized = path.replace(/\\/g, "/").toUpperCase(); + return ( + normalized === PLAN_FILENAME_UPPER || + normalized.endsWith(`/${PLAN_FILENAME_UPPER}`) + ); +} + +/** + * Whether `conversation` is the local planner helper for `parentConversationId` + * — identity comes from the `plannerparent` tag `createLocalPlanningConversation` + * stamps on creation, never from list position (`sub_conversation_ids` is the + * generic, untyped child list). + */ +export function isPlannerConversationOf( + conversation: Pick | null | undefined, + parentConversationId: string, +): boolean { + return ( + conversation?.tags?.[LOCAL_PLANNER_PARENT_TAG_KEY] === parentConversationId + ); +} + +/** Finds the planner helper among a conversation's fetched sub-conversations. */ +export function findPlannerConversationId( + subConversations: (AppConversation | null)[] | null | undefined, + parentConversationId: string | null | undefined, +): string | null { + if (!parentConversationId) return null; + return ( + subConversations?.find((sub) => + isPlannerConversationOf(sub, parentConversationId), + )?.id ?? null + ); +} diff --git a/src/utils/plugin-display.ts b/src/utils/plugin-display.ts new file mode 100644 index 0000000000000000000000000000000000000000..70849c1694b30740b53aeb3ee36fd6f87750837c --- /dev/null +++ b/src/utils/plugin-display.ts @@ -0,0 +1,53 @@ +import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; + +/** + * Friendly display name for a plugin reference: the explicit `name` when set + * (e.g. an installed plugin's name), otherwise the last segment of `repo_path`, + * otherwise the repo from the `source` coordinate. Mirrors the `/launch` modal's + * naming so the same plugin reads consistently across surfaces. + */ +export function getPluginDisplayName(plugin: PluginSpec): string { + if (plugin.name) return plugin.name; + if (plugin.repo_path) { + const parts = plugin.repo_path.split("/").filter(Boolean); + if (parts.length > 0) return parts[parts.length - 1]; + } + const { source } = plugin; + if (source.startsWith("github:")) return source.replace("github:", ""); + if (source.includes("/")) { + return ( + source + .split("/") + .pop() + ?.replace(/\.git$/, "") || source + ); + } + return source; +} + +/** + * True when the plugin was installed from the local machine — the `"local"` + * sentinel or a filesystem path — rather than a remote git source (`github:…` + * or a URL). These read inconsistently (a bare `"local"` vs a long absolute + * path that also leaks the home dir), so callers render one normalized label + * for them instead of {@link getPluginSourceLabel}. + */ +export function isLocalPluginSource(plugin: PluginSpec): boolean { + const { source } = plugin; + if (source.startsWith("github:")) return false; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(source)) return false; // git URL + return true; +} + +/** Source coordinate with an optional `@ref`, e.g. "OpenHands/extensions @ main". */ +export function getPluginSourceLabel(plugin: PluginSpec): string { + const base = plugin.source.startsWith("github:") + ? plugin.source.replace("github:", "") + : plugin.source; + return plugin.ref ? `${base} @ ${plugin.ref}` : base; +} + +/** Stable key for a plugin reference (coordinates only). */ +export function pluginReferenceKey(plugin: PluginSpec): string { + return [plugin.source, plugin.ref ?? "", plugin.repo_path ?? ""].join(" "); +} diff --git a/src/utils/plugin-launch-url.ts b/src/utils/plugin-launch-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..74914862e47080603cb23d62c75d4497fe8d6edd --- /dev/null +++ b/src/utils/plugin-launch-url.ts @@ -0,0 +1,17 @@ +import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; + +/** + * Build the in-app path to the `/launch` screen for the given plugins, so the + * Plugins UI can start a conversation with a plugin by reusing the existing + * launch flow. Inverse of `parsePluginsFromUrl` in `src/routes/launch.tsx` — + * keep the base64-encoded JSON `plugins` format in sync with that decoder. + * + * The base64 payload can contain `+`, `/`, and `=`; encoding it through + * `URLSearchParams` percent-escapes those so `/launch` reads back the exact + * string (a raw `+` would otherwise be decoded as a space and break `atob`). + */ +export function buildPluginLaunchPath(plugins: PluginSpec[]): string { + const encoded = btoa(JSON.stringify(plugins)); + const params = new URLSearchParams({ plugins: encoded }); + return `/launch?${params.toString()}`; +} diff --git a/src/utils/recommended-automation-rail.ts b/src/utils/recommended-automation-rail.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff343dfbd282930dbe2fe13fb80db30e7d189b13 --- /dev/null +++ b/src/utils/recommended-automation-rail.ts @@ -0,0 +1,100 @@ +import { + AUTOMATION_CATALOG, + type RecommendedAutomation, +} from "@openhands/extensions/automations"; +import { getFeaturedAutomationIds } from "#/manifests/automation-interface"; +import { SETUP_REGISTRY } from "#/manifests/manifest-sources"; +import type { Automation } from "#/types/automation"; + +export function getAutomationsByPopularity( + catalog: RecommendedAutomation[], +): RecommendedAutomation[] { + return catalog + .map((automation, index) => ({ automation, index })) + .sort((a, b) => { + const byPopularity = + (b.automation.popularityRank ?? 0) - (a.automation.popularityRank ?? 0); + return byPopularity || a.index - b.index; + }) + .map(({ automation }) => automation); +} + +/** + * Slug used to match a catalog entry against an installed automation name. + * Catalog ids are already kebab-case; installed names are human titles. + */ +export function normalizeAutomationKey(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/&/g, "and") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function catalogMatchKeys(entry: RecommendedAutomation): string[] { + return [ + entry.id, + entry.name, + entry.name ? `${entry.name} Agent` : undefined, + entry.skill, + ] + .filter((value): value is string => Boolean(value)) + .map(normalizeAutomationKey); +} + +export function isCatalogAutomationAdded( + entry: RecommendedAutomation, + installed: readonly Pick[], +): boolean { + const installedKeys = new Set( + installed.map((automation) => normalizeAutomationKey(automation.name)), + ); + return catalogMatchKeys(entry).some((key) => installedKeys.has(key)); +} + +function isProvenAutomation(entry: RecommendedAutomation): boolean { + return getFeaturedAutomationIds().includes(entry.id); +} + +/** Catalog cards that launch a conversation instead of a host setup form. */ +export function isConversationLaunchAutomation( + entry: RecommendedAutomation, +): boolean { + return SETUP_REGISTRY.findById(entry.id) == null; +} + +export interface RecommendedRailGroups { + proven: RecommendedAutomation[]; + conversation: RecommendedAutomation[]; +} + +/** + * Home / dashboard rail: remaining proven workflows, then other useful + * automations that open in a new conversation. Already-created automations + * are dropped from both groups. + */ +export function getRecommendedRailGroups( + installed: readonly Pick[], +): RecommendedRailGroups { + // Every catalog entry is offerable: one that declares no integration needs + // nothing connected, and one that names an integration this host cannot + // resolve stays visible so the drift is actionable rather than hidden. + const available = getAutomationsByPopularity(AUTOMATION_CATALOG).filter( + (entry) => !isCatalogAutomationAdded(entry, installed), + ); + + return { + proven: available.filter(isProvenAutomation), + conversation: available.filter( + (entry) => + !isProvenAutomation(entry) && isConversationLaunchAutomation(entry), + ), + }; +} + +export function flattenRecommendedRailGroups( + groups: RecommendedRailGroups, +): RecommendedAutomation[] { + return [...groups.proven, ...groups.conversation]; +} diff --git a/src/utils/redact-custom-secrets.ts b/src/utils/redact-custom-secrets.ts new file mode 100644 index 0000000000000000000000000000000000000000..32720852b71f38bd0600d16e0c9a9aa069706e99 --- /dev/null +++ b/src/utils/redact-custom-secrets.ts @@ -0,0 +1,32 @@ +const MASKED_PLACEHOLDER = ""; + +/** + * Defensive backstop: redact any unmasked value inside a `` + * block before showing dynamic context in the UI, in case backend masking + * regresses. Text outside the block is untouched. + */ +export function redactCustomSecrets(text: string): string { + // Regexes are local: the `g` flag makes them stateful, and keeping them + // scoped to this call avoids cross-call `lastIndex` surprises. + + // Closing tag is optional so a truncated block is still redacted, not leaked. + const customSecretsBlock = + /()([\s\S]*?)(<\/CUSTOM_SECRETS>|$)/gi; + + // `KEY: value` / `KEY=value`, capturing key + separator so only the value changes. + const secretLine = /^(\s*[^=:\n]+?\s*[:=]\s*)(.+?)\s*$/gm; + + return text.replace( + customSecretsBlock, + (_match, open: string, body: string, close: string) => { + const redactedBody = body.replace( + secretLine, + (lineMatch, prefix: string, value: string) => + value === MASKED_PLACEHOLDER + ? lineMatch + : `${prefix}${MASKED_PLACEHOLDER}`, + ); + return `${open}${redactedBody}${close}`; + }, + ); +} diff --git a/src/utils/redact-mcp-secrets.ts b/src/utils/redact-mcp-secrets.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d00969b6c1dd1059b3741032020371b2fdb08de --- /dev/null +++ b/src/utils/redact-mcp-secrets.ts @@ -0,0 +1,130 @@ +import type { MCPServerConfig } from "#/types/mcp-server"; +import { REDACTED_MCP_SECRET_VALUE } from "#/utils/mcp-config"; + +/** + * Minimum length for a config value to be treated as a redactable secret. + * Shorter strings are too likely to collide with ordinary words in an + * error message and would mangle it without hiding anything meaningful. + */ +const MIN_SECRET_LENGTH = 4; + +const SECRETLIKE_PARAM_NAME = /token|key|secret|auth/i; + +/** + * Token shapes that may appear in backend error text even when the browser + * never held the plaintext value (stored secrets round-trip encrypted and + * are decrypted server-side, so a spawn/connect error can echo them back). + */ +const GENERIC_SECRET_PATTERNS: RegExp[] = [ + // GitHub personal access tokens (classic and fine-grained). + /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, + // Slack workspace tokens (xoxb-, xoxp-, xoxs-, ...). + /\bxox[a-z](?:-[A-Za-z0-9]+)+/g, + // Linear API keys. + /\blin_api_[A-Za-z0-9]{10,}\b/g, + // JWT-shaped values (three base64url segments starting with "eyJ"). + /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g, +]; + +// "Bearer " keeps its prefix so the message stays readable. +const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi; + +function addValue(values: Set, value: unknown): void { + if (typeof value !== "string") return; + if (value.length < MIN_SECRET_LENGTH) return; + if (value === REDACTED_MCP_SECRET_VALUE) return; + values.add(value); +} + +function addRecordValues( + values: Set, + record: Record | null | undefined, +): void { + for (const value of Object.values(record ?? {})) { + addValue(values, value); + } +} + +function addUrlSecrets(values: Set, rawUrl: string | undefined): void { + if (!rawUrl) return; + try { + const url = new URL(rawUrl); + addValue(values, url.username); + addValue(values, url.password); + addValue(values, decodeURIComponent(url.username)); + addValue(values, decodeURIComponent(url.password)); + url.searchParams.forEach((value, name) => { + if (SECRETLIKE_PARAM_NAME.test(name)) addValue(values, value); + }); + } catch { + // Not a parseable URL — nothing to collect. + } +} + +function addAuthSecrets( + values: Set, + auth: MCPServerConfig["auth"], +): void { + if (!auth) return; + switch (auth.strategy) { + case "api_key": + case "bearer": + addValue(values, auth.value); + break; + case "basic": + addValue(values, auth.username); + addValue(values, auth.password); + break; + case "header": + addRecordValues(values, auth.headers); + break; + case "oauth2": + addValue(values, auth.authentication?.client_secret); + addRecordValues(values, auth.state?.tokens); + break; + default: + break; + } +} + +/** + * Collect every secret VALUE a server config may carry, longest first so + * replacing a shorter secret can never split a longer one that contains it. + */ +export function collectMcpSecretValues(server: MCPServerConfig): string[] { + const values = new Set(); + addRecordValues(values, server.env); + addRecordValues(values, server.headers); + addAuthSecrets(values, server.auth); + addUrlSecrets(values, server.url); + return [...values].sort((a, b) => b.length - a.length); +} + +/** + * Scrub secrets from MCP test/probe error text before it is displayed. + * Replaces every known secret value from the given server config(s), then + * applies generic token patterns as a safety net for plaintext the browser + * never saw (e.g. values decrypted server-side and echoed in an error). + */ +export function redactMcpSecrets( + text: string, + ...servers: (MCPServerConfig | undefined)[] +): string { + if (!text) return text; + let redacted = text; + for (const server of servers) { + if (!server) continue; + for (const value of collectMcpSecretValues(server)) { + redacted = redacted.split(value).join(REDACTED_MCP_SECRET_VALUE); + } + } + redacted = redacted.replace( + BEARER_PATTERN, + `Bearer ${REDACTED_MCP_SECRET_VALUE}`, + ); + for (const pattern of GENERIC_SECRET_PATTERNS) { + redacted = redacted.replace(pattern, REDACTED_MCP_SECRET_VALUE); + } + return redacted; +} diff --git a/src/utils/responder-deployment.ts b/src/utils/responder-deployment.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ff6ee24a5cf3b06c1b7f400e9cc720b161bed7b --- /dev/null +++ b/src/utils/responder-deployment.ts @@ -0,0 +1,94 @@ +import type { RecommendedAutomation } from "@openhands/extensions/automations"; +import { I18nKey } from "#/i18n/declaration"; +import { getResponderIntegrationIds } from "#/manifests/automation-interface"; +import { getRequiredIntegrationIds } from "#/utils/automation-catalog"; +import { PRODUCT_URL } from "#/utils/constants"; + +/** OpenHands Cloud integrations page — where always-on responders are set up. */ +export const OPENHANDS_CLOUD_INTEGRATIONS_URL = `${PRODUCT_URL.PRODUCTION}/settings/integrations`; + +/** + * Single source of truth for "does this automation get the deployment-choice + * modal?" — true only for automations whose required integrations are + * exclusively responder ones (owned by the interface manifest, GitHub/Slack by + * default), so multi-tool digests (e.g. slack + linear + notion) are not + * treated as Slack responders. An integration the automation can start without + * does not decide where it runs, so only the required ones are classified. + */ +export function isResponderAutomation( + automation: RecommendedAutomation, +): boolean { + const responderIds = getResponderIntegrationIds(); + const ids = getRequiredIntegrationIds(automation); + return ids.length > 0 && ids.every((id) => responderIds.includes(id)); +} + +/** + * Where a responder runs. Only `local` and `openhands-cloud` are wired today; + * `user-cloud` is reserved for a future remote-deployment target. + */ +export type ResponderDeploymentTarget = + | "local" + | "user-cloud" + | "openhands-cloud"; + +/** What the launcher should do when an option's primary action fires. */ +export type ResponderDeploymentAction = + | { kind: "launch-local" } + | { kind: "open-url"; url: string }; + +/** Presentational + behavioral descriptor for one deployment option. */ +export interface ResponderDeploymentOption { + target: ResponderDeploymentTarget; + testId: string; + titleKey: I18nKey; + descriptionKey: I18nKey; + primaryActionKey: I18nKey; + primaryActionTestId: string; + action: ResponderDeploymentAction; +} + +/** + * Centralized runtime-selection mechanism. Returns a descriptor (data) rather + * than executing behavior, so the side effects stay with the launcher that owns + * the relevant state. Adding a target later means adding a `case` here and an + * entry to {@link VISIBLE_RESPONDER_DEPLOYMENT_TARGETS}; the `never` check makes + * an unhandled target a compile error. + */ +export function resolveResponderDeploymentOption( + target: ResponderDeploymentTarget, +): ResponderDeploymentOption { + switch (target) { + case "local": + return { + target, + testId: "responder-deployment-option-local", + titleKey: I18nKey.RESPONDER_DEPLOYMENT$LOCAL_TITLE, + descriptionKey: I18nKey.RESPONDER_DEPLOYMENT$LOCAL_DESCRIPTION, + primaryActionKey: I18nKey.RESPONDER_DEPLOYMENT$LOCAL_ACTION, + primaryActionTestId: "responder-deployment-continue-local", + action: { kind: "launch-local" }, + }; + case "openhands-cloud": + return { + target, + testId: "responder-deployment-option-openhands-cloud", + titleKey: I18nKey.RESPONDER_DEPLOYMENT$OPENHANDS_CLOUD_TITLE, + descriptionKey: + I18nKey.RESPONDER_DEPLOYMENT$OPENHANDS_CLOUD_DESCRIPTION, + primaryActionKey: I18nKey.RESPONDER_DEPLOYMENT$OPENHANDS_CLOUD_ACTION, + primaryActionTestId: "responder-deployment-open-openhands-cloud", + action: { kind: "open-url", url: OPENHANDS_CLOUD_INTEGRATIONS_URL }, + }; + case "user-cloud": + throw new Error("User Cloud responder deployment is not yet supported"); + default: { + const exhaustive: never = target; + return exhaustive; + } + } +} + +/** Targets the modal renders today, in display order (excludes `user-cloud`). */ +export const VISIBLE_RESPONDER_DEPLOYMENT_TARGETS: ResponderDeploymentTarget[] = + ["local", "openhands-cloud"]; diff --git a/src/utils/retrieve-axios-error-message.ts b/src/utils/retrieve-axios-error-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..34227ffe82234a89dec09bc126d103186042efb0 --- /dev/null +++ b/src/utils/retrieve-axios-error-message.ts @@ -0,0 +1,49 @@ +import { AxiosError } from "axios"; +import { + isAxiosErrorWithErrorField, + isAxiosErrorWithMessageField, +} from "./type-guards"; +import { getUserFacingConnectionErrorMessage } from "./user-facing-error"; + +function isAxiosError(error: unknown): error is AxiosError { + return ( + typeof error === "object" && + error !== null && + "isAxiosError" in error && + (error as { isAxiosError?: unknown }).isAxiosError === true + ); +} + +/** + * Retrieve the error message from an Axios error + * @param error The error to render a toast for + */ +export const retrieveAxiosErrorMessage = (error: unknown): string => { + let errorMessage: string | null = null; + let shouldPreferExtractedMessage = false; + + if (isAxiosError(error)) { + shouldPreferExtractedMessage = true; + if (isAxiosErrorWithErrorField(error) && error.response?.data.error) { + errorMessage = error.response?.data.error; + } else if ( + isAxiosErrorWithMessageField(error) && + error.response?.data.message + ) { + errorMessage = error.response?.data.message; + } else { + errorMessage = error.message; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === "string") { + errorMessage = error; + } else { + errorMessage = null; + } + + const userFacingMessage = getUserFacingConnectionErrorMessage( + shouldPreferExtractedMessage ? (errorMessage ?? error) : error, + ); + return userFacingMessage ?? errorMessage ?? ""; +}; diff --git a/src/utils/sanitize-query.ts b/src/utils/sanitize-query.ts new file mode 100644 index 0000000000000000000000000000000000000000..565fdc67a3014580672c5ec55c2bc8ec62d0d05b --- /dev/null +++ b/src/utils/sanitize-query.ts @@ -0,0 +1,7 @@ +export const sanitizeQuery = (query: string) => + query + .trim() + .replace(/https?:\/\//, "") + .replace(/github.com\//, "") + .replace(/\.git$/, "") + .toLowerCase(); diff --git a/src/utils/scroll-fade-state.ts b/src/utils/scroll-fade-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..df40c335a778650ac17bf11081030dca0ab01c1e --- /dev/null +++ b/src/utils/scroll-fade-state.ts @@ -0,0 +1,37 @@ +const SCROLL_EDGE_THRESHOLD_PX = 1; + +export interface ScrollFadeState { + left: boolean; + right: boolean; +} + +export interface VerticalScrollEdgeState { + top: boolean; + bottom: boolean; +} + +/** Whether a horizontal scroller is clipped on each edge. */ +export function readScrollFadeState(element: HTMLElement): ScrollFadeState { + const { scrollLeft, scrollWidth, clientWidth } = element; + const maxScroll = scrollWidth - clientWidth; + const hasOverflow = maxScroll > SCROLL_EDGE_THRESHOLD_PX; + + return { + left: hasOverflow && scrollLeft > SCROLL_EDGE_THRESHOLD_PX, + right: hasOverflow && scrollLeft < maxScroll - SCROLL_EDGE_THRESHOLD_PX, + }; +} + +/** Whether a vertical scroller is clipped on each edge. */ +export function readVerticalScrollEdgeState( + element: HTMLElement, +): VerticalScrollEdgeState { + const { scrollTop, scrollHeight, clientHeight } = element; + const maxScroll = scrollHeight - clientHeight; + const hasOverflow = maxScroll > SCROLL_EDGE_THRESHOLD_PX; + + return { + top: hasOverflow && scrollTop > SCROLL_EDGE_THRESHOLD_PX, + bottom: hasOverflow && scrollTop < maxScroll - SCROLL_EDGE_THRESHOLD_PX, + }; +} diff --git a/src/utils/sdk-settings-schema.ts b/src/utils/sdk-settings-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..1086c62679638ac5960ee3cd6f67f56b4e575c15 --- /dev/null +++ b/src/utils/sdk-settings-schema.ts @@ -0,0 +1,513 @@ +import { + SettingProminence, + Settings, + SettingsFieldSchema, + SettingsSchema, + SettingsSectionSchema, + SettingsValue, +} from "#/types/settings"; +import { getSettingsFieldConstraints } from "#/utils/sdk-settings-field-metadata"; +import { + LLM_AUTH_TYPE_KEY, + LLM_SUBSCRIPTION_VENDOR_KEY, +} from "#/constants/llm-subscription"; + +export type SettingsFormValues = Record; +export type SettingsDirtyState = Record; +export type SdkSettingsPayload = Record; +export type SettingsValueSource = "agent_settings" | "conversation_settings"; + +export type SettingsView = "basic" | "advanced" | "all"; + +/** Fields that are rendered by purpose-built components instead of the + * generic `SchemaField` renderer. */ +export const SPECIALLY_RENDERED_KEYS = new Set([ + "llm.model", + "llm.api_key", + "llm.base_url", + LLM_AUTH_TYPE_KEY, + LLM_SUBSCRIPTION_VENDOR_KEY, +]); + +/** Prominence tiers visible at each view level. */ +const VIEW_PROMINENCES: Record> = { + basic: new Set(["critical"]), + advanced: new Set(["critical", "major"]), + all: new Set(["critical", "major", "minor"]), +}; + +/** + * True when `schema` looks like a usable `SettingsSchema` — i.e. an + * object with an array `sections` field. Guards every helper in this + * module against malformed/empty schema responses (e.g. when the + * frontend ends up pointing at a host that does not actually serve + * `/api/settings/agent-schema`, such as an unconfigured Vercel preview + * origin that returns the React Router SPA shell for arbitrary + * `/api/*` paths). Without this check, `schema.sections.filter(...)` + * inside `SdkSectionPage` blows up with + * `Cannot read properties of undefined (reading 'filter')` and React + * Router escalates to a full-screen error page. + */ +export function isValidSettingsSchema( + schema: SettingsSchema | null | undefined, +): schema is SettingsSchema { + return !!schema && Array.isArray((schema as SettingsSchema).sections); +} + +function getSchemaFields(schema: SettingsSchema): SettingsFieldSchema[] { + if (!isValidSettingsSchema(schema)) return []; + return schema.sections.flatMap((section) => section.fields); +} + +/** Traverse a nested object using a dotted key path (e.g. "llm.model"). */ +function lookupDotted( + obj: Record | null | undefined, + key: string, +): unknown { + if (!obj) return undefined; + const parts = key.split("."); + let current: unknown = obj; + for (const part of parts) { + if (current == null || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + return current; +} + +/** Set a value in a nested object at a dotted key path (e.g. "llm.model"). */ +function setDotted( + obj: Record, + key: string, + value: unknown, +): void { + const parts = key.split("."); + let current: Record = obj; + for (let i = 0; i < parts.length - 1; i += 1) { + const part = parts[i]; + if ( + current[part] == null || + typeof current[part] !== "object" || + Array.isArray(current[part]) + ) { + current[part] = {}; + } + current = current[part] as Record; + } + current[parts[parts.length - 1]] = value; +} + +export function getSettingValue( + settings: Settings, + key: string, + source: SettingsValueSource = "agent_settings", +): SettingsValue { + return (lookupDotted(settings[source] as Record, key) ?? + null) as SettingsValue; +} + +export function getAgentSettingValue( + settings: Settings, + key: string, +): SettingsValue { + return getSettingValue(settings, key, "agent_settings"); +} + +export function getConversationSettingValue( + settings: Settings, + key: string, +): SettingsValue { + return getSettingValue(settings, key, "conversation_settings"); +} + +function isChoiceField(field: SettingsFieldSchema): boolean { + return field.choices.length > 0; +} + +function isCriticalField(field: SettingsFieldSchema): boolean { + return field.prominence === "critical"; +} + +function isMinorField(field: SettingsFieldSchema): boolean { + return field.prominence === "minor"; +} + +export function normalizeFieldValue( + field: SettingsFieldSchema, + rawValue: unknown, +): string | boolean { + const resolvedValue = rawValue ?? field.default; + + if (isChoiceField(field)) { + return resolvedValue === null || resolvedValue === undefined + ? "" + : String(resolvedValue); + } + + if (field.value_type === "boolean") { + return Boolean(resolvedValue ?? false); + } + + if (resolvedValue === null || resolvedValue === undefined) { + return ""; + } + + if (field.value_type === "array" || field.value_type === "object") { + return JSON.stringify(resolvedValue, null, 2); + } + + return String(resolvedValue); +} + +function stableJsonStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map(stableJsonStringify).join(",")}]`; + } + + const sortedEntries = Object.entries(value as Record).sort( + ([leftKey], [rightKey]) => leftKey.localeCompare(rightKey), + ); + + return `{${sortedEntries + .map( + ([key, entryValue]) => + `${JSON.stringify(key)}:${stableJsonStringify(entryValue)}`, + ) + .join(",")}}`; +} + +export function normalizeComparableValue( + field: SettingsFieldSchema, + rawValue: unknown, +): boolean | number | string | null { + if (rawValue === undefined) { + return null; + } + + if (field.value_type === "boolean") { + if (typeof rawValue === "string") { + if (rawValue === "true") { + return true; + } + if (rawValue === "false") { + return false; + } + } + if (rawValue === null) { + return null; + } + return Boolean(rawValue); + } + + if (field.value_type === "integer" || field.value_type === "number") { + if (rawValue === "" || rawValue === null) { + return null; + } + + const parsedValue = + typeof rawValue === "number" ? rawValue : Number(String(rawValue)); + return Number.isNaN(parsedValue) ? null : parsedValue; + } + + if (field.value_type === "array" || field.value_type === "object") { + if (rawValue === null) { + return null; + } + + // Treat empty objects as null so that serializer artifacts + // (e.g. mcp_config: {} vs schema default null) don't trigger + // a spurious view escalation in inferInitialView. + if ( + field.value_type === "object" && + typeof rawValue === "object" && + !Array.isArray(rawValue) && + Object.keys(rawValue as Record).length === 0 + ) { + return null; + } + + if (typeof rawValue === "string") { + const trimmedValue = rawValue.trim(); + if (!trimmedValue) { + return null; + } + try { + const parsed: unknown = JSON.parse(trimmedValue); + // Also normalise stringified empty objects + if ( + field.value_type === "object" && + parsed !== null && + typeof parsed === "object" && + !Array.isArray(parsed) && + Object.keys(parsed as Record).length === 0 + ) { + return null; + } + return stableJsonStringify(parsed); + } catch { + return trimmedValue; + } + } + + return stableJsonStringify(rawValue); + } + + if (rawValue === null) { + return null; + } + + return String(rawValue); +} + +export function buildInitialSettingsFormValues( + settings: Settings, + schemaOverride?: SettingsSchema | null, + source: SettingsValueSource = "agent_settings", +): SettingsFormValues { + const schema = + schemaOverride ?? + (source === "conversation_settings" + ? settings.conversation_settings_schema + : settings.agent_settings_schema); + if (!schema) { + return {}; + } + + return Object.fromEntries( + getSchemaFields(schema).map((field) => [ + field.key, + normalizeFieldValue(field, getSettingValue(settings, field.key, source)), + ]), + ); +} + +export function inferInitialView( + settings: Settings, + schemaOverride?: SettingsSchema | null, + source: SettingsValueSource = "agent_settings", +): SettingsView { + const schema = + schemaOverride ?? + (source === "conversation_settings" + ? settings.conversation_settings_schema + : settings.agent_settings_schema); + if (!schema) { + return "basic"; + } + + let hasMinorOverride = false; + let hasMajorOverride = false; + + for (const field of getSchemaFields(schema)) { + if (!isCriticalField(field)) { + const currentValue = getSettingValue(settings, field.key, source); + const isDifferent = + normalizeComparableValue( + field, + currentValue ?? field.default ?? null, + ) !== normalizeComparableValue(field, field.default ?? null); + + if (isDifferent) { + if (isMinorField(field)) { + hasMinorOverride = true; + } else { + hasMajorOverride = true; + } + } + } + } + + if (hasMinorOverride) return "all"; + if (hasMajorOverride) return "advanced"; + return "basic"; +} + +/** Determine which view tier to default to based on whether the user has + * overridden any non-critical settings. */ +export function hasAdvancedSettingsOverrides(settings: Settings): boolean { + return inferInitialView(settings) !== "basic"; +} + +export function isSettingsFieldVisible( + field: SettingsFieldSchema, + values: SettingsFormValues, +): boolean { + return field.depends_on.every((dependency) => values[dependency] === true); +} + +function parseBooleanFieldValue(rawValue: string | boolean): boolean | null { + if (typeof rawValue === "boolean") { + return rawValue; + } + + const normalizedValue = rawValue.trim().toLowerCase(); + if (!normalizedValue) { + return null; + } + if (normalizedValue === "true") { + return true; + } + if (normalizedValue === "false") { + return false; + } + + throw new Error(`Expected a boolean value, received: ${rawValue}`); +} + +export function coerceFieldValue( + field: SettingsFieldSchema, + rawValue: string | boolean, +): SettingsValue { + if (field.value_type === "boolean") { + return parseBooleanFieldValue(rawValue); + } + + if (field.value_type === "integer" || field.value_type === "number") { + const stringValue = String(rawValue).trim(); + if (!stringValue) { + return null; + } + + const parsedValue = Number(stringValue); + if (Number.isNaN(parsedValue)) { + throw new Error(`Expected a numeric value, received: ${stringValue}`); + } + if (field.value_type === "integer" && !Number.isInteger(parsedValue)) { + throw new Error(`Expected an integer value, received: ${stringValue}`); + } + + const constraints = getSettingsFieldConstraints(field.key); + if (constraints?.min != null && parsedValue < constraints.min) { + throw new Error(`${field.label} must be at least ${constraints.min}`); + } + if (constraints?.max != null && parsedValue > constraints.max) { + throw new Error(`${field.label} must be at most ${constraints.max}`); + } + + return parsedValue; + } + + if (field.value_type === "array" || field.value_type === "object") { + const stringValue = String(rawValue).trim(); + if (!stringValue) { + return null; + } + + let parsedValue: unknown; + try { + parsedValue = JSON.parse(stringValue); + } catch { + throw new Error(`Invalid JSON for ${field.label}`); + } + + if (field.value_type === "array") { + if (!Array.isArray(parsedValue)) { + throw new Error(`${field.label} must be a JSON array`); + } + return parsedValue as SettingsValue[]; + } + + if ( + parsedValue === null || + Array.isArray(parsedValue) || + typeof parsedValue !== "object" + ) { + throw new Error(`${field.label} must be a JSON object`); + } + + return parsedValue as { [key: string]: SettingsValue }; + } + + const stringValue = String(rawValue); + if (stringValue === "" && !field.secret) { + return null; + } + + return stringValue; +} + +export function buildSdkSettingsPayload( + schema: SettingsSchema, + values: SettingsFormValues, + dirty: SettingsDirtyState, +): SdkSettingsPayload { + const payload: Record = {}; + + for (const field of getSchemaFields(schema)) { + if (dirty[field.key]) { + setDotted(payload, field.key, coerceFieldValue(field, values[field.key])); + } + } + + return payload as SdkSettingsPayload; +} + +function isFieldVisibleInView( + field: SettingsFieldSchema, + view: SettingsView, +): boolean { + return VIEW_PROMINENCES[view].has(field.prominence); +} + +export function buildSdkSettingsPayloadForView( + schema: SettingsSchema, + values: SettingsFormValues, + dirty: SettingsDirtyState, + view: SettingsView, +): SdkSettingsPayload { + const payload = buildSdkSettingsPayload(schema, values, dirty) as Record< + string, + unknown + >; + + for (const field of getSchemaFields(schema)) { + if (!isFieldVisibleInView(field, view)) { + setDotted(payload, field.key, field.default ?? null); + } + } + + return payload as SdkSettingsPayload; +} + +/** Return sections with fields filtered for the current view tier. + * Specially-rendered fields are excluded from the generic list. */ +export function getVisibleSettingsSections( + schema: SettingsSchema, + values: SettingsFormValues, + view: SettingsView, + excludeKeys: Set = SPECIALLY_RENDERED_KEYS, +): SettingsSectionSchema[] { + if (!isValidSettingsSchema(schema)) return []; + return schema.sections + .map((section) => ({ + ...section, + fields: section.fields.filter( + (field) => + !excludeKeys.has(field.key) && + isFieldVisibleInView(field, view) && + isSettingsFieldVisible(field, values), + ), + })) + .filter((section) => section.fields.length > 0); +} + +/** Whether the schema has any "critical" prominence fields (the basic tier). */ +export function hasCriticalSettings(schema: SettingsSchema | null): boolean { + if (!schema) return false; + return getSchemaFields(schema).some((f) => f.prominence === "critical"); +} + +/** Whether the schema has any fields visible in the "advanced" tier. */ +export function hasAdvancedSettings(schema: SettingsSchema | null): boolean { + if (!schema) return false; + return getSchemaFields(schema).some((f) => f.prominence === "major"); +} + +/** Whether the schema has any "minor" prominence fields. */ +export function hasMinorSettings(schema: SettingsSchema | null): boolean { + if (!schema) return false; + return getSchemaFields(schema).some((f) => f.prominence === "minor"); +} diff --git a/src/utils/send-message-with-attachments.ts b/src/utils/send-message-with-attachments.ts new file mode 100644 index 0000000000000000000000000000000000000000..83daa5b6b7e5ec73851983b8d74af78d96778649 --- /dev/null +++ b/src/utils/send-message-with-attachments.ts @@ -0,0 +1,94 @@ +import type { TFunction } from "i18next"; +import { + resolveConversationRuntime, + uploadFilesToConversation, +} from "#/api/conversation-file-upload.api"; +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import type { SendMessageRequest } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils"; +import { validateFiles } from "#/utils/file-validation"; +import { I18nKey } from "#/i18n/declaration"; + +export interface SendMessageWithAttachmentsResult { + text: string; + content: string; + imageUrls: string[]; + fileUrls: string[]; + timestamp: string; +} + +export async function sendMessageWithAttachments(options: { + conversationId: string; + content: string; + images: File[]; + files: File[]; + imagesMarkedUploadAsFile: string[]; + t: TFunction; +}): Promise { + const { + conversationId, + content, + images, + files, + imagesMarkedUploadAsFile, + t, + } = options; + + const { imagesToEmbed, imagesAsFiles } = partitionImagesForUpload( + images, + imagesMarkedUploadAsFile, + ); + const filesToUpload = [...files, ...imagesAsFiles]; + + const validation = validateFiles([...imagesToEmbed, ...filesToUpload]); + if (!validation.isValid) { + throw new Error(validation.errorMessage ?? "Invalid attachments"); + } + + const imageUrls = await Promise.all( + imagesToEmbed.map((image) => convertImageToBase64(image)), + ); + + const runtime = await resolveConversationRuntime(conversationId); + + const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } = + filesToUpload.length > 0 + ? await uploadFilesToConversation(conversationId, filesToUpload) + : { skipped_files: [], uploaded_files: [] }; + + skippedFiles.forEach((file) => displayErrorToast(file.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; + + const timestamp = new Date().toISOString(); + + const messageContent: SendMessageRequest = { + role: "user", + content: [{ type: "text", text: prompt }], + }; + + if (imageUrls.length > 0) { + messageContent.content.push({ + type: "image", + image_urls: imageUrls, + }); + } + + await AgentServerConversationService.sendMessage( + conversationId, + messageContent, + runtime, + ); + + return { + text: content, + content: prompt, + imageUrls, + fileUrls: uploadedFiles, + timestamp, + }; +} diff --git a/src/utils/settings-like-page-layout-classes.ts b/src/utils/settings-like-page-layout-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfb68f418db688b17f6d202b5653badb70469362 --- /dev/null +++ b/src/utils/settings-like-page-layout-classes.ts @@ -0,0 +1,15 @@ +/** + * Shared layout tokens for /settings/* and extensions pages (/skills, /mcp, + * /plugins) so mobile gets horizontal inset while desktop keeps the aside + + * `gap-10` + right gutter pattern. + * + * `scrollbar-gutter: stable` keeps the gutter reserved when the page stops + * overflowing. Without it, filtering a list short enough to fit drops the + * scrollbar and the centered content jumps sideways into the freed space. + */ +export const settingsLikeMainScrollClassName = + "flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto [scrollbar-gutter:stable] custom-scrollbar-always px-4 pt-8 pb-12 md:px-0 md:pr-[14px]"; + +/** Same scroll shell as {@link settingsLikeMainScrollClassName} but desktop top padding comes from the outer `md:pt-8` wrapper ({@link SettingsLayout}). */ +export const settingsLayoutMainScrollClassName = + "flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto [scrollbar-gutter:stable] custom-scrollbar-always px-4 pt-8 pb-12 md:px-0 md:pt-0 md:pr-[14px]"; diff --git a/src/utils/settings-list-classes.ts b/src/utils/settings-list-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fe5ae8ec9b5f855d355b33dea9e5c3ba0c36cb5 --- /dev/null +++ b/src/utils/settings-list-classes.ts @@ -0,0 +1,65 @@ +import { cn } from "#/utils/utils"; +import { + formControlBorderClassName, + formControlRadiusClassName, + formControlSurfaceClassName, + formControlTransitionClassName, +} from "#/utils/form-control-classes"; + +/** 48px row height — taller than form controls for settings list/table rows. */ +export const settingsListRowHeightClassName = "h-12 min-h-12"; + +/** Bordered list shell shared by secrets, LLM profiles, and similar settings tables. */ +export const settingsListContainerClassName = cn( + formControlBorderClassName, + formControlRadiusClassName, + formControlSurfaceClassName, + "overflow-hidden", +); + +/** Scrollable variant for long settings lists (e.g. secrets). */ +export const settingsListScrollContainerClassName = cn( + settingsListContainerClassName, + "overflow-auto max-h-[min(70vh,39rem)]", +); + +export const settingsListDividerClassName = + "divide-y divide-[var(--oh-border)]"; + +/** Interactive row hover on dark list surfaces (base-secondary). */ +export const settingsListRowHoverClassName = + "hover:bg-[var(--oh-interactive-hover-low)]"; + +export const settingsListRowClassName = cn( + settingsListRowHeightClassName, + "flex items-center px-3", + formControlTransitionClassName, + settingsListRowHoverClassName, +); + +export const settingsListTableRowClassName = cn( + settingsListRowHoverClassName, + formControlTransitionClassName, + "border-t border-[var(--oh-border)] first:border-t-0", +); + +export const settingsListTableHeadClassName = cn( + formControlSurfaceClassName, + "sticky top-0 z-10 border-b border-[var(--oh-border)]", +); + +export const settingsListTableHeaderCellClassName = cn( + settingsListRowHeightClassName, + "px-3 text-left text-sm font-medium align-middle", +); + +export const settingsListTableCellClassName = cn( + settingsListRowHeightClassName, + "px-3 text-sm align-middle min-w-0", +); + +export const settingsListIconActionButtonClassName = cn( + "inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted", + formControlTransitionClassName, + "hover:bg-[var(--oh-interactive-hover-low)] hover:text-white", +); diff --git a/src/utils/settings-utils.ts b/src/utils/settings-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..7742f02d64a84f5700c63915b60a6e2555f40d76 --- /dev/null +++ b/src/utils/settings-utils.ts @@ -0,0 +1,78 @@ +import { WebClientFeatureFlags } from "#/api/option-service/option.types"; +import { Settings, SettingsValue } from "#/types/settings"; +import { getProviderId } from "#/utils/map-provider"; + +const extractBasicFormData = (formData: FormData) => { + const providerDisplay = formData.get("llm-provider-input")?.toString(); + const provider = providerDisplay ? getProviderId(providerDisplay) : undefined; + const model = formData.get("llm-model-input")?.toString(); + + return { + llmModel: provider && model ? `${provider}/${model}` : undefined, + llmApiKey: formData.get("llm-api-key-input")?.toString(), + agent: formData.get("agent")?.toString(), + language: formData.get("language")?.toString(), + }; +}; + +export const parseMaxBudgetPerTask = (value: string): number | null => { + if (!value) { + return null; + } + + const parsedValue = parseFloat(value); + return parsedValue && parsedValue >= 1 && Number.isFinite(parsedValue) + ? parsedValue + : null; +}; + +export const extractSettings = ( + formData: FormData, +): Partial & Record => { + const { llmModel, llmApiKey, agent, language } = + extractBasicFormData(formData); + + const llm: Record = {}; + if (llmModel) llm.model = llmModel; + if (llmApiKey !== undefined) llm.api_key = llmApiKey; + + const agentSettings: Record = {}; + if (Object.keys(llm).length > 0) + agentSettings.llm = llm as Record; + if (agent) agentSettings.agent = agent; + + return { + ...(Object.keys(agentSettings).length > 0 + ? { agent_settings_diff: agentSettings } + : {}), + ...(language ? { language } : {}), + }; +}; + +export function isSettingsPageHidden( + path: string, + featureFlags: WebClientFeatureFlags | undefined, +): boolean { + if (featureFlags?.hide_llm_settings && path === "/settings/llm") return true; + return false; +} + +export function getFirstAvailablePath( + featureFlags: WebClientFeatureFlags | undefined, +): string | null { + // ``/settings/agents`` (the Agent Profile library — the "Agent" page) always + // wins: it is where the agent is defined (OpenHands / ACP, via the active + // profile) and is always available regardless of feature flags. Landing here + // keeps routing simple — every user lands where the agent is chosen, and + // the LLM page is one nav-click away. + const fallbackOrder = [ + { path: "/settings/agents", hidden: false }, + { path: "/settings/llm", hidden: !!featureFlags?.hide_llm_settings }, + { path: "/settings", hidden: !!featureFlags?.hide_llm_settings }, + { path: "/settings/app", hidden: false }, + { path: "/settings/secrets", hidden: false }, + ]; + + const firstAvailable = fallbackOrder.find((item) => !item.hidden); + return firstAvailable?.path ?? null; +} diff --git a/src/utils/settings-value-pickers.ts b/src/utils/settings-value-pickers.ts new file mode 100644 index 0000000000000000000000000000000000000000..97162ca3a2f98746dc1a67d065e20c272b9495e2 --- /dev/null +++ b/src/utils/settings-value-pickers.ts @@ -0,0 +1,26 @@ +export const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.length > 0; + +export const pickFirstString = (...values: unknown[]): string | undefined => + values.find(isNonEmptyString); + +export const pickFirstBoolean = (...values: unknown[]): boolean | undefined => + values.find((value): value is boolean => typeof value === "boolean"); + +export const pickFirstNumber = (...values: unknown[]): number | undefined => + values.find((value): value is number => typeof value === "number"); + +export const pickNullableString = ( + ...values: unknown[] +): string | null | undefined => { + for (const value of values) { + if (typeof value === "string") { + return value; + } + if (value === null) { + return null; + } + } + + return undefined; +}; diff --git a/src/utils/skill-category.ts b/src/utils/skill-category.ts new file mode 100644 index 0000000000000000000000000000000000000000..afb61baed705cbaad6bf1b674d6bbcd7014a42ca --- /dev/null +++ b/src/utils/skill-category.ts @@ -0,0 +1,69 @@ +import { + Bot, + GitPullRequest, + Package, + Palette, + PenLine, + Plug, + ShieldCheck, + Workflow, + Wrench, + type LucideIcon, +} from "lucide-react"; +import { + SKILL_CATEGORY_IDS, + type SkillCategoryId, +} from "@openhands/extensions/skills"; +import { I18nKey } from "#/i18n/declaration"; +import type { SkillInfo } from "#/types/settings"; + +/** Display order in the facet rail. `other` last. */ +export const SKILL_CATEGORY_ORDER: readonly SkillCategoryId[] = [ + "automations", + "environment", + "code-hosting", + "agent-authoring", + "code-quality", + "integrations", + "writing", + "design", + "other", +]; + +export const SKILL_CATEGORY_LABEL_KEYS: Record = { + automations: I18nKey.SETTINGS$SKILLS_CATEGORY_AUTOMATIONS, + environment: I18nKey.SETTINGS$SKILLS_CATEGORY_ENVIRONMENT, + "code-hosting": I18nKey.SETTINGS$SKILLS_CATEGORY_CODE_HOSTING, + "agent-authoring": I18nKey.SETTINGS$SKILLS_CATEGORY_AGENT_AUTHORING, + "code-quality": I18nKey.SETTINGS$SKILLS_CATEGORY_CODE_QUALITY, + integrations: I18nKey.SETTINGS$SKILLS_CATEGORY_INTEGRATIONS, + writing: I18nKey.SETTINGS$SKILLS_CATEGORY_WRITING, + design: I18nKey.SETTINGS$SKILLS_CATEGORY_DESIGN, + other: I18nKey.SETTINGS$SKILLS_CATEGORY_OTHER, +}; + +export const SKILL_CATEGORY_ICONS: Record = { + automations: Workflow, + environment: Wrench, + "code-hosting": GitPullRequest, + "agent-authoring": Bot, + "code-quality": ShieldCheck, + integrations: Plug, + writing: PenLine, + design: Palette, + other: Package, +}; + +/** The catalog uses `other` for a skill with no marketplace entry, so it means "uncategorized" there exactly as it does for a local skill. */ +export const UNCATEGORIZED_SKILL_CATEGORY: SkillCategoryId = "other"; + +const KNOWN_CATEGORIES = new Set(SKILL_CATEGORY_IDS); + +/** Local skills carry no category and a stale bundled catalog could carry one this build does not know; both degrade to `other` rather than an unrenderable facet value. */ +export function getSkillCategory(skill: SkillInfo): SkillCategoryId { + const raw = skill.category; + if (raw && KNOWN_CATEGORIES.has(raw)) { + return raw; + } + return UNCATEGORIZED_SKILL_CATEGORY; +} diff --git a/src/utils/skill-enablement.ts b/src/utils/skill-enablement.ts new file mode 100644 index 0000000000000000000000000000000000000000..22f9c5441127215781ae8c32dae1631725d92375 --- /dev/null +++ b/src/utils/skill-enablement.ts @@ -0,0 +1,124 @@ +import { + DEFAULT_ENABLED_SKILL_NAMES, + SKILLS_CATALOG, +} from "@openhands/extensions/skills"; + +/** Every skill bundled from `@openhands/extensions`, in catalog order. */ +export const CATALOG_SKILL_NAMES: readonly string[] = SKILLS_CATALOG.map( + (entry) => entry.name, +); + +const CATALOG_SKILL_NAME_SET = new Set(CATALOG_SKILL_NAMES); +const RECOMMENDED_SKILL_NAME_SET = new Set(DEFAULT_ENABLED_SKILL_NAMES); + +export function isCatalogSkill(name: string): boolean { + return CATALOG_SKILL_NAME_SET.has(name); +} + +export function isRecommendedSkill(name: string): boolean { + return RECOMMENDED_SKILL_NAME_SET.has(name); +} + +/** + * The two persisted lists. They cover different populations: `enabledSkills` + * allow-lists the bundled catalog, whose every future addition would otherwise + * be on for everyone (#16302), while `disabledSkills` keeps denying user- and + * project-authored skills, which should be on the moment they appear. + * + * `undefined` means "never migrated" and must survive settings hydration. + */ +export interface SkillEnablement { + enabledSkills?: string[]; + disabledSkills?: string[]; +} + +export function resolveEnabledCatalogSkills( + enablement: SkillEnablement, +): string[] { + return enablement.enabledSkills ?? [...DEFAULT_ENABLED_SKILL_NAMES]; +} + +/** + * The one rule for "will this skill be loaded", resolved once per caller so + * per-skill checks stay cheap. + * + * The deny-list still wins over the allow-list, which only matters before the + * migration runs: until then a pre-existing "I turned this off" lives in the + * deny-list alone. + */ +export function buildSkillEnablementFilter( + enablement: SkillEnablement, +): (skillName: string) => boolean { + const enabled = new Set(resolveEnabledCatalogSkills(enablement)); + const disabled = new Set(enablement.disabledSkills ?? []); + + return (skillName) => { + if (disabled.has(skillName)) return false; + return !isCatalogSkill(skillName) || enabled.has(skillName); + }; +} + +/** + * One-shot conversion from "all catalog skills on, minus a deny-list" to an + * explicit allow-list; `undefined` once already migrated. + * + * A fresh workspace is migrated too, even though the resolver's fallback would + * give it the same set: persisting an explicit list is what stops a later + * `defaultEnabled` catalog addition from switching itself on. + */ +export function migrateSkillEnablement( + enablement: SkillEnablement, +): { enabled_skills: string[]; disabled_skills: string[] } | undefined { + if (enablement.enabledSkills !== undefined) return undefined; + + const disabled = new Set(enablement.disabledSkills ?? []); + // A deny-list naming a catalog skill is the only evidence that this + // workspace predates the allow-list; one holding local names alone says + // nothing about the catalog. + const isExistingWorkspace = [...disabled].some(isCatalogSkill); + + return { + enabled_skills: isExistingWorkspace + ? CATALOG_SKILL_NAMES.filter((name) => !disabled.has(name)) + : [...DEFAULT_ENABLED_SKILL_NAMES], + // Catalog names move to the allow-list; a leftover deny entry would veto + // a skill the user later switches back on. + disabled_skills: [...disabled].filter((name) => !isCatalogSkill(name)), + }; +} + +export function toSkillEnablement(settings: { + enabled_skills?: string[]; + disabled_skills?: string[]; +}): SkillEnablement { + return { + enabledSkills: settings.enabled_skills, + disabledSkills: settings.disabled_skills, + }; +} + +// Both forms a user can send: the commands a skill declares in its own +// `triggers` (what an automation card fills in — see `findAutomationCommand`), +// and `/`, which the detail modal's "Use skill" button inserts. +const CATALOG_SKILL_BY_SLASH_COMMAND = new Map( + SKILLS_CATALOG.flatMap((entry) => + [ + `/${entry.name}`, + ...(entry.triggers ?? []).filter((trigger) => trigger.startsWith("/")), + ].map((command) => [command.toLowerCase(), entry.name] as const), + ), +); + +/** + * The catalog skill a message invokes by name, if any. + * + * 18 of the catalog's 24 slash commands belong to skills that are off by + * default, so without this an automation card would send its command with none + * of the instructions behind it. Only the leading token counts: matching a + * `/word` anywhere in prose would re-admit most of the catalog. + */ +export function findInvokedCatalogSkill(query?: string): string | undefined { + const firstToken = query?.trim().split(/\s+/, 1)[0]; + if (!firstToken?.startsWith("/")) return undefined; + return CATALOG_SKILL_BY_SLASH_COMMAND.get(firstToken.toLowerCase()); +} diff --git a/src/utils/skill-install-events.ts b/src/utils/skill-install-events.ts new file mode 100644 index 0000000000000000000000000000000000000000..1af157309aa54a0ecb0330d7cd2d052432235e88 --- /dev/null +++ b/src/utils/skill-install-events.ts @@ -0,0 +1,57 @@ +import { OpenHandsEvent } from "#/types/agent-server/core"; +import { isExecuteBashObservationEvent } from "#/types/agent-server/type-guards"; + +export interface DetectedSkillInstall { + /** Id of the bash observation event carrying the success marker. */ + eventId: string; + skillName: string; + /** Workspace root the skill was installed into (forward slashes). */ + workspacePath: string; +} + +/** + * Success marker printed by the bundled add-skill skill's fetch_skill.py: + * ✅ Successfully installed '' to /.agents/skills/ + * Failures exit 1 and never print this line. Matched on output alone — + * `command` can be "" and `exit_code` can be -1 on soft-timeout + * continuation observations that still carry the full output. + */ +const INSTALL_SUCCESS_PATTERN = + /^✅ Successfully installed '([^']+)' to (.+)$/m; + +/** + * Skills the agent installed during this conversation via the add-skill + * flow. The SDK loads skills once at conversation start, so these are on + * disk but inert until a new conversation starts in `workspacePath`. + * Deduped per workspace+skill; a re-install (--force) keeps the latest + * event id and moves the entry to the end. + */ +export function detectSkillInstalls( + events: OpenHandsEvent[], +): DetectedSkillInstall[] { + const byKey = new Map(); + + for (const event of events) { + if (!isExecuteBashObservationEvent(event)) continue; + + const text = event.observation.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"); + const match = text.match(INSTALL_SUCCESS_PATTERN); + if (!match) continue; + + const skillName = match[1]; + const dest = match[2].replace(/\\/g, "/").replace(/\/+$/, ""); + const suffix = `/.agents/skills/${skillName}`; + if (!dest.endsWith(suffix)) continue; + const workspacePath = dest.slice(0, -suffix.length); + if (!workspacePath) continue; + + const key = `${workspacePath}::${skillName}`; + byKey.delete(key); + byKey.set(key, { eventId: event.id, skillName, workspacePath }); + } + + return [...byKey.values()]; +} diff --git a/src/utils/streaming-delta-batcher.ts b/src/utils/streaming-delta-batcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0231f80273fc93650e9d810749136608f44ab60 --- /dev/null +++ b/src/utils/streaming-delta-batcher.ts @@ -0,0 +1,75 @@ +import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event"; +import { mergeStreamingDeltaEvent } from "#/utils/handle-event-for-ui"; + +/** Schedules a single deferred callback (defaults to the animation frame). */ +export interface DeltaFlushScheduler { + schedule: (callback: () => void) => number; + cancel: (handle: number) => void; +} + +const defaultScheduler: DeltaFlushScheduler = + typeof requestAnimationFrame === "function" + ? { + schedule: (callback) => requestAnimationFrame(callback), + cancel: (handle) => cancelAnimationFrame(handle), + } + : { + schedule: (callback) => setTimeout(callback, 16) as unknown as number, + cancel: (handle) => clearTimeout(handle), + }; + +export interface StreamingDeltaBatcher { + /** Buffer a delta; a flush is scheduled for the next frame if not already. */ + enqueue: (event: StreamingDeltaEvent) => void; + /** Commit buffered deltas now. Call before any non-delta event. */ + flush: () => void; + /** Drop buffered deltas without committing. Call on unmount / conversation switch. */ + reset: () => void; +} + +/** + * Coalesces adjacent `StreamingDeltaEvent`s and commits them at most once per + * animation frame, so a fast model can't force a store commit + re-render per + * token. Callers MUST `flush()` before any non-delta event so a + * durable message/action can't render ahead of its own streamed text. + */ +export function createStreamingDeltaBatcher( + commit: (event: StreamingDeltaEvent) => void, + scheduler: DeltaFlushScheduler = defaultScheduler, +): StreamingDeltaBatcher { + let pending: StreamingDeltaEvent[] = []; + let frame: number | null = null; + + const cancelFrame = () => { + if (frame !== null) { + scheduler.cancel(frame); + frame = null; + } + }; + + const flush = () => { + cancelFrame(); + if (pending.length === 0) { + return; + } + const batch = pending; + pending = []; + commit( + batch.reduce((merged, delta) => mergeStreamingDeltaEvent(delta, merged)), + ); + }; + + return { + enqueue: (event) => { + pending.push(event); + if (frame === null) { + frame = scheduler.schedule(flush); + } + }, + flush, + reset: () => { + cancelFrame(); + pending = []; + }, + }; +} diff --git a/src/utils/system-message-adapter.ts b/src/utils/system-message-adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d81f758834d9c90871bb6cfdc9959e60a13698b --- /dev/null +++ b/src/utils/system-message-adapter.ts @@ -0,0 +1,35 @@ +import { OHEvent } from "#/stores/use-event-store"; +import { ChatCompletionToolParam } from "#/types/agent-server/core"; +import { isSystemPromptEvent } from "#/types/agent-server/type-guards"; +import { redactCustomSecrets } from "#/utils/redact-custom-secrets"; + +export interface SystemMessageForModal { + content: string; + tools: ChatCompletionToolParam[] | Record[] | null; + openhands_version: string | null; + agent_class: string | null; +} + +export function adaptSystemMessage( + events: OHEvent[], +): SystemMessageForModal | null { + const systemPromptEvent = events.find(isSystemPromptEvent); + + if (!systemPromptEvent) { + return null; + } + + // dynamic_context is the runtime-injected tail of the same system prompt the + // model receives, so append it to show the full message as one block. + const dynamicContextText = systemPromptEvent.dynamic_context?.text; + const content = dynamicContextText + ? `${systemPromptEvent.system_prompt.text.trimEnd()}\n\n${redactCustomSecrets(dynamicContextText)}` + : systemPromptEvent.system_prompt.text; + + return { + content, + tools: systemPromptEvent.tools ?? null, + openhands_version: null, + agent_class: null, + }; +} diff --git a/src/utils/table-row-classes.ts b/src/utils/table-row-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..70a5aa968c2aae9f840b828e2b0e8b6eca137101 --- /dev/null +++ b/src/utils/table-row-classes.ts @@ -0,0 +1,23 @@ +/** Bordered table container on base-secondary surfaces. */ +export const tableContainerClassName = + "overflow-hidden rounded-md border border-[var(--oh-border)] bg-base-secondary"; + +/** Fixed 44px row height for compact data tables (secrets, automations, MCP). */ +export const tableRowHeightClassName = "h-11"; + +export const tableRowClassName = [ + tableRowHeightClassName, + "border-t border-[var(--oh-border)] transition-colors", +].join(" "); + +/** Subtle row highlight on base-secondary tables. */ +export const tableRowHoverClassName = + "hover:bg-interactive-hover-low outline-none focus:outline-none focus-visible:outline-none focus-visible:bg-interactive-hover-low"; + +export const tableCellClassName = "px-3 align-middle"; + +export const tableRowInteractiveClassName = [ + tableRowClassName, + tableRowHoverClassName, + "cursor-pointer", +].join(" "); diff --git a/src/utils/tar-gzip.ts b/src/utils/tar-gzip.ts new file mode 100644 index 0000000000000000000000000000000000000000..dae92f7df38e8e0078ef1974ea05b4e3705f10d8 --- /dev/null +++ b/src/utils/tar-gzip.ts @@ -0,0 +1,119 @@ +/** + * Packing a handful of text files into a `.tar.gz`, in the browser. + * + * The automation service accepts a gzipped tar and nothing else, and the only + * archives built here are the few small files a catalog bundle ships, so this + * writes the original POSIX ustar format directly rather than pulling in a tar + * library: 512-byte header, name and metadata as space-padded octal, content + * padded to the next 512-byte boundary, two zero blocks to end the archive. + * Gzip is the platform's own `CompressionStream`. + * + * Deliberately not general-purpose. Names must fit ustar's 100-byte field, and + * only regular files are written - no directories, links, or long-name + * extensions - because a bundle that needed any of those would be packed by + * something that also has to unpack them. + */ + +const encoder = new TextEncoder(); + +const BLOCK_SIZE = 512; +const NAME_FIELD_SIZE = 100; +const CHECKSUM_OFFSET = 148; +const CHECKSUM_SIZE = 8; + +export interface TarFile { + /** Path inside the archive. Must fit ustar's 100-byte name field. */ + name: string; + content: string; + /** Defaults to 0o644. A setup script wants 0o755. */ + mode?: number; +} + +function header(file: TarFile, name: Uint8Array, size: number): Uint8Array { + const block = new Uint8Array(BLOCK_SIZE); + + /** Host-written ASCII: the octal fields and the format's own markers. */ + const ascii = (offset: number, value: string): void => { + for (let index = 0; index < value.length; index += 1) { + block[offset + index] = value.charCodeAt(index) & 0x7f; + } + }; + /** ustar writes numbers as octal, NUL-terminated, right-aligned with zeros. */ + const octal = (offset: number, size_: number, value: number): void => + ascii(offset, value.toString(8).padStart(size_ - 1, "0")); + + // The name is the caller's, so it is written as the bytes it encodes to + // rather than through `ascii`, whose mask would quietly turn `café.py` into + // `cafi.py`. ustar's name field is bytes, and readers take them as UTF-8. + block.set(name, 0); + octal(100, 8, file.mode ?? 0o644); + octal(108, 8, 0); // uid + octal(116, 8, 0); // gid + octal(124, 12, size); + // A fixed mtime keeps the archive byte-identical for identical inputs, so a + // re-upload of an unchanged bundle is visibly unchanged. + octal(136, 12, 0); + block[156] = "0".charCodeAt(0); // regular file + ascii(257, "ustar"); + ascii(263, "00"); + + // The checksum is computed with its own field read as spaces, then written + // into it as octal followed by NUL and a space. + block.fill(0x20, CHECKSUM_OFFSET, CHECKSUM_OFFSET + CHECKSUM_SIZE); + const sum = block.reduce((total, byte) => total + byte, 0); + ascii(CHECKSUM_OFFSET, sum.toString(8).padStart(6, "0")); + block[CHECKSUM_OFFSET + 6] = 0; + block[CHECKSUM_OFFSET + 7] = 0x20; + + return block; +} + +/** The uncompressed archive. Exported for tests; callers want `packTarGzip`. */ +export function packTar(files: readonly TarFile[]): Uint8Array { + const blocks: Uint8Array[] = []; + + for (const file of files) { + const name = encoder.encode(file.name); + if (name.length > NAME_FIELD_SIZE) { + throw new Error(`tar: name too long for a ustar header: ${file.name}`); + } + const content = encoder.encode(file.content); + blocks.push(header(file, name, content.length)); + const padded = new Uint8Array( + Math.ceil(content.length / BLOCK_SIZE) * BLOCK_SIZE, + ); + padded.set(content); + blocks.push(padded); + } + + // Two zero blocks mark the end of the archive. + blocks.push(new Uint8Array(BLOCK_SIZE * 2)); + + const total = blocks.reduce((size, block) => size + block.length, 0); + const archive = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const block of blocks) { + archive.set(block, offset); + offset += block.length; + } + return archive; +} + +/** The gzipped archive, ready to POST as `application/gzip`. */ +export async function packTarGzip( + files: readonly TarFile[], +): Promise { + const tar = packTar(files); + // Streamed from the bytes rather than through a Blob: a Blob's stream() is + // absent in the jsdom test environment, and the archive is one chunk anyway. + const source = new ReadableStream({ + start(controller) { + controller.enqueue(tar); + controller.close(); + }, + }); + const compressed = new Response( + source.pipeThrough(new CompressionStream("gzip")), + ); + return new Uint8Array(await compressed.arrayBuffer()); +} diff --git a/src/utils/title-llm-profile.ts b/src/utils/title-llm-profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1cde92fec6fae220b8628cc7f5337eff3119f10 --- /dev/null +++ b/src/utils/title-llm-profile.ts @@ -0,0 +1,22 @@ +import type { ProfileListResponse } from "#/api/profiles-service/profiles-service.api"; + +export function resolveTitleLlmProfile( + preference: string | null | undefined, + profiles: ProfileListResponse | undefined, +): string | undefined { + if (!profiles) return undefined; + + const availableProfiles = new Set( + profiles.profiles.map((profile) => profile.name), + ); + if (preference && availableProfiles.has(preference)) { + return preference; + } + if ( + profiles.active_profile && + availableProfiles.has(profiles.active_profile) + ) { + return profiles.active_profile; + } + return undefined; +} diff --git a/src/utils/toast-duration.ts b/src/utils/toast-duration.ts new file mode 100644 index 0000000000000000000000000000000000000000..300efb6097a8db3b086945e24b42c7c8a4d1daa1 --- /dev/null +++ b/src/utils/toast-duration.ts @@ -0,0 +1,32 @@ +/** + * Calculate toast duration based on message length + * @param message - The message to display + * @param minDuration - Minimum duration in milliseconds (default: 5000 for success, 4000 for error) + * @param maxDuration - Maximum duration in milliseconds (default: 10000) + * @returns Duration in milliseconds + */ +export const calculateToastDuration = ( + message: string | null | undefined, + minDuration: number = 5000, + maxDuration: number = 10000, +): number => { + // Handle null/undefined messages - return minDuration immediately + if (!message) { + return minDuration; + } + + // Calculate duration based on reading speed (average 200 words per minute) + // Assuming average word length of 5 characters + const wordsPerMinute = 200; + const charactersPerMinute = wordsPerMinute * 5; + const charactersPerSecond = charactersPerMinute / 60; + + // Calculate time needed to read the message + const readingTimeMs = (message.length / charactersPerSecond) * 1000; + + // Add some buffer time (50% extra) for processing + const durationWithBuffer = readingTimeMs * 1.5; + + // Ensure duration is within min/max bounds + return Math.min(Math.max(durationWithBuffer, minDuration), maxDuration); +}; diff --git a/src/utils/type-guards.ts b/src/utils/type-guards.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d4f22e9da3e21359aadbd02e242c35415edcd40 --- /dev/null +++ b/src/utils/type-guards.ts @@ -0,0 +1,17 @@ +import { AxiosError } from "axios"; + +export const isAxiosErrorWithErrorField = ( + error: AxiosError, +): error is AxiosError<{ error: string }> => + typeof error.response?.data === "object" && + error.response?.data !== null && + "error" in error.response.data && + typeof error.response?.data?.error === "string"; + +export const isAxiosErrorWithMessageField = ( + error: AxiosError, +): error is AxiosError<{ message: string }> => + typeof error.response?.data === "object" && + error.response?.data !== null && + "message" in error.response.data && + typeof error.response?.data?.message === "string"; diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd2a9e696202cc790d682a54ea02bc20cd49c0ea --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,20 @@ +import { Provider } from "#/types/settings"; + +export type SuggestedTaskType = + | "MERGE_CONFLICTS" + | "FAILING_CHECKS" + | "UNRESOLVED_COMMENTS" + | "OPEN_ISSUE"; // This is a task type identifier, not a UI string + +export interface SuggestedTask { + git_provider: Provider; + issue_number: number; + repo: string; + title: string; + task_type: SuggestedTaskType; +} + +export interface SuggestedTaskGroup { + title: string; + tasks: SuggestedTask[]; +} diff --git a/src/utils/user-facing-error.test.ts b/src/utils/user-facing-error.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..410eca8feb0b3df43a5aa1ce416672fd05c412f8 --- /dev/null +++ b/src/utils/user-facing-error.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import { + BACKEND_REQUEST_TIMEOUT_MESSAGE, + CORS_OR_NETWORK_ERROR_MESSAGE, + getRawErrorMessage, + getUserFacingConnectionErrorMessage, + isBackendRequestTimeoutMessage, + isCorsOrNetworkError, + isCorsOrNetworkErrorMessage, +} from "./user-facing-error"; + +describe("user-facing connection errors", () => { + it("maps browser fetch failures to the CORS or network message", () => { + expect( + getUserFacingConnectionErrorMessage( + new Error("Request failed: Failed to fetch"), + ), + ).toBe(CORS_OR_NETWORK_ERROR_MESSAGE); + }); + + it("looks through wrapped causes from fetch-based clients", () => { + expect( + getUserFacingConnectionErrorMessage( + new Error("Request failed", { + cause: new TypeError("Failed to fetch"), + }), + ), + ).toBe(CORS_OR_NETWORK_ERROR_MESSAGE); + }); + + it("detects existing CORS or network labels", () => { + expect(isCorsOrNetworkErrorMessage(CORS_OR_NETWORK_ERROR_MESSAGE)).toBe( + true, + ); + }); + + it("maps request timeouts to a backend timeout message", () => { + expect( + getUserFacingConnectionErrorMessage( + new Error("Request timeout after 5000ms"), + ), + ).toBe(BACKEND_REQUEST_TIMEOUT_MESSAGE); + }); + + it("leaves ordinary server errors intact", () => { + expect( + getUserFacingConnectionErrorMessage(new Error("Invalid API key")), + ).toBe("Invalid API key"); + }); + + it.each([ + "Disconnected (check CORS or network)", + "Disconnected (check URL or network)", + "blocked by CORS", + "failed to fetch", + "network error", + "load failed", + "NetworkError when attempting to fetch resource", + "CORS policy blocked this request", + ])("recognizes network failure wording: %s", (message) => { + expect(isCorsOrNetworkErrorMessage(message)).toBe(true); + }); + + it("rejects absent and ordinary messages as network failures", () => { + expect(isCorsOrNetworkErrorMessage(undefined)).toBe(false); + expect(isCorsOrNetworkErrorMessage("ordinary failure")).toBe(false); + }); + + it.each(["CORS configuration issue", "request blocked by policy"])( + "does not classify incomplete network wording: %s", + (message) => expect(isCorsOrNetworkErrorMessage(message)).toBe(false), + ); + + it.each([ + "request timeout", + "timeout after 10s", + "backend request timed out", + ])("recognizes backend timeout wording: %s", (message) => + expect(isBackendRequestTimeoutMessage(message)).toBe(true), + ); + + it("rejects absent and ordinary messages as timeouts", () => { + expect(isBackendRequestTimeoutMessage(null)).toBe(false); + expect(isBackendRequestTimeoutMessage("ordinary failure")).toBe(false); + }); + + it("collects messages from strings and record-shaped causes", () => { + const wrapped = { + message: "outer", + cause: { message: "Failed to fetch" }, + }; + expect(getRawErrorMessage(wrapped)).toBe("outer"); + expect(isCorsOrNetworkError(wrapped)).toBe(true); + expect(getRawErrorMessage("plain string")).toBe("plain string"); + expect(getRawErrorMessage({ message: 42, cause: "nested" })).toBe("nested"); + }); + + it("ignores callable values with error-like properties", () => { + const callable = Object.assign(() => undefined, { + message: "Failed to fetch", + }); + + expect(getRawErrorMessage(callable)).toBeNull(); + }); + + it("handles empty, primitive, cyclic, and deeply nested causes", () => { + expect(getRawErrorMessage(undefined)).toBeNull(); + expect(getRawErrorMessage(42)).toBeNull(); + expect(getRawErrorMessage("")).toBeNull(); + expect(getRawErrorMessage(new Error(""))).toBeNull(); + expect(getRawErrorMessage({ message: "" })).toBeNull(); + + const cyclic: { message: string; cause?: unknown } = { message: "cycle" }; + cyclic.cause = cyclic; + expect(getRawErrorMessage(cyclic)).toBe("cycle"); + + const tooDeep = { + message: "one", + cause: { + message: "two", + cause: { + message: "three", + cause: { + message: "four", + cause: { message: "Failed to fetch" }, + }, + }, + }, + }; + expect(isCorsOrNetworkError(tooDeep)).toBe(false); + }); + + it("stops collecting when a cyclic cause is encountered again", () => { + let messageReads = 0; + const cyclic: { readonly message: string; cause?: unknown } = { + get message() { + messageReads += 1; + return messageReads === 1 ? "ordinary failure" : "Failed to fetch"; + }, + }; + cyclic.cause = cyclic; + + expect(getUserFacingConnectionErrorMessage(cyclic)).toBe( + "ordinary failure", + ); + }); + + it("returns null when no user-facing message can be extracted", () => { + expect(getUserFacingConnectionErrorMessage({})).toBeNull(); + }); + + it("exports actionable connection guidance on module initialization", async () => { + vi.resetModules(); + + try { + const fresh = await import("./user-facing-error"); + + expect(fresh.CORS_OR_NETWORK_ERROR_MESSAGE).toContain( + "check URL or network", + ); + expect(fresh.BACKEND_REQUEST_TIMEOUT_MESSAGE).toContain( + "request timed out", + ); + } finally { + vi.resetModules(); + } + }); +}); diff --git a/src/utils/user-facing-error.ts b/src/utils/user-facing-error.ts new file mode 100644 index 0000000000000000000000000000000000000000..50615d8620450e05e93c213eab86dab1df516f25 --- /dev/null +++ b/src/utils/user-facing-error.ts @@ -0,0 +1,92 @@ +export const CORS_OR_NETWORK_ERROR_MESSAGE = + "Disconnected (check URL or network). Check that the backend URL is correct and the backend server is reachable. If the backend is on another origin, check that it allows this frontend origin."; + +export const BACKEND_REQUEST_TIMEOUT_MESSAGE = + "Disconnected (request timed out). Check that the backend URL is correct and reachable."; + +const MAX_CAUSE_DEPTH = 4; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function collectErrorMessages(error: unknown): string[] { + const messages: string[] = []; + const seen = new Set(); + let current: unknown = error; + let depth = 0; + + while (current !== undefined && current !== null && depth < MAX_CAUSE_DEPTH) { + if (seen.has(current)) break; + seen.add(current); + + if (current instanceof Error) { + if (current.message) messages.push(current.message); + current = current.cause; + } else if (typeof current === "string") { + if (current) messages.push(current); + break; + } else if (isRecord(current)) { + const message = current.message; + if (typeof message === "string" && message) messages.push(message); + current = current.cause; + } else { + break; + } + + depth += 1; + } + + return messages; +} + +export function getRawErrorMessage(error: unknown): string | null { + return collectErrorMessages(error)[0] ?? null; +} + +export function isCorsOrNetworkErrorMessage( + message: string | null | undefined, +): boolean { + if (!message) return false; + const normalized = message.toLowerCase(); + + return ( + normalized.includes("disconnected (check cors or network)") || + normalized.includes("disconnected (check url or network)") || + normalized.includes("blocked by cors") || + normalized.includes("failed to fetch") || + normalized.includes("network error") || + normalized.includes("load failed") || + normalized.includes("networkerror when attempting to fetch resource") || + (normalized.includes("cors") && normalized.includes("blocked")) + ); +} + +export function isCorsOrNetworkError(error: unknown): boolean { + return collectErrorMessages(error).some(isCorsOrNetworkErrorMessage); +} + +export function isBackendRequestTimeoutMessage( + message: string | null | undefined, +): boolean { + if (!message) return false; + const normalized = message.toLowerCase(); + return ( + normalized.includes("request timeout") || + normalized.includes("timeout after") || + normalized.includes("backend request timed out") + ); +} + +export function getUserFacingConnectionErrorMessage( + error: unknown, +): string | null { + const messages = collectErrorMessages(error); + if (messages.some(isCorsOrNetworkErrorMessage)) { + return CORS_OR_NETWORK_ERROR_MESSAGE; + } + if (messages.some(isBackendRequestTimeoutMessage)) { + return BACKEND_REQUEST_TIMEOUT_MESSAGE; + } + return messages[0] ?? null; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c7a50eea988fe0ee05b772471eab4172363f8bd --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,785 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { Provider } from "#/types/settings"; +import { SuggestedTaskGroup } from "#/utils/types"; +import { ConversationStatus } from "#/types/conversation-status"; +import { GitRepository } from "#/types/git"; +import { sanitizeQuery } from "#/utils/sanitize-query"; +import { PRODUCT_URL } from "#/utils/constants"; +import { AgentState } from "#/types/agent-state"; +import { I18nKey } from "#/i18n/declaration"; +import { getTaskStatusI18nKey } from "#/utils/status"; +import type { AppConversationStartTaskStatus } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { + OH_STATUS_ERROR_COLOR, + OH_STATUS_SUCCESS_COLOR, +} from "#/constants/status-colors"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +/** + * Trigger a download for a provided Blob with the given filename + */ +export const downloadBlob = (blob: Blob, filename: string): void => { + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +}; + +/** + * Get the numeric height value from an element's style property + * @param el The HTML element to get the height from + * @param fallback The fallback value to return if style height is invalid + * @returns The numeric height value in pixels, or the fallback value + * + * @example + * getStyleHeightPx(element, 20) // Returns 20 if element.style.height is "auto" or invalid + * getStyleHeightPx(element, 20) // Returns 100 if element.style.height is "100px" + */ +export const getStyleHeightPx = (el: HTMLElement, fallback: number): number => { + const elementHeight = parseFloat(el.style.height || ""); + return Number.isFinite(elementHeight) ? elementHeight : fallback; +}; + +/** + * Set the height style property of an element to a specific pixel value + * @param el The HTML element to set the height for + * @param height The height value in pixels to set + * + * @example + * setStyleHeightPx(element, 100) // Sets element.style.height to "100px" + * setStyleHeightPx(textarea, 200) // Sets textarea.style.height to "200px" + */ +export const setStyleHeightPx = (el: HTMLElement, height: number): void => { + el.style.setProperty("height", `${height}px`); +}; + +/** + * Detect a phone/tablet user agent (Android, iPhone, iPad, …). + * Unlike isMobileDevice, this ignores touch capability, so a desktop OS with + * a touchscreen (e.g. a Windows 2-in-1) is NOT matched. Use this when the + * decision depends on having a physical keyboard rather than on touch input. + */ +export const isMobileUserAgent = (): boolean => + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ); + +/** + * Detect if the user is on a mobile device. + * Touch support alone is not sufficient — touchscreen laptops have touch + * but use a mouse/trackpad as primary input. We check that the primary + * pointing device is coarse (finger) to avoid false positives. + */ +export const isMobileDevice = (): boolean => { + if (isMobileUserAgent()) return true; + + const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0; + if (!hasTouch) return false; + + // If matchMedia is available, check whether the primary pointer is fine + // (mouse/trackpad). Touchscreen laptops report fine, real mobile devices don't. + if (typeof window.matchMedia === "function") { + return !window.matchMedia("(pointer: fine)").matches; + } + + // Fallback: touch present but no matchMedia — assume mobile + return true; +}; + +/** + * Checks if the current domain is the production domain + * @returns True if the current domain matches the production URL + */ +export const isProductionDomain = (): boolean => + window.location.origin === PRODUCT_URL.PRODUCTION; + +interface EventActionHistory { + args?: { + LLM_API_KEY?: string; + [key: string]: unknown; + }; + extras?: { + open_page_urls: string[]; + active_page_index: number; + dom_object: Record; + axtree_object: Record; + extra_element_properties: Record; + last_browser_action: string; + last_browser_action_error: unknown; + focused_element_bid: string; + }; + [key: string]: unknown; +} + +export const removeUnwantedKeys = ( + data: EventActionHistory[], +): EventActionHistory[] => { + const UNDESIRED_KEYS = [ + "open_page_urls", + "active_page_index", + "dom_object", + "axtree_object", + "extra_element_properties", + "last_browser_action", + "last_browser_action_error", + "focused_element_bid", + ]; + + return data + .filter((item) => { + // Skip items that have a status key + if ("status" in item) { + return false; + } + return true; + }) + .map((item) => { + // Create a shallow copy of item + const newItem = { ...item }; + + // Check if extras exists and delete it from a new extras object + if (newItem.extras) { + const newExtras = { ...newItem.extras }; + UNDESIRED_KEYS.forEach((key) => { + delete newExtras[key as keyof typeof newExtras]; + }); + newItem.extras = newExtras; + } + + return newItem; + }); +}; + +/** + * Get file extension from file name in uppercase format + * @param fileName The file name to extract extension from + * @returns The file extension in uppercase, or "FILE" if no extension found + * + * @example + * getFileExtension("document.pdf") // "PDF" + * getFileExtension("image.jpeg") // "JPEG" + * getFileExtension("noextension") // "FILE" + */ +export const getFileExtension = (fileName: string): string => { + const extension = fileName.split(".").pop()?.toUpperCase(); + return extension || "FILE"; +}; + +/** + * Whether to use the installation-scoped repo flow + * (`/api/v1/git/installations/search` → `/api/v1/git/repositories/search?installation_id=…`) + * for the given provider/backend combo. + * + * Mirrors OpenHands' cloud frontend (parameterized by `app_mode`): + * - bitbucket / bitbucket_data_center → always installation-based + * - github → installation-based ONLY when the active backend is cloud + * - gitlab / azure_devops / forgejo → direct (search) flow + * + * `appMode` accepts the active backend `kind` ("local" | "cloud") so call + * sites can hand it through directly. + */ +export const shouldUseInstallationRepos = ( + provider: Provider | null | undefined, + appMode?: "local" | "cloud", +) => { + if (!provider) return false; + + switch (provider) { + case "bitbucket": + case "bitbucket_data_center": + return true; + case "github": + return appMode === "cloud"; + default: + return false; + } +}; + +export const getGitProviderBaseUrl = ( + gitProvider: Provider, + host?: string | null, +): string => { + // If custom host provided, use it (with https:// prefix if needed) + if (host && host.trim() !== "") { + return host.startsWith("http") ? host : `https://${host}`; + } + + // Fall back to defaults + switch (gitProvider) { + case "github": + return "https://github.com"; + case "gitlab": + return "https://gitlab.com"; + case "bitbucket": + return "https://bitbucket.org"; + case "azure_devops": + return "https://dev.azure.com"; + case "forgejo": + // Default UI links to Codeberg unless a custom host is available in settings + // Note: UI link builders don't currently receive host; consider plumbing settings if needed + return "https://codeberg.org"; + default: + return ""; + } +}; + +/** + * Get the name of the git provider + * @param gitProvider The git provider + * @returns The name of the git provider + */ +export const getProviderName = (gitProvider: Provider) => { + if (gitProvider === "gitlab") return "GitLab"; + if (gitProvider === "bitbucket") return "Bitbucket"; + if (gitProvider === "bitbucket_data_center") return "Bitbucket Data Center"; + if (gitProvider === "azure_devops") return "Azure DevOps"; + if (gitProvider === "forgejo") return "Forgejo"; + return "GitHub"; +}; + +/** + * Get the name of the PR + * @param isGitLab Whether the git provider is GitLab + * @returns The name of the PR + */ +export const getPR = (isGitLab: boolean) => + isGitLab ? "merge request" : "pull request"; + +/** + * Get the short name of the PR + * @param isGitLab Whether the git provider is GitLab + * @returns The short name of the PR + */ +export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR"); + +/** + * Construct the pull request (merge request) URL for different providers + * @param prNumber The pull request number + * @param provider The git provider + * @param repositoryName The repository name in format "owner/repo" + * @returns The pull request URL + * + * @example + * constructPullRequestUrl(123, "github", "owner/repo") // "https://github.com/owner/repo/pull/123" + * constructPullRequestUrl(456, "gitlab", "owner/repo") // "https://gitlab.com/owner/repo/-/merge_requests/456" + * constructPullRequestUrl(789, "bitbucket", "owner/repo") // "https://bitbucket.org/owner/repo/pull-requests/789" + * constructPullRequestUrl(789, "bitbucket", "PROJECT/repo", "server.com") // "https://server.com/projects/PROJECT/repos/repo/pull-requests/789" + */ +export const constructPullRequestUrl = ( + prNumber: number, + provider: Provider, + repositoryName: string, + host?: string | null, +): string => { + const baseUrl = getGitProviderBaseUrl(provider, host); + + switch (provider) { + case "github": + return `${baseUrl}/${repositoryName}/pull/${prNumber}`; + case "forgejo": + return `${baseUrl}/${repositoryName}/pull/${prNumber}`; + case "gitlab": + return `${baseUrl}/${repositoryName}/-/merge_requests/${prNumber}`; + case "bitbucket": + return `${baseUrl}/${repositoryName}/pull-requests/${prNumber}`; + case "bitbucket_data_center": { + const [project, repo] = repositoryName.split("/"); + return `${baseUrl}/projects/${project}/repos/${repo}/pull-requests/${prNumber}`; + } + case "azure_devops": { + // Azure DevOps format: org/project/repo + const parts = repositoryName.split("/"); + if (parts.length === 3) { + const [org, project, repo] = parts; + return `${baseUrl}/${org}/${project}/_git/${repo}/pullrequest/${prNumber}`; + } + return ""; + } + default: + return ""; + } +}; + +/** + * Construct the repository URL for different providers + * @param provider The git provider + * @param repositoryName The repository name in format "owner/repo" + * @returns The repository URL + * + * @example + * constructRepositoryUrl("github", "owner/repo") // "https://github.com/owner/repo" + * constructRepositoryUrl("gitlab", "owner/repo") // "https://gitlab.com/owner/repo" + * constructRepositoryUrl("bitbucket", "owner/repo") // "https://bitbucket.org/owner/repo" + */ +export const constructRepositoryUrl = ( + provider: Provider, + repositoryName: string, + host?: string | null, +): string => { + const baseUrl = getGitProviderBaseUrl(provider, host); + if (provider === "bitbucket_data_center") { + const [project, repo] = repositoryName.split("/"); + return `${baseUrl}/projects/${project}/repos/${repo}`; + } + return `${baseUrl}/${repositoryName}`; +}; + +/** + * Percent-encode a branch name for use as a URL path component. Each + * `/`-separated segment is encoded on its own so nested branch names such as + * `release/1.0` keep their slashes while `#`, `%` and `&` are escaped. + */ +const encodeBranchPath = (branchName: string): string => + branchName.split("/").map(encodeURIComponent).join("/"); + +/** + * Construct the branch URL for different providers + * @param provider The git provider + * @param repositoryName The repository name in format "owner/repo" + * @param branchName The branch name + * @param host Optional custom host for self-hosted instances + * @returns The branch URL + * + * @example + * constructBranchUrl("github", "owner/repo", "main") // "https://github.com/owner/repo/tree/main" + * constructBranchUrl("gitlab", "owner/repo", "develop") // "https://gitlab.com/owner/repo/-/tree/develop" + * constructBranchUrl("bitbucket", "owner/repo", "feature") // "https://bitbucket.org/owner/repo/src/feature" + * constructBranchUrl("bitbucket", "PROJECT/repo", "feature", "server.com") // "https://server.com/projects/PROJECT/repos/repo/browse?at=refs/heads/feature" + */ +export const constructBranchUrl = ( + provider: Provider, + repositoryName: string, + branchName: string, + host?: string | null, +): string => { + const baseUrl = getGitProviderBaseUrl(provider, host); + + switch (provider) { + case "github": + return `${baseUrl}/${repositoryName}/tree/${encodeBranchPath(branchName)}`; + case "forgejo": + return `${baseUrl}/${repositoryName}/src/branch/${encodeBranchPath(branchName)}`; + case "gitlab": + return `${baseUrl}/${repositoryName}/-/tree/${encodeBranchPath(branchName)}`; + case "bitbucket": + return `${baseUrl}/${repositoryName}/src/${encodeBranchPath(branchName)}`; + case "bitbucket_data_center": { + // Bitbucket Server format: /projects/{PROJECT}/repos/{repo}/browse?at=refs/heads/{branch} + const parts = repositoryName.split("/"); + if (parts.length >= 2) { + const [project, repo] = parts; + // The branch is one query value here, so encode it whole: an + // unencoded `&` would start a new query parameter. + return `${baseUrl}/projects/${project}/repos/${repo}/browse?at=refs/heads/${encodeURIComponent(branchName)}`; + } + return ""; + } + case "azure_devops": { + // Azure DevOps format: org/project/repo + const parts = repositoryName.split("/"); + if (parts.length === 3) { + const [org, project, repo] = parts; + // The branch is one query value here, so encode it whole: an + // unencoded `&` would start a new query parameter. + return `${baseUrl}/${org}/${project}/_git/${repo}?version=GB${encodeURIComponent(branchName)}`; + } + return ""; + } + default: + return ""; + } +}; + +// Git Action Prompts + +const GIT_COMMIT_PROMPT = + "Please review the current changes and create a git commit with a concise, descriptive message."; + +export const getGitCommitPrompt = (): string => GIT_COMMIT_PROMPT; + +/** + * Generate a git pull prompt + * @returns The git pull prompt + */ +export const getGitPullPrompt = (): string => + "Please pull the latest code from the repository."; + +/** + * Generate a git push prompt + * @param gitProvider The git provider + * @returns The git push prompt + */ +export const getGitPushPrompt = (gitProvider: Provider): string => { + const providerName = getProviderName(gitProvider); + const pr = getPR(gitProvider === "gitlab"); + + return `Please push the changes to a remote branch on ${providerName}, but do NOT create a ${pr}. Check your current branch name first - if it's main, master, deploy, or another common default branch name, create a new branch with a descriptive name related to your changes. Otherwise, use the exact SAME branch name as the one you are currently on.`; +}; + +/** + * Generate a create pull request prompt + * @param gitProvider The git provider + * @returns The create PR prompt + */ +export const getCreatePRPrompt = (gitProvider: Provider): string => { + const providerName = getProviderName(gitProvider); + const pr = getPR(gitProvider === "gitlab"); + const prShort = getPRShort(gitProvider === "gitlab"); + + return `Please push the changes to ${providerName} and open a ${pr}. If you're on a default branch (e.g., main, master, deploy), create a new branch with a descriptive name otherwise use the current branch. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`; +}; + +/** + * Generate a push to existing PR prompt + * @param gitProvider The git provider + * @returns The push to PR prompt + */ +export const getPushToPRPrompt = (gitProvider: Provider): string => { + const pr = getPR(gitProvider === "gitlab"); + + return `Please push the latest changes to the existing ${pr}.`; +}; + +/** + * Generate a create new branch prompt + * @returns The create new branch prompt + */ +export const getCreateNewBranchPrompt = (): string => + "Please create a new branch with a descriptive name related to the work you plan to do."; + +// Helper functions +export function getTotalTaskCount( + suggestedTasks: SuggestedTaskGroup[] | undefined, +): number { + if (!suggestedTasks) return 0; + return suggestedTasks.flatMap((group) => group.tasks).length; +} + +export function getLimitedTaskGroups( + suggestedTasks: SuggestedTaskGroup[], + maxTasks: number, +): SuggestedTaskGroup[] { + const limitedGroups: SuggestedTaskGroup[] = []; + let taskCount = 0; + + for (const group of suggestedTasks) { + if (taskCount >= maxTasks) break; + + const remainingTasksNeeded = maxTasks - taskCount; + const tasksToShow = group.tasks.slice(0, remainingTasksNeeded); + + if (tasksToShow.length > 0) { + limitedGroups.push({ + ...group, + tasks: tasksToShow, + }); + taskCount += tasksToShow.length; + } + } + + return limitedGroups; +} + +export function getDisplayedTaskGroups( + suggestedTasks: SuggestedTaskGroup[] | undefined, + isExpanded: boolean, +): SuggestedTaskGroup[] { + if (!suggestedTasks || suggestedTasks.length === 0) { + return []; + } + + if (isExpanded) { + return suggestedTasks; + } + + return getLimitedTaskGroups(suggestedTasks, 3); +} + +/** + * Get the label for a conversation status + * @param status The conversation status + * @returns The localized label for the status + */ +export const getConversationStatusLabel = ( + status: ConversationStatus, +): string => { + switch (status) { + case "STOPPED": + return "COMMON$STOPPED"; + case "RUNNING": + return "COMMON$RUNNING"; + case "STARTING": + return "COMMON$STARTING"; + case "ERROR": + return "COMMON$ERROR"; + case "ARCHIVED": + return "COMMON$ARCHIVED"; // Use STOPPED for archived conversations + default: + return "COMMON$UNKNOWN"; + } +}; + +// Task Tracking Utility Functions + +/** + * Get the status icon for a task status + * @param status The task status + * @returns The emoji icon for the status + */ +export const getStatusIcon = (status: string) => { + switch (status) { + case "todo": + return "⏳"; + case "in_progress": + return "🔄"; + case "done": + return "✅"; + default: + return "❓"; + } +}; + +/** + * Get the CSS class names for a task status badge + * @param status The task status + * @returns The CSS class names for styling the status badge + */ +export const getStatusClassName = (status: string) => { + if (status === "done") { + return "bg-green-800 text-green-200"; + } + if (status === "in_progress") { + return "bg-yellow-800 text-yellow-200"; + } + return "bg-tertiary text-[var(--oh-text-tertiary)]"; +}; + +/** + * Helper function to apply client-side filtering based on search query + * @param repo The Git repository to check + * @param searchQuery The search query string + * @returns True if the repository should be included based on the search query + */ +export const shouldIncludeRepository = ( + repo: GitRepository, + searchQuery: string, +): boolean => { + if (!searchQuery.trim()) { + return true; + } + + const sanitizedQuery = sanitizeQuery(searchQuery); + const sanitizedRepoName = sanitizeQuery(repo.full_name); + return sanitizedRepoName.includes(sanitizedQuery); +}; + +/** + * Get the OpenHands query string based on the provider + * @param provider The git provider + * @returns The query string for searching OpenHands repositories + */ +export const getOpenHandsQuery = (provider: Provider | null): string => { + const providerRepositorySuffix: Record = { + gitlab: "openhands-config", + azure_devops: "openhands-config", + default: ".openhands", + } as const; + + return provider && provider in providerRepositorySuffix + ? providerRepositorySuffix[provider] + : providerRepositorySuffix.default; +}; + +/** + * Check if a repository has the OpenHands suffix based on the provider + * @param repo The Git repository to check + * @param provider The git provider + * @returns True if the repository has the OpenHands suffix + */ +export const hasOpenHandsSuffix = ( + repo: GitRepository, + provider: Provider | null, +): boolean => repo.full_name.endsWith(`/${getOpenHandsQuery(provider)}`); + +/** + * Build headers for V1 API requests that require session authentication + * @param sessionApiKey Session API key for authentication + * @returns Headers object with X-Session-API-Key if provided + */ +export const buildSessionHeaders = ( + sessionApiKey?: string | null, +): Record => { + const headers: Record = {}; + if (sessionApiKey) { + headers["X-Session-API-Key"] = sessionApiKey; + } + return headers; +}; + +/** + * Check if a task is currently being polled (loading state) + * @param taskStatus The task status string (e.g., "WORKING", "ERROR", "READY") + * @returns True if the task is in a loading state (not ERROR and not READY) + * + * @example + * isTaskPolling("WORKING") // Returns true + * isTaskPolling("PREPARING_REPOSITORY") // Returns true + * isTaskPolling("READY") // Returns false + * isTaskPolling("ERROR") // Returns false + * isTaskPolling(null) // Returns false + * isTaskPolling(undefined) // Returns false + */ +export const isTaskPolling = (taskStatus: string | null | undefined): boolean => + !!taskStatus && taskStatus !== "ERROR" && taskStatus !== "READY"; + +/** + * Get the appropriate color based on agent status + * @param options Configuration object for status color calculation + * @param options.isPausing Whether the agent is currently pausing + * @param options.isTask Whether we're polling a task + * @param options.taskStatus The task status string (e.g., "ERROR", "READY") + * @param options.isStartingStatus Whether the agent is in a starting state (LOADING or INIT) + * @param options.isStopStatus Whether the conversation status is STOPPED + * @param options.curAgentState The current agent state + * @returns The hex color code for the status + * + * @example + * getStatusColor({ + * isPausing: false, + * isTask: false, + * taskStatus: undefined, + * isStartingStatus: false, + * isStopStatus: false, + * curAgentState: AgentState.RUNNING + * }) // Returns "var(--oh-status-success)" + */ +export const getStatusColor = (options: { + isPausing: boolean; + isTask: boolean; + taskStatus?: string | null; + isStartingStatus: boolean; + isStopStatus: boolean; + curAgentState: AgentState; +}): string => { + const { + isPausing, + isTask, + taskStatus, + isStartingStatus, + isStopStatus, + curAgentState, + } = options; + + // Show pausing status + if (isPausing) { + return "#FFD600"; + } + + // Show task status if we're polling a task + if (isTask && taskStatus) { + if (taskStatus === "ERROR") { + return OH_STATUS_ERROR_COLOR; + } + return "#FFD600"; + } + + if (isStartingStatus) { + return "#FFD600"; + } + if (isStopStatus) { + return "#ffffff"; + } + if (curAgentState === AgentState.ERROR) { + return OH_STATUS_ERROR_COLOR; + } + return OH_STATUS_SUCCESS_COLOR; +}; + +interface GetStatusTextArgs { + isPausing: boolean; + isTask: boolean; + taskStatus?: AppConversationStartTaskStatus | null; + taskDetail?: string | null; + isStartingStatus: boolean; + isStopStatus: boolean; + curAgentState: AgentState; + errorMessage?: string | null; + t: (t: string) => string; +} + +/** + * Get the server status text based on agent and task state + * + * @param options Configuration object for status text calculation + * @param options.isPausing Whether the agent is currently pausing + * @param options.isTask Whether we're polling a task + * @param options.taskStatus The task status string (e.g., "ERROR", "READY") + * @param options.taskDetail Optional task-specific detail text + * @param options.isStartingStatus Whether the conversation is in STARTING state + * @param options.isStopStatus Whether the conversation is STOPPED + * @param options.curAgentState The current agent state + * @param options.errorMessage Optional agent error message + * @returns Localized human-readable status text + * + * @example + * getStatusText({ + * isPausing: false, + * isTask: true, + * taskStatus: "STARTING_CONVERSATION", + * taskDetail: null, + * isStartingStatus: false, + * isStopStatus: false, + * curAgentState: AgentState.RUNNING + * }) // Returns "Starting conversation" + */ +export function getStatusText({ + isPausing = false, + isTask, + taskStatus, + taskDetail, + isStartingStatus, + isStopStatus, + curAgentState, + errorMessage, + t, +}: GetStatusTextArgs): string { + // Show pausing status + if (isPausing) { + return t(I18nKey.COMMON$STOPPING); + } + + // Show task status if we're polling a task + if (isTask && taskStatus) { + if (taskStatus === "ERROR") { + return taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION); + } + + if (taskStatus === "READY") { + return t(I18nKey.CONVERSATION$READY); + } + + return taskDetail || t(getTaskStatusI18nKey(taskStatus)); + } + + if (isStartingStatus) { + return t(I18nKey.COMMON$STARTING); + } + + if (isStopStatus) { + return t(I18nKey.COMMON$SERVER_STOPPED); + } + + if (curAgentState === AgentState.ERROR) { + return errorMessage || t(I18nKey.COMMON$ERROR); + } + + return t(I18nKey.COMMON$RUNNING); +} diff --git a/src/utils/vscode-origin.ts b/src/utils/vscode-origin.ts new file mode 100644 index 0000000000000000000000000000000000000000..c12a4cab60d3e38d9e18aa80da8b3b5364946448 --- /dev/null +++ b/src/utils/vscode-origin.ts @@ -0,0 +1,109 @@ +/** + * Whether *this browser origin* can serve the editor, and whether a given + * editor URL is one it will actually route. + * + * Editor availability is not a property of the agent-server process alone. The + * agent-server answers `/api/vscode/status` for whatever conversation you ask + * about, and builds `/api/vscode/url` from the `base_url` the caller sends — + * which is always `window.location.origin`. So a server can truthfully report + * an editor that the page's own origin has no route to. Two supported layouts + * hit that: + * + * - **Public mode** (`docker/entrypoint.sh`, `--auth-required`): a second + * static server on its own port, sharing the same agent-server but + * deliberately without the editor route, because the editor's connection + * token is the session API key (OpenHands/software-agent-sdk#4317) and that + * origin exists to test the *unauthenticated* case. + * - **Extra backends** (`scripts/dev-extra-backend.mjs`): a second + * agent-server registered from a browser whose origin belongs to another + * stack. That origin's editor route, if any, points at the *bundled* + * stack's editor — a different container's workspace. + * + * In both cases a control gated only on the server probe renders and then + * resolves to the canvas SPA or, worse, someone else's workspace. So the + * frontend has to intersect the server's capability with the origin's route + * table, and this module is the origin half of that. + */ + +const VSCODE_BASE_PATH_WINDOW_KEY = "__AGENT_CANVAS_VSCODE_BASE_PATH__"; + +/** + * The path prefix this origin serves the editor under, or `null` if it serves + * no editor at all. + * + * Two sources, in order — mirroring `getRawRuntimeServicesInfo` in + * `agent-server-adapter.ts`: + * 1. `VITE_VSCODE_BASE_PATH` — baked in at build time by the dev launchers + * that proxy the editor (`npm run dev`, dev:static). + * 2. `window.__AGENT_CANVAS_VSCODE_BASE_PATH__` — injected into index.html at + * serve time by `scripts/static-server.mjs --vscode-base-path `, + * which refuses to start unless a matching `--route` exists. That is the + * path used by static builds (the Docker image, the published binary), + * where the env var is empty in the prebuilt bundle. + * + * Absent from both means "this origin does not serve the editor" and the + * control is hidden. That is the pre-existing behavior for every local backend, + * so a deployment this cannot detect is no worse off than before. + */ +export function getOriginVSCodeBasePath(): string | null { + const envRaw = import.meta.env.VITE_VSCODE_BASE_PATH?.trim(); + if (envRaw) return normalizePrefix(envRaw); + + if (typeof window !== "undefined") { + const injected = (window as unknown as Record)[ + VSCODE_BASE_PATH_WINDOW_KEY + ]; + if (typeof injected === "string" && injected.trim()) { + return normalizePrefix(injected.trim()); + } + } + + return null; +} + +function normalizePrefix(value: string): string { + const withLeadingSlash = value.startsWith("/") ? value : `/${value}`; + return withLeadingSlash.length > 1 + ? withLeadingSlash.replace(/\/+$/, "") + : withLeadingSlash; +} + +/** + * Whether `url` is an editor URL this origin will actually route. + * + * The agent-server appends its own `vscode_base_path` to the origin we send it, + * so the returned URL carries the prefix of the server that answered — not + * necessarily the prefix this origin routes. An extra backend that configures + * no prefix yields `/?tkn=…`, which is the canvas root: same origin, + * wrong destination. Comparing paths is what separates the two. + */ +export function isVSCodeUrlServedByOrigin( + url: string | null | undefined, + basePath: string | null, +): boolean { + if (!url || !basePath) return false; + + let parsed: URL; + try { + parsed = new URL( + url, + typeof window !== "undefined" ? window.location.href : undefined, + ); + } catch { + return false; + } + + // A cross-origin URL is somebody else's problem to route, not ours to vouch + // for. The local path always builds on `window.location.origin`, so this only + // fires on a value we did not construct. + if ( + typeof window !== "undefined" && + parsed.origin !== window.location.origin + ) { + return false; + } + + return ( + parsed.pathname === basePath || parsed.pathname.startsWith(`${basePath}/`) + ); +} diff --git a/src/utils/vscode-url-helper.ts b/src/utils/vscode-url-helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..21f32a66a8e0196700d7ee53a57fbd5eedf8c3a1 --- /dev/null +++ b/src/utils/vscode-url-helper.ts @@ -0,0 +1,31 @@ +/** + * Helper function to transform VS Code URLs + * + * This function checks if a VS Code URL points to localhost and replaces it with + * the current window's hostname if they don't match. + * + * @param vsCodeUrl The original VS Code URL from the backend + * @returns The transformed URL with the correct hostname + */ +export function transformVSCodeUrl(vsCodeUrl: string | null): string | null { + if (!vsCodeUrl) return null; + + try { + const url = new URL(vsCodeUrl); + + // Check if the URL points to localhost + if ( + url.hostname === "localhost" && + window.location.hostname !== "localhost" + ) { + // Replace localhost with the current hostname + url.hostname = window.location.hostname; + return url.toString(); + } + + return vsCodeUrl; + } catch { + // Silently handle the error and return the original URL + return vsCodeUrl; + } +} diff --git a/src/utils/websocket-auth.ts b/src/utils/websocket-auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..6404cc36be298e3e8233bc0badc778e73d99aada --- /dev/null +++ b/src/utils/websocket-auth.ts @@ -0,0 +1,18 @@ +const WEBSOCKET_AUTH_TYPE = "auth"; +const WEBSOCKET_SESSION_KEY_FIELD = "session_api_key"; + +export function sendWebSocketAuth( + socket: Pick, + sessionApiKey: string | null | undefined, +): void { + if (!sessionApiKey) { + return; + } + + socket.send( + JSON.stringify({ + type: WEBSOCKET_AUTH_TYPE, + [WEBSOCKET_SESSION_KEY_FIELD]: sessionApiKey, + }), + ); +} diff --git a/src/utils/websocket-handshake.ts b/src/utils/websocket-handshake.ts new file mode 100644 index 0000000000000000000000000000000000000000..52a6a9207d8aafb1558e60998dcb009f49a2aa9b --- /dev/null +++ b/src/utils/websocket-handshake.ts @@ -0,0 +1,26 @@ +// Browsers never time out a socket stuck in CONNECTING on their own, and +// Chrome serializes WebSocket handshakes per host — a hung handshake also +// blocks every other socket to the same origin until it settles. Close it +// after this long so a retry (and any other queued handshake) can proceed. +const HANDSHAKE_TIMEOUT_MS = 10_000; + +/** + * Watches a freshly constructed socket's handshake and closes the socket if it + * is still in CONNECTING when the timeout elapses. close() fires the socket's + * own error/close events (code 1006), so the abort flows through the caller's + * normal close handling rather than a separate failure path. + * + * Returns a cancel function. The caller must invoke it once the handshake + * settles — in its open and close handlers, and in any cleanup that detaches + * those handlers — so the watchdog never outlives the socket it watches. + */ +export function startHandshakeWatchdog( + ws: Pick, +): () => void { + const timeout = setTimeout(() => { + if (ws.readyState === WebSocket.CONNECTING) { + ws.close(); + } + }, HANDSHAKE_TIMEOUT_MS); + return () => clearTimeout(timeout); +} diff --git a/src/utils/websocket-url.ts b/src/utils/websocket-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f060900c61af64041a0e4ff05394b7304cfa7f0 --- /dev/null +++ b/src/utils/websocket-url.ts @@ -0,0 +1,135 @@ +/** + * Extracts the base host from conversation URL + * @param conversationUrl The conversation URL containing host/port (e.g., "http://localhost:3000/api/conversations/123") + * @returns Base host (e.g., "localhost:3000") or window.location.host as fallback + */ +function extractBaseHost(conversationUrl: string | null | undefined): string { + if (conversationUrl && !conversationUrl.startsWith("/")) { + try { + const url = new URL(conversationUrl); + // If the conversation URL points to localhost but we're accessing from external, + // use the browser's hostname with the conversation URL's port + const urlHostname = url.hostname; + const browserHostname = + window.location.hostname ?? window.location.host?.split(":")[0]; + if ( + browserHostname && + (urlHostname === "localhost" || urlHostname === "127.0.0.1") && + browserHostname !== "localhost" && + browserHostname !== "127.0.0.1" + ) { + return `${browserHostname}:${url.port}`; + } + return url.host; // e.g., "localhost:3000" + } catch { + return window.location.host; + } + } + return window.location.host; +} + +/** + * Extracts the path prefix from conversation URL (everything before /api/conversations) + * This is needed for proxy deployments where agent-servers are accessed via paths like /runtime/{port}/ + * @param conversationUrl The conversation URL (e.g., "http://localhost:3000/runtime/55313/api/conversations/123") + * @returns Path prefix without trailing slash (e.g., "/runtime/55313") or empty string + */ +function extractPathPrefix(conversationUrl: string | null | undefined): string { + if (conversationUrl && !conversationUrl.startsWith("/")) { + try { + const url = new URL(conversationUrl); + const pathBeforeApi = url.pathname.split("/api/conversations")[0] || ""; + return pathBeforeApi.replace(/\/$/, ""); // Remove trailing slash + } catch { + return ""; + } + } + return ""; +} + +/** + * Builds the HTTP base URL for V1 API calls + * @param conversationUrl The conversation URL containing host/port + * @returns HTTP base URL (e.g., "http://localhost:3000" or "http://localhost:3000/runtime/55313") + */ +export function buildHttpBaseUrl( + conversationUrl: string | null | undefined, +): string { + const baseHost = extractBaseHost(conversationUrl); + const pathPrefix = extractPathPrefix(conversationUrl); + const pageIsSecure = window.location.protocol === "https:"; + const targetIsSecure = + getConversationUrlProtocol(conversationUrl) === "https:"; + const protocol = pageIsSecure || targetIsSecure ? "https:" : "http:"; + return `${protocol}//${baseHost}${pathPrefix}`; +} + +function getConversationUrlProtocol( + conversationUrl: string | null | undefined, +): string | null { + if (!conversationUrl || conversationUrl.startsWith("/")) { + return null; + } + + try { + return new URL(conversationUrl).protocol; + } catch { + return null; + } +} + +/** + * Builds the WebSocket URL for the agent-server's bash-events endpoint. + * The URL is derived from the same host and path prefix as the conversation + * events socket so it works in both direct-connect and reverse-proxy deployments. + * + * @param conversationUrl The conversation URL containing host/port + * @returns WebSocket URL for the bash-events endpoint + */ +export function buildBashWebSocketUrl( + conversationUrl: string | null | undefined, +): string { + const baseHost = extractBaseHost(conversationUrl); + const pathPrefix = extractPathPrefix(conversationUrl); + + const pageIsSecure = window.location.protocol === "https:"; + const targetIsSecure = + getConversationUrlProtocol(conversationUrl) === "https:"; + const protocol = pageIsSecure || targetIsSecure ? "wss:" : "ws:"; + + return `${protocol}//${baseHost}${pathPrefix}/sockets/bash-events`; +} + +/** + * Builds the WebSocket URL for V1 conversations (without query params) + * @param conversationId The conversation ID + * @param conversationUrl The conversation URL containing host/port (e.g., "http://localhost:3000/api/conversations/123") + * @returns WebSocket URL or null if inputs are invalid + */ +export function buildWebSocketUrl( + conversationId: string | undefined, + conversationUrl: string | null | undefined, +): string | null { + if (!conversationId) { + return null; + } + + const baseHost = extractBaseHost(conversationUrl); + const pathPrefix = extractPathPrefix(conversationUrl); + + // Build WebSocket URL: ws://host:port[/path-prefix]/sockets/events/{conversationId} + // The path prefix (e.g., /runtime/55313) is needed for proxy deployments + // Note: Query params should be passed via the useWebSocket hook options + // + // Protocol selection follows the actual HTTP access path. A page served + // over HTTPS must use WSS, but an HTTP page that reaches a remote dev ingress + // over plain HTTP (for example a Tailscale hostname) must use WS; forcing WSS + // sends a TLS handshake to the HTTP-only ingress and Node reports it as a + // malformed HTTP method. + const pageIsSecure = window.location.protocol === "https:"; + const targetIsSecure = + getConversationUrlProtocol(conversationUrl) === "https:"; + const protocol = pageIsSecure || targetIsSecure ? "wss:" : "ws:"; + + return `${protocol}//${baseHost}${pathPrefix}/sockets/events/${conversationId}`; +} diff --git a/src/utils/workspace-mode.ts b/src/utils/workspace-mode.ts new file mode 100644 index 0000000000000000000000000000000000000000..b97d7c73a48cec67a8d57405a366188c8a3c1872 --- /dev/null +++ b/src/utils/workspace-mode.ts @@ -0,0 +1,47 @@ +import type { BackendKind } from "#/api/backend-registry/types"; +import type { WorkspaceMode } from "#/api/conversation-metadata-store"; +import { I18nKey } from "#/i18n/declaration"; + +export const DEFAULT_WORKSPACE_MODE: WorkspaceMode = "local_repo"; + +export const LAST_LOCAL_WORKSPACE_MODE_STORAGE_KEY = + "openhands-last-local-workspace-mode"; + +function isWorkspaceMode(value: string | null): value is WorkspaceMode { + return value === "local_repo" || value === "new_worktree"; +} + +export function readStoredLocalWorkspaceMode(): WorkspaceMode { + if (typeof window === "undefined") return DEFAULT_WORKSPACE_MODE; + + try { + const stored = window.localStorage.getItem( + LAST_LOCAL_WORKSPACE_MODE_STORAGE_KEY, + ); + return isWorkspaceMode(stored) ? stored : DEFAULT_WORKSPACE_MODE; + } catch { + return DEFAULT_WORKSPACE_MODE; + } +} + +export function writeStoredLocalWorkspaceMode(mode: WorkspaceMode): void { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(LAST_LOCAL_WORKSPACE_MODE_STORAGE_KEY, mode); + } catch { + // Ignore storage failures; the selector still works for this render. + } +} + +export function getWorkspaceModeI18nKey( + mode: WorkspaceMode, + backendKind: BackendKind, +): I18nKey { + if (mode === "new_worktree") { + return I18nKey.COMMON$WORKSPACE_MODE_NEW_WORKTREE; + } + return backendKind === "cloud" + ? I18nKey.COMMON$WORKSPACE_MODE_CLOUD_REPO + : I18nKey.COMMON$WORKSPACE_MODE_LOCAL_REPO; +} diff --git a/src/utils/workspace-relative-path.ts b/src/utils/workspace-relative-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c93afd031db789922e0842dfcec9cb628236f5e --- /dev/null +++ b/src/utils/workspace-relative-path.ts @@ -0,0 +1,7 @@ +/** + * Workspace-relative path helpers for Files drawer / workspace file APIs. + * + * Implementation lives in path-utils (`toFilesTabPath`) so chat links, + * canvas_ui navigation, and markdown artifact View share one normalizer. + */ +export { toFilesTabPath as toWorkspaceRelativePath } from "./path-utils"; diff --git a/src/utils/workspaces-compatibility.ts b/src/utils/workspaces-compatibility.ts new file mode 100644 index 0000000000000000000000000000000000000000..5cb5520406c28561e628c673b03d6fe67b4d5e05 --- /dev/null +++ b/src/utils/workspaces-compatibility.ts @@ -0,0 +1,18 @@ +import type { TFunction } from "i18next"; +import { isAgentServerVersionError } from "@openhands/typescript-client/clients"; + +import { I18nKey } from "#/i18n/declaration"; + +export function getWorkspacesUnsupportedMessage( + error: unknown, + t: TFunction<"openhands">, +) { + if (!isAgentServerVersionError(error)) { + return null; + } + + return t(I18nKey.HOME$WORKSPACES_UNSUPPORTED_AGENT_SERVER, { + actualVersion: error.actualVersion, + requiredVersion: error.requiredVersion, + }); +}