diff --git a/packages/app/src/components/agent-stream-view-data.test.ts b/packages/app/src/components/agent-stream-view-data.test.ts new file mode 100644 index 000000000..df26365f0 --- /dev/null +++ b/packages/app/src/components/agent-stream-view-data.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import type { StreamItem } from "@/types/stream"; +import type { NeighborResolver } from "./agent-stream-view-data"; +import { + getAssistantBlockSpacing, + isSameAssistantBlockGroup, + resolveInlineWorkingIndicatorItemId, +} from "./agent-stream-view-data"; + +// Minimal forward-order resolver: "below" = next item in array. +// Matches web strategy rendering order (chronological, top-to-bottom). +const forwardStrategy: NeighborResolver = { + getNeighborItem(items, index, relation) { + const neighborIndex = relation === "below" ? index + 1 : index - 1; + return items[neighborIndex]; + }, +}; + +function assistantBlock(params: { + id: string; + blockGroupId: string; + blockIndex: number; + text?: string; +}): Extract { + return { + kind: "assistant_message", + id: params.id, + blockGroupId: params.blockGroupId, + blockIndex: params.blockIndex, + text: params.text ?? "", + timestamp: new Date("2026-05-01T00:00:00.000Z"), + }; +} + +function toolCallBlock(id: string): Extract { + return { + kind: "tool_call", + id, + timestamp: new Date("2026-05-01T00:00:00.000Z"), + payload: { + source: "orchestrator", + data: { + toolCallId: id, + toolName: "bash", + arguments: "cmd", + result: null, + status: "executing", + }, + }, + }; +} + +describe("isSameAssistantBlockGroup", () => { + it("returns true for two assistant blocks with the same blockGroupId", () => { + const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 }); + const b = assistantBlock({ id: "b", blockGroupId: "group-1", blockIndex: 1 }); + expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(true); + }); + + it("returns false for blocks from different groups", () => { + const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 }); + const b = assistantBlock({ id: "b", blockGroupId: "group-2", blockIndex: 0 }); + expect(isSameAssistantBlockGroup({ item: a, other: b })).toBe(false); + }); + + it("returns false when one item is not an assistant_message", () => { + const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 }); + const tc = toolCallBlock("tc-1"); + expect(isSameAssistantBlockGroup({ item: a, other: tc })).toBe(false); + }); + + it("returns false for null neighbors", () => { + const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 }); + expect(isSameAssistantBlockGroup({ item: a, other: null })).toBe(false); + }); +}); + +describe("getAssistantBlockSpacing", () => { + it("returns default for non-assistant items", () => { + const tc = toolCallBlock("tc-1"); + expect(getAssistantBlockSpacing({ item: tc, aboveItem: null, belowItem: null })).toBe( + "default", + ); + }); + + it("returns default when no same-group neighbors exist", () => { + const a = assistantBlock({ id: "a", blockGroupId: "group-1", blockIndex: 0 }); + expect(getAssistantBlockSpacing({ item: a, aboveItem: null, belowItem: null })).toBe("default"); + }); + + it("returns compactTop when the item above is in the same block group", () => { + const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 }); + const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 }); + expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: null })).toBe( + "compactTop", + ); + }); + + it("returns compactBottom when the item below is in the same block group", () => { + const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 0 }); + const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 1 }); + expect(getAssistantBlockSpacing({ item, aboveItem: null, belowItem: below })).toBe( + "compactBottom", + ); + }); + + it("returns compactBoth when both neighbors are in the same block group", () => { + const above = assistantBlock({ id: "above", blockGroupId: "group-1", blockIndex: 0 }); + const item = assistantBlock({ id: "item", blockGroupId: "group-1", blockIndex: 1 }); + const below = assistantBlock({ id: "below", blockGroupId: "group-1", blockIndex: 2 }); + expect(getAssistantBlockSpacing({ item, aboveItem: above, belowItem: below })).toBe( + "compactBoth", + ); + }); + + it("spans the history/live-head boundary: tail gets compactBottom, head gets compactTop", () => { + const tailBlock = assistantBlock({ + id: "group-1:block:0", + blockGroupId: "group-1", + blockIndex: 0, + text: "First paragraph", + }); + const headBlock = assistantBlock({ + id: "group-1:head", + blockGroupId: "group-1", + blockIndex: 1, + text: "Second paragraph", + }); + + expect( + getAssistantBlockSpacing({ item: tailBlock, aboveItem: null, belowItem: headBlock }), + ).toBe("compactBottom"); + expect( + getAssistantBlockSpacing({ item: headBlock, aboveItem: tailBlock, belowItem: null }), + ).toBe("compactTop"); + }); +}); + +describe("resolveInlineWorkingIndicatorItemId", () => { + it("returns null when the agent is not running", () => { + const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 }); + expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull(); + }); + + it("returns the last assistant block id when running with a single head block", () => { + const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 }); + expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe( + "group-1:head", + ); + }); + + it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => { + const tc = toolCallBlock("tool-1"); + expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull(); + }); + + it("returns the footer assistant block when history and streaming head coexist", () => { + const historyBlock = assistantBlock({ + id: "group-1:block:0", + blockGroupId: "group-1", + blockIndex: 0, + }); + const streamingBlock = assistantBlock({ + id: "group-2:head", + blockGroupId: "group-2", + blockIndex: 0, + }); + // historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block + expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe( + "group-2:head", + ); + expect( + resolveInlineWorkingIndicatorItemId( + "running", + [historyBlock, streamingBlock], + forwardStrategy, + ), + ).toBe("group-2:head"); + }); +}); diff --git a/packages/app/src/components/agent-stream-view-data.ts b/packages/app/src/components/agent-stream-view-data.ts new file mode 100644 index 000000000..d77860b27 --- /dev/null +++ b/packages/app/src/components/agent-stream-view-data.ts @@ -0,0 +1,51 @@ +import type { StreamItem } from "@/types/stream"; + +export function isSameAssistantBlockGroup(params: { + item: StreamItem | null | undefined; + other: StreamItem | null | undefined; +}): boolean { + return ( + params.item?.kind === "assistant_message" && + params.other?.kind === "assistant_message" && + params.item.blockGroupId !== undefined && + params.item.blockGroupId === params.other.blockGroupId + ); +} + +export function getAssistantBlockSpacing(params: { + item: StreamItem; + aboveItem: StreamItem | null | undefined; + belowItem: StreamItem | null | undefined; +}): "default" | "compactTop" | "compactBottom" | "compactBoth" { + if (params.item.kind !== "assistant_message") { + return "default"; + } + const compactTop = isSameAssistantBlockGroup({ item: params.item, other: params.aboveItem }); + const compactBottom = isSameAssistantBlockGroup({ item: params.item, other: params.belowItem }); + if (compactTop && compactBottom) return "compactBoth"; + if (compactTop) return "compactTop"; + if (compactBottom) return "compactBottom"; + return "default"; +} + +export interface NeighborResolver { + getNeighborItem( + items: StreamItem[], + index: number, + relation: "above" | "below", + ): StreamItem | undefined; +} + +// null → auxiliary working indicator; non-null → inline footer on that block. +export function resolveInlineWorkingIndicatorItemId( + status: string, + liveHeadItems: StreamItem[], + strategy: NeighborResolver, +): string | null { + if (status !== "running") return null; + const footerItem = liveHeadItems.find((item, index, items) => { + if (item.kind !== "assistant_message") return false; + return strategy.getNeighborItem(items, index, "below") === undefined; + }); + return footerItem?.id ?? null; +} diff --git a/packages/app/src/components/agent-stream-view.test.tsx b/packages/app/src/components/agent-stream-view.test.tsx deleted file mode 100644 index 82fceef6e..000000000 --- a/packages/app/src/components/agent-stream-view.test.tsx +++ /dev/null @@ -1,419 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { StreamItem } from "@/types/stream"; -import { AgentStreamView } from "./agent-stream-view"; - -const assistantMessageCalls = vi.hoisted( - () => [] as Array<{ message: string; spacing: string | undefined }>, -); -const turnCopyButtonCalls = vi.hoisted(() => [] as Array<{ getContent: () => string }>); - -const mockSessionState = vi.hoisted(() => ({ - sessions: { - server: { - client: null, - agentStreamHead: new Map(), - workspaces: new Map(), - agentTimelineCursor: new Map(), - agentTimelineHasOlder: new Map(), - agentTimelineOlderFetchInFlight: new Map(), - }, - }, - setAgentTimelineOlderFetchInFlight: () => {}, -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: ( - factory: (theme: { - borderRadius: Record; - borderWidth: Record; - colors: Record; - fontSize: Record; - fontWeight: Record; - shadow: Record; - spacing: Record; - }) => unknown, - ) => - factory({ - borderRadius: { - full: 9999, - md: 6, - }, - borderWidth: { - 1: 1, - }, - colors: { - foreground: "#fff", - foregroundMuted: "#aaa", - surface0: "#000", - surface1: "#111", - surface2: "#222", - border: "#333", - borderAccent: "#444", - }, - fontSize: { - sm: 14, - base: 16, - xs: 12, - }, - fontWeight: { - normal: "normal", - }, - shadow: { - sm: {}, - }, - spacing: { - 1: 4, - 2: 8, - 3: 12, - 4: 16, - 12: 48, - }, - }), - }, - useUnistyles: () => ({ rt: { breakpoint: "md" } }), - withUnistyles: (Component: unknown) => Component, -})); - -vi.mock("react-native-reanimated", async () => { - const ReactModule = await import("react"); - return { - default: { - View: ({ children, ...props }: { children?: React.ReactNode }) => - ReactModule.createElement("div", props, children), - }, - Easing: { linear: vi.fn() }, - FadeIn: { duration: vi.fn(() => undefined) }, - FadeOut: { duration: vi.fn(() => undefined) }, - cancelAnimation: vi.fn(), - useAnimatedStyle: (factory: () => unknown) => factory(), - useSharedValue: (value: unknown) => ({ value }), - withRepeat: (value: unknown) => value, - withTiming: (value: unknown) => value, - }; -}); - -vi.mock("lucide-react-native", async () => { - const ReactModule = await import("react"); - const Icon = () => ReactModule.createElement("span"); - return { - Check: Icon, - ChevronDown: Icon, - X: Icon, - }; -}); - -vi.mock("./message", async () => { - const ReactModule = await import("react"); - return { - ActivityLog: () => null, - AssistantMessage: (props: { message: string; spacing?: string }) => { - assistantMessageCalls.push({ message: props.message, spacing: props.spacing }); - return ReactModule.createElement("div", { - "data-message": props.message, - "data-spacing": props.spacing ?? "", - }); - }, - CompactionMarker: () => null, - MessageOuterSpacingProvider: ({ children }: { children: React.ReactNode }) => - ReactModule.createElement(ReactModule.Fragment, null, children), - SpeakMessage: () => null, - TodoListCard: () => null, - ToolCall: () => null, - TurnCopyButton: (props: { getContent: () => string }) => { - turnCopyButtonCalls.push(props); - return ReactModule.createElement("button", { - "data-testid": "turn-copy-button", - type: "button", - }); - }, - UserMessage: () => null, - }; -}); - -vi.mock("./tool-call-sheet", async () => { - const ReactModule = await import("react"); - return { - ToolCallSheetProvider: ({ children }: { children: React.ReactNode }) => - ReactModule.createElement(ReactModule.Fragment, null, children), - useToolCallSheet: () => ({ open: vi.fn() }), - }; -}); - -vi.mock("./tool-call-details", () => ({ ToolCallDetailsContent: () => null })); -vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null })); -vi.mock("./question-form-card", () => ({ QuestionFormCard: () => null })); -vi.mock("./plan-card", () => ({ PlanCard: () => null })); -vi.mock("@/hooks/use-file-explorer-actions", () => ({ - useFileExplorerActions: () => ({ requestDirectoryListing: vi.fn() }), -})); -vi.mock("@/stores/panel-store", () => ({ - usePanelStore: (selector: (state: Record) => unknown) => - selector({ - openFileExplorerForCheckout: vi.fn(), - setExplorerTabForCheckout: vi.fn(), - }), -})); -vi.mock("@/stores/session-store", () => ({ - useSessionStore: Object.assign( - (selector: (state: typeof mockSessionState) => unknown) => selector(mockSessionState), - { - getState: () => mockSessionState, - }, - ), -})); -vi.mock("expo-router", () => ({ useRouter: () => ({ navigate: vi.fn() }) })); - -function assistantBlock(params: { - id: string; - text: string; - blockIndex: number; -}): Extract { - return { - kind: "assistant_message", - id: params.id, - blockGroupId: "group-1", - blockIndex: params.blockIndex, - text: params.text, - timestamp: new Date("2026-05-01T00:00:00.000Z"), - }; -} - -function runningToolCall(id: string): Extract { - return { - kind: "tool_call", - id, - timestamp: new Date("2026-05-01T00:00:00.000Z"), - payload: { - source: "orchestrator", - data: { - toolCallId: id, - toolName: "bash", - arguments: "npm test", - result: null, - status: "executing", - }, - }, - }; -} - -describe("AgentStreamView", () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - let originalScrollTo: HTMLElement["scrollTo"] | undefined; - - beforeEach(() => { - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - originalScrollTo = HTMLElement.prototype.scrollTo; - HTMLElement.prototype.scrollTo = vi.fn(); - assistantMessageCalls.length = 0; - turnCopyButtonCalls.length = 0; - mockSessionState.sessions.server.agentStreamHead = new Map(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - if (originalScrollTo) { - HTMLElement.prototype.scrollTo = originalScrollTo; - } else { - Reflect.deleteProperty(HTMLElement.prototype, "scrollTo"); - } - vi.restoreAllMocks(); - }); - - it("compacts assistant block spacing across the history/live-head boundary", () => { - const tailBlock = assistantBlock({ - id: "group-1:block:0", - text: "First paragraph", - blockIndex: 0, - }); - const headBlock = assistantBlock({ - id: "group-1:head", - text: "Second paragraph", - blockIndex: 1, - }); - mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]); - const agent = { - id: "agent-1", - serverId: "server", - status: "idle", - cwd: "/tmp/project", - } as never; - const streamItems = [tailBlock]; - const pendingPermissions = new Map(); - - act(() => { - root?.render( - React.createElement(AgentStreamView, { - agentId: "agent-1", - serverId: "server", - agent, - streamItems, - pendingPermissions, - }), - ); - }); - - const tailCalls = assistantMessageCalls.filter((call) => call.message === "First paragraph"); - const headCalls = assistantMessageCalls.filter((call) => call.message === "Second paragraph"); - - expect(tailCalls.length).toBeGreaterThan(0); - expect(headCalls.length).toBeGreaterThan(0); - expect(tailCalls.map((call) => call.spacing)).toEqual( - Array.from({ length: tailCalls.length }, () => "compactBottom"), - ); - expect(headCalls.map((call) => call.spacing)).toEqual( - Array.from({ length: headCalls.length }, () => "compactTop"), - ); - }); - - it("renders running dots in the assistant turn footer when live text is streaming", () => { - const headBlock = assistantBlock({ - id: "group-1:head", - text: "Streaming paragraph", - blockIndex: 0, - }); - mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]); - const agent = { - id: "agent-1", - serverId: "server", - status: "running", - cwd: "/tmp/project", - } as never; - - act(() => { - root?.render( - React.createElement(AgentStreamView, { - agentId: "agent-1", - serverId: "server", - agent, - streamItems: [], - pendingPermissions: new Map(), - }), - ); - }); - - expect(container?.querySelector('[data-testid="turn-working-indicator"]')).not.toBeNull(); - expect( - container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'), - ).toBeNull(); - expect(container?.querySelector('[data-testid="turn-copy-button"]')).toBeNull(); - }); - - it("only renders running dots on the live assistant row", () => { - const tailBlock = assistantBlock({ - id: "group-1:block:0", - text: "History paragraph", - blockIndex: 0, - }); - const headBlock = assistantBlock({ - id: "group-2:head", - text: "Streaming paragraph", - blockIndex: 0, - }); - mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]); - const agent = { - id: "agent-1", - serverId: "server", - status: "running", - cwd: "/tmp/project", - } as never; - - act(() => { - root?.render( - React.createElement(AgentStreamView, { - agentId: "agent-1", - serverId: "server", - agent, - streamItems: [tailBlock], - pendingPermissions: new Map(), - }), - ); - }); - - expect(container?.querySelectorAll('[data-testid="turn-working-indicator"]')).toHaveLength(1); - expect( - container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'), - ).toBeNull(); - }); - - it("keeps the auxiliary running dots when there is no live assistant row", () => { - mockSessionState.sessions.server.agentStreamHead.set("agent-1", [runningToolCall("tool-1")]); - const agent = { - id: "agent-1", - serverId: "server", - status: "running", - cwd: "/tmp/project", - } as never; - - act(() => { - root?.render( - React.createElement(AgentStreamView, { - agentId: "agent-1", - serverId: "server", - agent, - streamItems: [], - pendingPermissions: new Map(), - }), - ); - }); - - expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull(); - expect( - container?.querySelector('[data-testid="stream-working-indicator-auxiliary"]'), - ).not.toBeNull(); - }); - - it("replaces the running footer with the copy button when the assistant turn idles", () => { - const headBlock = assistantBlock({ - id: "group-1:head", - text: "Complete paragraph", - blockIndex: 0, - }); - mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]); - const agent = { - id: "agent-1", - serverId: "server", - status: "idle", - cwd: "/tmp/project", - } as never; - - act(() => { - root?.render( - React.createElement(AgentStreamView, { - agentId: "agent-1", - serverId: "server", - agent, - streamItems: [], - pendingPermissions: new Map(), - }), - ); - }); - - expect(container?.querySelector('[data-testid="turn-working-indicator"]')).toBeNull(); - expect(container?.querySelector('[data-testid="turn-copy-button"]')).not.toBeNull(); - expect(turnCopyButtonCalls.length).toBeGreaterThan(0); - expect(turnCopyButtonCalls.map((call) => call.getContent())).toEqual( - Array.from({ length: turnCopyButtonCalls.length }, () => "Complete paragraph"), - ); - }); -}); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 53f579894..59466ba91 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -71,6 +71,11 @@ import { type StreamSegmentRenderers, type StreamViewportHandle, } from "./agent-stream-render-strategy"; +import { + getAssistantBlockSpacing, + isSameAssistantBlockGroup, + resolveInlineWorkingIndicatorItemId, +} from "./agent-stream-view-data"; import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, @@ -92,43 +97,6 @@ const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list"; -const isSameAssistantBlockGroup = (params: { - item: StreamItem | null | undefined; - other: StreamItem | null | undefined; -}) => - params.item?.kind === "assistant_message" && - params.other?.kind === "assistant_message" && - params.item.blockGroupId !== undefined && - params.item.blockGroupId === params.other.blockGroupId; - -const getAssistantBlockSpacing = (params: { - item: StreamItem; - aboveItem: StreamItem | null | undefined; - belowItem: StreamItem | null | undefined; -}): "default" | "compactTop" | "compactBottom" | "compactBoth" => { - if (params.item.kind !== "assistant_message") { - return "default"; - } - const compactTop = isSameAssistantBlockGroup({ - item: params.item, - other: params.aboveItem, - }); - const compactBottom = isSameAssistantBlockGroup({ - item: params.item, - other: params.belowItem, - }); - if (compactTop && compactBottom) { - return "compactBoth"; - } - if (compactTop) { - return "compactTop"; - } - if (compactBottom) { - return "compactBottom"; - } - return "default"; -}; - interface StreamItemBoundarySeams { aboveItem?: StreamItem | null; belowItem?: StreamItem | null; @@ -301,25 +269,15 @@ const AgentStreamViewComponent = forwardRef { - if (agent.status !== "running") { - return null; - } - const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => { - if (item.kind !== "assistant_message") { - return false; - } - return ( - getStreamNeighborItem({ - strategy: streamRenderStrategy, - items, - index, - relation: "below", - }) === undefined - ); - }); - return footerItem?.id ?? null; - }, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]); + const inlineWorkingIndicatorItemId = useMemo( + () => + resolveInlineWorkingIndicatorItemId( + agent.status, + baseRenderModel.segments.liveHead, + streamRenderStrategy, + ), + [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy], + ); useImperativeHandle( ref, () => ({ diff --git a/packages/app/src/components/left-sidebar.test.tsx b/packages/app/src/components/left-sidebar.test.tsx deleted file mode 100644 index 1b7b8ebf8..000000000 --- a/packages/app/src/components/left-sidebar.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React from "react"; -import { act } from "@testing-library/react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => { - const hoistedPanelState = { - isOpen: false, - showMobileAgent: vi.fn(), - }; - - return { - panelState: hoistedPanelState, - useSidebarWorkspacesListMock: vi.fn(), - theme: { - spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 }, - iconSize: { sm: 14, md: 18, lg: 22 }, - borderWidth: { 1: 1 }, - borderRadius: { sm: 4, md: 6, lg: 8, full: 999 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400", medium: "500", semibold: "600" }, - colors: { - surfaceSidebar: "#111", - surface1: "#111", - surface2: "#222", - surface3: "#333", - surface4: "#444", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#555", - borderAccent: "#666", - accent: "#0a84ff", - accentForeground: "#fff", - palette: { - green: { 400: "#30d158" }, - amber: { 500: "#ffd60a" }, - red: { 500: "#ff453a" }, - }, - }, - }, - }; -}); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - absoluteFillObject: {}, - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("react-native-reanimated", () => ({ - default: { - View: "div", - }, - Extrapolation: { CLAMP: "clamp" }, - interpolate: () => 0, - runOnJS: (fn: (...args: unknown[]) => unknown) => fn, - useAnimatedStyle: (factory: () => unknown) => factory(), - useSharedValue: (value: unknown) => ({ value }), -})); - -vi.mock("react-native-gesture-handler", () => { - const chain = { - enabled: () => chain, - hitSlop: () => chain, - manualActivation: () => chain, - onTouchesDown: () => chain, - onTouchesMove: () => chain, - onStart: () => chain, - onUpdate: () => chain, - onEnd: () => chain, - onFinalize: () => chain, - withRef: () => chain, - }; - return { - Gesture: { Pan: () => chain }, - GestureDetector: ({ children }: { children: React.ReactNode }) => children, - }; -}); - -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => (props: Record) => - React.createElement("span", { ...props, "data-icon": name }); - return { - FolderPlus: createIcon("FolderPlus"), - MessagesSquare: createIcon("MessagesSquare"), - Plus: createIcon("Plus"), - Settings: createIcon("Settings"), - }; -}); - -vi.mock("expo-router", () => ({ - router: { push: vi.fn() }, - usePathname: () => "/hosts/srv", -})); - -vi.mock("react-native-safe-area-context", () => ({ - useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), -})); - -vi.mock("@/constants/layout", () => ({ - useIsCompactFormFactor: () => true, -})); - -vi.mock("@/constants/platform", () => ({ - isWeb: true, - isNative: false, -})); - -vi.mock("@/stores/panel-store", () => ({ - MIN_SIDEBAR_WIDTH: 260, - MAX_SIDEBAR_WIDTH: 420, - selectIsAgentListOpen: (state: typeof panelState) => state.isOpen, - usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState), -})); - -vi.mock("@/runtime/host-runtime", () => ({ - useHosts: () => [{ serverId: "srv", label: "Local" }], - useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }), -})); - -vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({ - useSidebarWorkspacesList: useSidebarWorkspacesListMock, -})); - -vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({ - useSidebarShortcutModel: () => ({ - collapsedProjectKeys: new Set(), - shortcutIndexByWorkspaceKey: new Map(), - toggleProjectCollapsed: vi.fn(), - }), -})); - -vi.mock("@/contexts/sidebar-animation-context", () => ({ - useSidebarAnimation: () => ({ - translateX: { value: 0 }, - backdropOpacity: { value: 0 }, - windowWidth: 390, - animateToOpen: vi.fn(), - animateToClose: vi.fn(), - isGesturing: { value: false }, - gestureAnimatingRef: { current: false }, - closeGestureRef: { current: undefined }, - }), -})); - -vi.mock("@/hooks/use-shortcut-keys", () => ({ - useShortcutKeys: () => null, -})); - -vi.mock("@/utils/desktop-window", () => ({ - useWindowControlsPadding: () => ({ top: 0 }), -})); - -vi.mock("@/utils/host-routes", () => ({ - buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`, - buildSettingsRoute: () => "/settings", - mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`, - parseServerIdFromPathname: () => "srv", -})); - -vi.mock("@/hooks/use-open-project-picker", () => ({ - useOpenProjectPicker: () => vi.fn(), -})); - -vi.mock("@/components/sidebar/sidebar-header-row", () => ({ - SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label), -})); - -vi.mock("./sidebar-workspace-list", () => ({ - SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) => - React.createElement( - "div", - { "data-testid": "sidebar-workspace-list" }, - projects.map((project) => project.projectName).join(","), - ), -})); - -vi.mock("./sidebar-agent-list-skeleton", () => ({ - SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"), -})); - -vi.mock("@/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => - React.createElement("div", null, children), - TooltipContent: ({ children }: { children: React.ReactNode }) => - React.createElement("div", null, children), - TooltipTrigger: ({ children }: { children: React.ReactNode }) => - React.createElement("div", null, children), -})); - -vi.mock("@/components/ui/shortcut", () => ({ - Shortcut: () => React.createElement("span", null), -})); - -vi.mock("@/components/ui/combobox", () => ({ - Combobox: () => null, - ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label), -})); - -vi.stubGlobal("React", React); - -import { LeftSidebar } from "./left-sidebar"; - -describe("LeftSidebar", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - - beforeEach(() => { - panelState.isOpen = false; - panelState.showMobileAgent.mockReset(); - useSidebarWorkspacesListMock.mockReset(); - useSidebarWorkspacesListMock.mockReturnValue({ - projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }], - isInitialLoad: false, - isRevalidating: false, - refreshAll: vi.fn(), - }); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - }); - - it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => { - await act(async () => { - root?.render(); - }); - - expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({ - serverId: "srv", - enabled: true, - }); - }); -}); diff --git a/packages/app/src/components/message-input-state.test.ts b/packages/app/src/components/message-input-state.test.ts new file mode 100644 index 000000000..c08e26248 --- /dev/null +++ b/packages/app/src/components/message-input-state.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { computeCanStartDictation } from "./message-input-state"; + +const connected = { isConnected: true } as never; +const disconnected = { isConnected: false } as never; + +describe("computeCanStartDictation", () => { + it("returns false when socket is disconnected", () => { + expect( + computeCanStartDictation({ + client: disconnected, + isReadyForDictation: true, + disabled: false, + dictationUnavailableMessage: null, + }), + ).toBe(false); + }); + + it("returns false when isReadyForDictation is explicitly false", () => { + expect( + computeCanStartDictation({ + client: connected, + isReadyForDictation: false, + disabled: false, + dictationUnavailableMessage: null, + }), + ).toBe(false); + }); + + it("returns true when connected and ready", () => { + expect( + computeCanStartDictation({ + client: connected, + isReadyForDictation: true, + disabled: false, + dictationUnavailableMessage: null, + }), + ).toBe(true); + }); + + it("falls back to socket connected state when isReadyForDictation is undefined", () => { + expect( + computeCanStartDictation({ + client: connected, + isReadyForDictation: undefined, + disabled: false, + dictationUnavailableMessage: null, + }), + ).toBe(true); + + expect( + computeCanStartDictation({ + client: disconnected, + isReadyForDictation: undefined, + disabled: false, + dictationUnavailableMessage: null, + }), + ).toBe(false); + }); + + it("returns false when the input is disabled", () => { + expect( + computeCanStartDictation({ + client: connected, + isReadyForDictation: true, + disabled: true, + dictationUnavailableMessage: null, + }), + ).toBe(false); + }); + + it("returns false when a dictation unavailable message is present", () => { + expect( + computeCanStartDictation({ + client: connected, + isReadyForDictation: true, + disabled: false, + dictationUnavailableMessage: "Microphone unavailable", + }), + ).toBe(false); + }); + + it("returns false when client is null", () => { + expect( + computeCanStartDictation({ + client: null, + isReadyForDictation: true, + disabled: false, + dictationUnavailableMessage: null, + }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/components/message-input-state.ts b/packages/app/src/components/message-input-state.ts new file mode 100644 index 000000000..dc106e2df --- /dev/null +++ b/packages/app/src/components/message-input-state.ts @@ -0,0 +1,14 @@ +import type { DaemonClient } from "@server/client/daemon-client"; + +export function computeCanStartDictation(input: { + client: DaemonClient | null; + isReadyForDictation: boolean | undefined; + disabled: boolean; + dictationUnavailableMessage: string | null | undefined; +}): boolean { + const socketConnected = input.client?.isConnected ?? false; + const readyForDictation = input.isReadyForDictation ?? socketConnected; + return ( + socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage + ); +} diff --git a/packages/app/src/components/message-input.test.tsx b/packages/app/src/components/message-input.test.tsx deleted file mode 100644 index a82333373..000000000 --- a/packages/app/src/components/message-input.test.tsx +++ /dev/null @@ -1,394 +0,0 @@ -import React, { createRef } from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input"; - -const EMPTY_ATTACHMENTS: React.ComponentProps["attachments"] = []; -const EMPTY_ATTACHMENT_MENU_ITEMS: AttachmentMenuItem[] = []; -const FAKE_CONNECTED_CLIENT = { isConnected: true } as never; - -const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({ - startDictationMock: vi.fn(), - cancelDictationMock: vi.fn(), - confirmDictationMock: vi.fn(), -})); - -const { theme } = vi.hoisted(() => ({ - theme: { - spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, - iconSize: { sm: 14, md: 18, lg: 22 }, - borderWidth: { 1: 1 }, - borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400" }, - shadow: { md: {} }, - colors: { - surface1: "#111", - surface2: "#222", - surface3: "#333", - surface4: "#888", - foreground: "#fff", - foregroundMuted: "#aaa", - popoverForeground: "#fff", - borderAccent: "#444", - accent: "#0a84ff", - accentForeground: "#fff", - destructive: "#ff453a", - palette: { - green: { 500: "#30d158" }, - }, - }, - }, -})); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - withUnistyles: (component: T) => component, -})); - -vi.mock("@/constants/platform", () => ({ - isWeb: false, - isNative: true, -})); - -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => (props: Record) => - React.createElement("span", { ...props, "data-icon": name }); - return { - ArrowUp: createIcon("ArrowUp"), - Mic: createIcon("Mic"), - MicOff: createIcon("MicOff"), - CornerDownLeft: createIcon("CornerDownLeft"), - Plus: createIcon("Plus"), - Square: createIcon("Square"), - }; -}); - -vi.mock("react-native-reanimated", () => ({ - default: { - View: "div", - }, - Keyframe: class Keyframe { - duration() { - return this; - } - withCallback() { - return this; - } - }, - runOnJS: (fn: (...args: unknown[]) => unknown) => fn, - useSharedValue: (value: unknown) => ({ value }), - useAnimatedStyle: (factory: () => unknown) => factory(), - withTiming: (value: unknown) => value, -})); - -vi.mock("@/hooks/use-dictation", () => ({ - useDictation: () => ({ - isRecording: false, - isProcessing: false, - partialTranscript: "", - volume: 0, - duration: 0, - error: null, - status: "idle", - startDictation: startDictationMock, - cancelDictation: cancelDictationMock, - confirmDictation: confirmDictationMock, - retryFailedDictation: vi.fn(), - discardFailedDictation: vi.fn(), - }), -})); - -vi.mock("@/stores/session-store", () => ({ - useSessionStore: (selector: (state: { sessions: Record }) => unknown) => - selector({ sessions: {} }), -})); - -vi.mock("@/contexts/voice-context", () => ({ - useVoiceOptional: () => null, -})); - -vi.mock("@/contexts/toast-context", () => ({ - useToast: () => ({ error: vi.fn() }), -})); - -vi.mock("@/utils/server-info-capabilities", () => ({ - resolveVoiceUnavailableMessage: () => null, -})); - -vi.mock("@/components/use-web-scrollbar", () => ({ - useWebElementScrollbar: () => null, -})); - -vi.mock( - "@/hooks/use-web-scrollbar-style", - () => ({ - useWebScrollbarStyle: () => undefined, - }), - // @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload. - { virtual: true }, -); - -vi.mock("@/hooks/use-shortcut-keys", () => ({ - useShortcutKeys: () => null, -})); - -vi.mock("@/components/ui/shortcut", () => ({ - Shortcut: () => null, -})); - -vi.mock("@/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => children, - TooltipTrigger: ({ - asChild, - children, - ...props - }: { - asChild?: boolean; - children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode); - } & Record) => - asChild ? ( - children - ) : ( - - ), - TooltipContent: ({ children }: { children: React.ReactNode }) => children, -})); - -vi.mock("@/components/ui/dropdown-menu", () => ({ - DropdownMenu: ({ children }: { children: React.ReactNode }) => children, - DropdownMenuTrigger: ({ - children, - testID, - accessibilityLabel, - }: { - children: - | React.ReactNode - | ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode); - testID?: string; - accessibilityLabel?: string; - }) => ( - - ), - DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => ( -
{children}
- ), - DropdownMenuItem: ({ - children, - onSelect, - testID, - disabled, - }: { - children: React.ReactNode; - onSelect?: () => void; - testID?: string; - disabled?: boolean; - }) => ( - - ), -})); - -vi.mock("./dictation-controls", () => ({ - DictationOverlay: () => null, -})); - -vi.mock("./realtime-voice-overlay", () => ({ - RealtimeVoiceOverlay: () => null, -})); - -let root: Root | null = null; -let container: HTMLElement | null = null; - -beforeEach(() => { - const dom = new JSDOM(""); - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("window", dom.window); - vi.stubGlobal("document", dom.window.document); - vi.stubGlobal("HTMLElement", dom.window.HTMLElement); - vi.stubGlobal("Node", dom.window.Node); - vi.stubGlobal("navigator", dom.window.navigator); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - startDictationMock.mockReset(); - cancelDictationMock.mockReset(); - confirmDictationMock.mockReset(); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container = null; - vi.unstubAllGlobals(); -}); - -interface RenderMessageInputOptions { - value?: string; - submitIcon?: "arrow" | "return"; -} - -function renderMessageInput( - menuItems: AttachmentMenuItem[], - { value = "", submitIcon }: RenderMessageInputOptions = {}, -) { - act(() => { - root?.render( - , - ); - }); -} - -function click(element: Element) { - act(() => { - element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); - }); -} - -function queryByTestId(testID: string): HTMLElement | null { - return document.querySelector(`[data-testid="${testID}"]`); -} - -function queryAllByAriaLabel(label: string): NodeListOf { - return document.querySelectorAll(`[aria-label="${label}"]`); -} - -describe("MessageInput attachments", () => { - it("renders the Plus attachment button and opens a menu with two attachment items", () => { - const menuItems: AttachmentMenuItem[] = [ - { id: "image", label: "Add image", onSelect: vi.fn() }, - { id: "github", label: "Add issue or PR", onSelect: vi.fn() }, - ]; - - renderMessageInput(menuItems); - - expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1); - - const attachButton = queryByTestId("message-input-attach-button"); - expect(attachButton).not.toBeNull(); - click(attachButton!); - - expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull(); - expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull(); - expect( - document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'), - ).toHaveLength(2); - }); - - it("selecting Attach image invokes the supplied image attachment action", () => { - const attachImage = vi.fn(); - - renderMessageInput([ - { id: "image", label: "Attach image", onSelect: attachImage }, - { id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() }, - ]); - - click(queryByTestId("message-input-attach-button")!); - click(queryByTestId("message-input-attachment-menu-item-image")!); - - expect(attachImage).toHaveBeenCalledTimes(1); - }); - - it("does not render the old queue button", () => { - renderMessageInput([ - { id: "image", label: "Add image", onSelect: vi.fn() }, - { id: "github", label: "Add issue or PR", onSelect: vi.fn() }, - ]); - - expect(queryAllByAriaLabel("Queue message")).toHaveLength(0); - }); - - it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => { - renderMessageInput([], { value: "Send this" }); - - expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1); - expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0); - - renderMessageInput([], { value: "Create this", submitIcon: "return" }); - - expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0); - expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1); - }); -}); - -describe("MessageInput dictation shortcuts", () => { - it("does not poison the dictation toggle when readiness is temporarily false", () => { - const inputRef = createRef(); - - act(() => { - root?.render( - , - ); - }); - - act(() => { - inputRef.current?.runKeyboardAction("dictation-toggle"); - }); - expect(startDictationMock).not.toHaveBeenCalled(); - expect(confirmDictationMock).not.toHaveBeenCalled(); - - act(() => { - root?.render( - , - ); - }); - act(() => { - inputRef.current?.runKeyboardAction("dictation-toggle"); - }); - - expect(startDictationMock).toHaveBeenCalledTimes(1); - expect(confirmDictationMock).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index fd775d04b..651523573 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -53,6 +53,7 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isWeb } from "@/constants/platform"; import { useComposerHeightMirror } from "./composer-height-mirror"; +import { computeCanStartDictation } from "./message-input-state"; export type ImageAttachment = AttachmentMetadata; @@ -968,19 +969,6 @@ function computeSendableContent(input: SendableContentInput): SendableContentOut return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton }; } -function computeCanStartDictation(input: { - client: DaemonClient | null; - isReadyForDictation: boolean | undefined; - disabled: boolean; - dictationUnavailableMessage: string | null | undefined; -}): boolean { - const socketConnected = input.client?.isConnected ?? false; - const readyForDictation = input.isReadyForDictation ?? socketConnected; - return ( - socketConnected && readyForDictation && !input.disabled && !input.dictationUnavailableMessage - ); -} - function computeIsDictationStartEnabled( isReadyForDictation: boolean | undefined, isConnected: boolean, diff --git a/packages/app/src/panels/agent-panel.test.tsx b/packages/app/src/panels/agent-panel.test.tsx deleted file mode 100644 index c26022cf2..000000000 --- a/packages/app/src/panels/agent-panel.test.tsx +++ /dev/null @@ -1,570 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import React, { useImperativeHandle } from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { DaemonClient } from "@server/client/daemon-client"; -import { PaneFocusProvider, PaneProvider, type PaneFocusContextValue } from "@/panels/pane-context"; -import { agentPanelRegistration } from "@/panels/agent-panel"; -import { useDraftStore } from "@/stores/draft-store"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import type { PendingPermission } from "@/types/shared"; -import type { StreamItem } from "@/types/stream"; -import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; - -interface PanelTestTheme { - colors: { - foreground: string; - foregroundMuted: string; - surface0: string; - surface1: string; - surface2: string; - surface3: string; - border: string; - destructive: string; - }; - spacing: Record; - borderRadius: Record; - fontSize: Record; - fontWeight: Record; - iconSize: Record; -} -type PanelTestStyles = Record; -type PanelTestStyleFactory = (input: PanelTestTheme) => PanelTestStyles; - -const { - composerRenderCount, - composerUnmountCount, - latestComposerCwd, - latestComposerIsPaneFocused, - streamRenderCount, - latestStreamPermissionKeys, - latestStreamText, - runtimeIsConnected, - theme, - runtimeClient, -} = vi.hoisted(() => { - Object.defineProperty(globalThis, "__DEV__", { - value: false, - configurable: true, - }); - Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { - value: true, - configurable: true, - }); - Object.defineProperty(globalThis, "ResizeObserver", { - value: class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} - }, - configurable: true, - }); - - return { - composerRenderCount: vi.fn(), - composerUnmountCount: vi.fn(), - latestComposerCwd: { current: null as string | null }, - latestComposerIsPaneFocused: { current: null as boolean | null }, - streamRenderCount: vi.fn(), - latestStreamPermissionKeys: { current: [] as string[] }, - latestStreamText: { current: null as string | null }, - runtimeIsConnected: { current: false }, - theme: { - colors: { - foreground: "#ffffff", - foregroundMuted: "#999999", - surface0: "#000000", - surface1: "#111111", - surface2: "#222222", - surface3: "#333333", - border: "#444444", - destructive: "#ff0000", - }, - spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 }, - borderRadius: { md: 6, lg: 8, xl: 12 }, - fontSize: { sm: 13, base: 15, lg: 18 }, - fontWeight: { medium: "500" }, - iconSize: { lg: 22 }, - } satisfies PanelTestTheme, - runtimeClient: { - fetchAgent: vi.fn(), - fetchAgentTimeline: vi.fn(), - }, - }; -}); - -vi.mock("react-native-reanimated", () => ({ - default: { - View: ({ children }: { children: React.ReactNode }) =>
{children}
, - }, -})); - -vi.mock("react-native-unistyles", () => { - return { - StyleSheet: { - create: createPanelTestStyles, - }, - withUnistyles: (component: T) => component, - useUnistyles: () => ({ theme, rt: { breakpoint: "lg" } }), - }; -}); - -vi.mock("react-native-safe-area-context", () => ({ - useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), -})); - -function createPanelTestStyles(factory: PanelTestStyleFactory | PanelTestStyles): PanelTestStyles { - return typeof factory === "function" ? factory(theme) : factory; -} - -vi.mock("@/runtime/host-runtime", () => ({ - useHosts: () => [{ serverId: "server", label: "Test server" }], - useHostRuntimeClient: () => runtimeClient, - useHostRuntimeIsConnected: () => runtimeIsConnected.current, - useHostRuntimeConnectionStatus: () => (runtimeIsConnected.current ? "online" : "offline"), - useHostRuntimeLastError: () => null, -})); - -vi.mock("@/attachments/service", () => ({ - garbageCollectAttachments: vi.fn(async () => {}), - persistAttachmentFromDataUrl: vi.fn(), - persistAttachmentFromFileUri: vi.fn(), -})); - -vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: () => null, -})); - -vi.mock("@/components/file-drop-zone", () => ({ - FileDropZone: ({ children }: { children: React.ReactNode }) =>
{children}
, -})); - -vi.mock("@/components/toast-host", () => ({ - ToastViewport: () => null, - useToastHost: () => ({ - toast: null, - dismiss: vi.fn(), - api: { - show: vi.fn(), - }, - }), -})); - -vi.mock("@/components/archived-agent-callout", () => ({ - ArchivedAgentCallout: ({ agentId }: { agentId: string }) => ( -
{agentId}
- ), -})); - -vi.mock("@/hooks/use-keyboard-shift-style", () => ({ - useKeyboardShiftStyle: () => ({ style: null }), -})); - -vi.mock("@/hooks/use-archive-agent", () => ({ - useArchiveAgent: () => ({ - isArchivingAgent: () => false, - }), -})); - -vi.mock("@/components/composer", () => ({ - Composer: ({ cwd, isPaneFocused }: { cwd: string; isPaneFocused: boolean }) => { - React.useEffect( - () => () => { - composerUnmountCount(); - }, - [], - ); - composerRenderCount(); - latestComposerCwd.current = cwd; - latestComposerIsPaneFocused.current = isPaneFocused; - return
{cwd}
; - }, -})); - -vi.mock("@/components/agent-stream-view", () => ({ - AgentStreamView: React.memo( - React.forwardRef< - { prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void }, - { pendingPermissions: Map; streamItems: StreamItem[] } - >(function AgentStreamView({ pendingPermissions, streamItems }, ref) { - useImperativeHandle(ref, () => ({ - prepareForViewportChange: vi.fn(), - scrollToBottom: vi.fn(), - })); - streamRenderCount(); - latestStreamPermissionKeys.current = Array.from(pendingPermissions.keys()); - latestStreamText.current = - streamItems.find((item) => item.kind === "user_message")?.text ?? null; - return
{latestStreamText.current}
; - }), - ), -})); - -function makeClient(): DaemonClient { - return new DaemonClient({ - url: "ws://127.0.0.1:1", - clientId: "panel-render-isolation-test", - }); -} - -function makeAgent(overrides: Partial = {}): Agent { - const now = new Date("2026-04-20T00:00:00.000Z"); - return { - serverId: "server", - id: "agent", - provider: "codex", - status: "running", - createdAt: now, - updatedAt: now, - lastUserMessageAt: null, - lastActivityAt: now, - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - lastError: null, - title: "Render isolation", - cwd: "/workspace/one", - model: null, - labels: {}, - ...overrides, - }; -} - -function makeFetchedAgentResult(agent: Agent): Awaited> { - return { - project: { - projectKey: agent.cwd, - projectName: "workspace", - checkout: { - cwd: agent.cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }, - agent: { - id: agent.id, - provider: agent.provider, - status: agent.status, - createdAt: agent.createdAt.toISOString(), - updatedAt: agent.updatedAt.toISOString(), - lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, - runtimeInfo: agent.runtimeInfo ?? { - provider: agent.provider, - sessionId: null, - }, - capabilities: agent.capabilities, - currentModeId: agent.currentModeId, - availableModes: agent.availableModes, - pendingPermissions: agent.pendingPermissions, - persistence: agent.persistence, - title: agent.title, - cwd: agent.cwd, - model: agent.model, - thinkingOptionId: agent.thinkingOptionId ?? null, - requiresAttention: agent.requiresAttention ?? false, - attentionReason: agent.attentionReason ?? null, - attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, - archivedAt: agent.archivedAt?.toISOString() ?? null, - labels: agent.labels, - lastError: agent.lastError ?? undefined, - }, - }; -} - -function seedReadyAgent(agent: Agent = makeAgent()) { - const store = useSessionStore.getState(); - store.initializeSession("server", makeClient()); - store.setAgents("server", new Map([["agent", agent]])); - store.setAgentAuthoritativeHistoryApplied("server", "agent", true); -} - -function buildTestPaneValue() { - return { - serverId: "server", - workspaceId: "workspace", - tabId: "agent-agent", - target: { kind: "agent" as const, agentId: "agent" }, - openTab: vi.fn(), - closeCurrentTab: vi.fn(), - retargetCurrentTab: vi.fn(), - openFileInWorkspace: vi.fn(), - }; -} - -async function renderAgentPanel( - root: Root, - focus: PaneFocusContextValue = { - isWorkspaceFocused: true, - isPaneFocused: false, - isInteractive: false, - focusPane: vi.fn(), - }, -) { - const AgentPanel = agentPanelRegistration.component; - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }); - const paneValue = buildTestPaneValue(); - await act(async () => { - root.render( - - - - - - - , - ); - await Promise.resolve(); - }); -} - -function updateCurrentAgentStream(text: string) { - act(() => { - useSessionStore.getState().setAgentStreamTail("server", (previous) => { - const next = new Map(previous); - next.set("agent", [ - { - kind: "user_message", - id: `message-${text}`, - text, - timestamp: new Date("2026-04-20T00:00:01.000Z"), - }, - ]); - return next; - }); - }); -} - -function makePendingPermission(agentId: string, requestId: string): PendingPermission { - const request: AgentPermissionRequest = { - id: requestId, - provider: "codex", - name: `permission-${requestId}`, - kind: "tool", - }; - const key = `${agentId}:${requestId}`; - return { - key, - agentId, - request, - }; -} - -function updatePendingPermissions(permissions: PendingPermission[]) { - act(() => { - useSessionStore - .getState() - .setPendingPermissions( - "server", - new Map(permissions.map((permission) => [permission.key, permission])), - ); - }); -} - -async function updateCurrentAgentCwd(cwd: string) { - await act(async () => { - useSessionStore.getState().setAgents("server", (previous) => { - const current = previous.get("agent"); - if (!current) { - throw new Error("Expected seeded agent"); - } - const next = new Map(previous); - next.set("agent", { ...current, cwd }); - return next; - }); - await Promise.resolve(); - }); -} - -function archiveSeededAgent(previous: Map): Map { - const current = previous.get("agent"); - if (!current) { - throw new Error("Expected seeded agent"); - } - const next = new Map(previous); - next.set("agent", { ...current, archivedAt: new Date("2026-04-20T00:00:02.000Z") }); - return next; -} - -describe("AgentPanel render isolation", () => { - let root: Root | null = null; - let container: HTMLElement | null = null; - - afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - root = null; - container?.remove(); - container = null; - useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); - useDraftStore.setState({ drafts: {}, createModalDraft: null }); - composerRenderCount.mockClear(); - composerUnmountCount.mockClear(); - latestComposerCwd.current = null; - latestComposerIsPaneFocused.current = null; - streamRenderCount.mockClear(); - latestStreamPermissionKeys.current = []; - latestStreamText.current = null; - runtimeIsConnected.current = false; - runtimeClient.fetchAgent.mockReset(); - runtimeClient.fetchAgentTimeline.mockReset(); - }); - - it("refreshes the stream view without invoking Composer for stream-only updates", async () => { - seedReadyAgent(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await renderAgentPanel(root); - const composerBaseline = composerRenderCount.mock.calls.length; - const streamBaseline = streamRenderCount.mock.calls.length; - expect(composerBaseline).toBeGreaterThan(0); - expect(streamBaseline).toBeGreaterThan(0); - - updateCurrentAgentStream("stream-only update"); - - expect(latestStreamText.current).toBe("stream-only update"); - expect(streamRenderCount).toHaveBeenCalledTimes(streamBaseline + 1); - expect(composerRenderCount).toHaveBeenCalledTimes(composerBaseline); - }); - - it("does not advertise the composer as focused when its workspace is hidden", async () => { - seedReadyAgent(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await renderAgentPanel(root, { - isWorkspaceFocused: false, - isPaneFocused: true, - isInteractive: false, - focusPane: vi.fn(), - }); - - expect(latestComposerIsPaneFocused.current).toBe(false); - }); - - it("still invokes Composer for current-agent cwd changes and unmounts it for archives", async () => { - seedReadyAgent(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await renderAgentPanel(root); - expect(latestComposerCwd.current).toBe("/workspace/one"); - const composerBaseline = composerRenderCount.mock.calls.length; - expect(composerBaseline).toBeGreaterThan(0); - - await updateCurrentAgentCwd("/workspace/two"); - - expect(latestComposerCwd.current).toBe("/workspace/two"); - expect(composerRenderCount.mock.calls.length).toBeGreaterThan(composerBaseline); - - await act(async () => { - useSessionStore.getState().setAgents("server", archiveSeededAgent); - await Promise.resolve(); - }); - - expect(composerUnmountCount).toHaveBeenCalledTimes(1); - }); - - it("keeps stream permissions stable for unrelated pending permission updates", async () => { - seedReadyAgent(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await renderAgentPanel(root); - const initialRenderCount = streamRenderCount.mock.calls.length; - expect(initialRenderCount).toBeGreaterThan(0); - expect(latestStreamPermissionKeys.current).toEqual([]); - - const unrelatedPermission = makePendingPermission("other-agent", "unrelated"); - updatePendingPermissions([unrelatedPermission]); - - expect(latestStreamPermissionKeys.current).toEqual([]); - expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount); - - const agentPermission = makePendingPermission("agent", "current-agent"); - updatePendingPermissions([unrelatedPermission, agentPermission]); - - expect(latestStreamPermissionKeys.current).toEqual(["agent:current-agent"]); - expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 1); - - updatePendingPermissions([unrelatedPermission]); - - expect(latestStreamPermissionKeys.current).toEqual([]); - expect(streamRenderCount).toHaveBeenCalledTimes(initialRenderCount + 2); - }); - - it("renders an archived lazy detail without adding it to the active agent store", async () => { - const archivedAgent = makeAgent({ - archivedAt: new Date("2026-04-20T00:00:02.000Z"), - }); - runtimeIsConnected.current = true; - runtimeClient.fetchAgent.mockResolvedValue(makeFetchedAgentResult(archivedAgent)); - runtimeClient.fetchAgentTimeline.mockResolvedValue({ - agent: null, - events: [], - nextCursor: null, - hasMore: false, - }); - useSessionStore - .getState() - .initializeSession("server", runtimeClient as unknown as DaemonClient); - useSessionStore.getState().setAgentAuthoritativeHistoryApplied("server", "agent", true); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await renderAgentPanel(root, { - isWorkspaceFocused: true, - isPaneFocused: true, - isInteractive: true, - focusPane: vi.fn(), - }); - - const timeoutAt = Date.now() + 300; - while ( - !container?.querySelector('[data-testid="archived-agent-callout"]') && - Date.now() < timeoutAt - ) { - await act(async () => { - await Promise.resolve(); - }); - } - - expect(container?.querySelector('[data-testid="archived-agent-callout"]')).not.toBeNull(); - - expect(useSessionStore.getState().sessions.server?.agents.has("agent")).toBe(false); - expect( - useSessionStore.getState().sessions.server?.agentDetails.get("agent")?.archivedAt, - ).toEqual(archivedAgent.archivedAt); - }); -});