refactor(app): replace 4 component test slop files with pure module extractions (#724)
Delete message-input.test.tsx, left-sidebar.test.tsx, agent-stream-view.test.tsx, and agent-panel.test.tsx — all heavy vi.mock + JSDOM + toHaveBeenCalledWith slop. Coverage preserved by extracting the testable derivations: - agent-stream-view-data.ts: isSameAssistantBlockGroup, getAssistantBlockSpacing, resolveInlineWorkingIndicatorItemId — 14 pure unit tests, zero mocks - message-input-state.ts: computeCanStartDictation (dictation readiness gate) — 7 pure unit tests, zero mocks Remaining behaviors confirmed covered by existing E2E: - left-sidebar subscription-while-hidden → sidebar-workspace.spec.ts - agent-panel render isolation + archived agent store hygiene → archive-tab.spec.ts - message-input attachment menu / submit icon → workspace-setup-streaming.spec.ts
This commit is contained in:
@@ -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<StreamItem, { kind: "assistant_message" }> {
|
||||||
|
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<StreamItem, { kind: "tool_call" }> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, StreamItem[]>(),
|
|
||||||
workspaces: new Map(),
|
|
||||||
agentTimelineCursor: new Map(),
|
|
||||||
agentTimelineHasOlder: new Map(),
|
|
||||||
agentTimelineOlderFetchInFlight: new Map(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setAgentTimelineOlderFetchInFlight: () => {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => ({
|
|
||||||
StyleSheet: {
|
|
||||||
create: (
|
|
||||||
factory: (theme: {
|
|
||||||
borderRadius: Record<string, number>;
|
|
||||||
borderWidth: Record<number, number>;
|
|
||||||
colors: Record<string, string>;
|
|
||||||
fontSize: Record<string, number>;
|
|
||||||
fontWeight: Record<string, string>;
|
|
||||||
shadow: Record<string, object>;
|
|
||||||
spacing: Record<number, number>;
|
|
||||||
}) => 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<string, unknown>) => 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<StreamItem, { kind: "assistant_message" }> {
|
|
||||||
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<StreamItem, { kind: "tool_call" }> {
|
|
||||||
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"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -71,6 +71,11 @@ import {
|
|||||||
type StreamSegmentRenderers,
|
type StreamSegmentRenderers,
|
||||||
type StreamViewportHandle,
|
type StreamViewportHandle,
|
||||||
} from "./agent-stream-render-strategy";
|
} from "./agent-stream-render-strategy";
|
||||||
|
import {
|
||||||
|
getAssistantBlockSpacing,
|
||||||
|
isSameAssistantBlockGroup,
|
||||||
|
resolveInlineWorkingIndicatorItemId,
|
||||||
|
} from "./agent-stream-view-data";
|
||||||
import {
|
import {
|
||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
@@ -92,43 +97,6 @@ const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
|||||||
const isToolSequenceItem = (item?: StreamItem) =>
|
const isToolSequenceItem = (item?: StreamItem) =>
|
||||||
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
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 {
|
interface StreamItemBoundarySeams {
|
||||||
aboveItem?: StreamItem | null;
|
aboveItem?: StreamItem | null;
|
||||||
belowItem?: StreamItem | null;
|
belowItem?: StreamItem | null;
|
||||||
@@ -301,25 +269,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [isMobile, streamHead, streamItems]);
|
}, [isMobile, streamHead, streamItems]);
|
||||||
const inlineWorkingIndicatorItemId = useMemo(() => {
|
const inlineWorkingIndicatorItemId = useMemo(
|
||||||
if (agent.status !== "running") {
|
() =>
|
||||||
return null;
|
resolveInlineWorkingIndicatorItemId(
|
||||||
}
|
agent.status,
|
||||||
const footerItem = baseRenderModel.segments.liveHead.find((item, index, items) => {
|
baseRenderModel.segments.liveHead,
|
||||||
if (item.kind !== "assistant_message") {
|
streamRenderStrategy,
|
||||||
return false;
|
),
|
||||||
}
|
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
|
||||||
return (
|
);
|
||||||
getStreamNeighborItem({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
items,
|
|
||||||
index,
|
|
||||||
relation: "below",
|
|
||||||
}) === undefined
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return footerItem?.id ?? null;
|
|
||||||
}, [agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy]);
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -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<string, unknown>) =>
|
|
||||||
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<string>(),
|
|
||||||
shortcutIndexByWorkspaceKey: new Map<string, number>(),
|
|
||||||
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(<LeftSidebar />);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
|
|
||||||
serverId: "srv",
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof MessageInput>["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: <T,>(component: T) => component,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/constants/platform", () => ({
|
|
||||||
isWeb: false,
|
|
||||||
isNative: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react-native", () => {
|
|
||||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
|
||||||
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<string, unknown> }) => 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<string, unknown>) =>
|
|
||||||
asChild ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<button type="button" aria-label={props.accessibilityLabel as string | undefined}>
|
|
||||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
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;
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
|
|
||||||
{typeof children === "function"
|
|
||||||
? children({ hovered: false, pressed: false, open: false })
|
|
||||||
: children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
|
||||||
<div data-testid={testID}>{children}</div>
|
|
||||||
),
|
|
||||||
DropdownMenuItem: ({
|
|
||||||
children,
|
|
||||||
onSelect,
|
|
||||||
testID,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
onSelect?: () => void;
|
|
||||||
testID?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => (
|
|
||||||
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
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("<!doctype html><html><body></body></html>");
|
|
||||||
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(
|
|
||||||
<MessageInput
|
|
||||||
value={value}
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={menuItems}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
submitIcon={submitIcon}
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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<HTMLElement> {
|
|
||||||
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<MessageInputRef>();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<MessageInput
|
|
||||||
ref={inputRef}
|
|
||||||
value=""
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
isReadyForDictation={false}
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
inputRef.current?.runKeyboardAction("dictation-toggle");
|
|
||||||
});
|
|
||||||
expect(startDictationMock).not.toHaveBeenCalled();
|
|
||||||
expect(confirmDictationMock).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<MessageInput
|
|
||||||
ref={inputRef}
|
|
||||||
value=""
|
|
||||||
onChangeText={vi.fn()}
|
|
||||||
onSubmit={vi.fn()}
|
|
||||||
attachments={EMPTY_ATTACHMENTS}
|
|
||||||
cwd="/repo"
|
|
||||||
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
|
|
||||||
client={FAKE_CONNECTED_CLIENT}
|
|
||||||
isAgentRunning={false}
|
|
||||||
isReadyForDictation
|
|
||||||
onQueue={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
inputRef.current?.runKeyboardAction("dictation-toggle");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(startDictationMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(confirmDictationMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -53,6 +53,7 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
|||||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useComposerHeightMirror } from "./composer-height-mirror";
|
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||||
|
import { computeCanStartDictation } from "./message-input-state";
|
||||||
|
|
||||||
export type ImageAttachment = AttachmentMetadata;
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
@@ -968,19 +969,6 @@ function computeSendableContent(input: SendableContentInput): SendableContentOut
|
|||||||
return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton };
|
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(
|
function computeIsDictationStartEnabled(
|
||||||
isReadyForDictation: boolean | undefined,
|
isReadyForDictation: boolean | undefined,
|
||||||
isConnected: boolean,
|
isConnected: boolean,
|
||||||
|
|||||||
@@ -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<number, number>;
|
|
||||||
borderRadius: Record<string, number>;
|
|
||||||
fontSize: Record<string, number>;
|
|
||||||
fontWeight: Record<string, string>;
|
|
||||||
iconSize: Record<string, number>;
|
|
||||||
}
|
|
||||||
type PanelTestStyles = Record<string, unknown>;
|
|
||||||
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 }) => <div>{children}</div>,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-native-unistyles", () => {
|
|
||||||
return {
|
|
||||||
StyleSheet: {
|
|
||||||
create: createPanelTestStyles,
|
|
||||||
},
|
|
||||||
withUnistyles: <T,>(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 }) => <div>{children}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
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 }) => (
|
|
||||||
<div data-testid="archived-agent-callout">{agentId}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
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 <div data-testid="composer">{cwd}</div>;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/agent-stream-view", () => ({
|
|
||||||
AgentStreamView: React.memo(
|
|
||||||
React.forwardRef<
|
|
||||||
{ prepareForViewportChange: () => void; scrollToBottom: (reason: "message-sent") => void },
|
|
||||||
{ pendingPermissions: Map<string, PendingPermission>; 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 <div data-testid="agent-stream-view">{latestStreamText.current}</div>;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function makeClient(): DaemonClient {
|
|
||||||
return new DaemonClient({
|
|
||||||
url: "ws://127.0.0.1:1",
|
|
||||||
clientId: "panel-render-isolation-test",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAgent(overrides: Partial<Agent> = {}): 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<ReturnType<DaemonClient["fetchAgent"]>> {
|
|
||||||
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(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<PaneProvider value={paneValue}>
|
|
||||||
<PaneFocusProvider value={focus}>
|
|
||||||
<AgentPanel />
|
|
||||||
</PaneFocusProvider>
|
|
||||||
</PaneProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
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<string, Agent>): Map<string, Agent> {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user