Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIConversationRailQuery.search.spec.tsx
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

1190 lines
39 KiB
TypeScript

import {
normalizeAgentActivitySession,
type AgentActivityTurn
} from "@tutti-os/agent-activity-core";
import { TooltipProvider } from "@tutti-os/ui-system";
import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor
} from "@testing-library/react";
import { Profiler, useLayoutEffect, type PropsWithChildren } from "react";
import { describe, expect, it, vi } from "vitest";
import {
AgentGUIRuntimeProvider,
type AgentGUIRuntime,
type AgentActivityRuntimeListSessionsPageInput
} from "../../../agentActivityRuntime";
import type { AgentHostUserProjectsApi } from "../../../host/agentHostApi";
import { createTestAgentSessionEngine } from "../../../shared/testing/createTestAgentSessionEngine";
import type { AgentGUINodeData } from "../../../types";
import { useAgentGUIConversationRailQuery } from "./useAgentGUIConversationRailQuery";
import { useAgentGUILocalState } from "./useAgentGUILocalState";
import {
projectDragAutoScrollDelta,
useAgentGUIProjectDrag
} from "./useAgentGUIProjectDrag";
import { AgentGUIConversationRailPane } from "../view/AgentGUIConversationRailPane";
import type { AgentGUIViewLabels } from "../view/AgentGUINodeView.types";
import { createDefaultWorkspaceUserProjectI18nRuntime } from "@tutti-os/workspace-user-project/i18n";
describe("useAgentGUIConversationRailQuery search", () => {
it("respects the runtime first-page refresh limit across Rail scopes", async () => {
type ConversationFilter =
| { agentTargetId: string; kind: "agentTarget" }
| { kind: "all" };
const engine = createTestAgentSessionEngine("workspace-1");
const sessions = Array.from({ length: 21 }, (_, index) =>
normalizeAgentActivitySession({
activeTurnId: null,
agentSessionId: `session-${index + 1}`,
agentTargetId: "shared:one",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey: "conversations",
title: `Session ${index + 1}`,
updatedAtUnixMs: index + 1,
workspaceId: "workspace-1"
})
);
const listSessionSections = vi.fn(
async (input: { workspaceId: string }) => ({
sections: [
{
hasMore: true,
kind: "conversations" as const,
sectionKey: "conversations",
sessions,
totalCount: sessions.length + 1
}
],
workspaceId: input.workspaceId
})
);
const runtime = {
conversationRailQueryLimits: { sectionRefreshLimitMax: 20 },
getSessionEngine: () => engine,
listSessionSectionPage: vi.fn(),
listSessionSections
} as unknown as AgentGUIRuntime;
const wrapper = ({ children }: PropsWithChildren) => (
<AgentGUIRuntimeProvider runtime={runtime}>
{children}
</AgentGUIRuntimeProvider>
);
const { rerender, unmount } = renderHook(
({ conversationFilter }: { conversationFilter: ConversationFilter }) =>
useAgentGUIConversationRailQuery({
activeConversationId: null,
conversationFilter,
conversationQuery: "",
userProjects: [],
workspaceId: "workspace-1"
}),
{
initialProps: {
conversationFilter: {
agentTargetId: "shared:one",
kind: "agentTarget"
}
},
wrapper
}
);
await waitFor(() => expect(listSessionSections).toHaveBeenCalledTimes(1));
rerender({ conversationFilter: { kind: "all" } });
await waitFor(() => expect(listSessionSections).toHaveBeenCalledTimes(2));
expect(listSessionSections).toHaveBeenLastCalledWith(
expect.objectContaining({ limitPerSection: 20 })
);
unmount();
engine.dispose();
});
it("adds the Desktop node identity through the runtime diagnostic adapter", async () => {
const engine = createTestAgentSessionEngine("workspace-1");
const reportDiagnostic = vi.fn();
const runtime = {
getSessionEngine: () => engine,
async listSessionSections() {
throw new Error("section membership unavailable");
},
async listSessionSectionPage() {
throw new Error("section membership unavailable");
},
reportDiagnostic
} as unknown as AgentGUIRuntime;
const wrapper = ({ children }: PropsWithChildren) => (
<AgentGUIRuntimeProvider runtime={runtime}>
{children}
</AgentGUIRuntimeProvider>
);
const { unmount } = renderHook(
() =>
useAgentGUIConversationRailQuery({
activeConversationId: null,
conversationFilter: { kind: "all" },
conversationQuery: "",
nodeId: "desktop-node-1",
userProjects: [],
workspaceId: "workspace-1"
}),
{ wrapper }
);
await waitFor(() =>
expect(reportDiagnostic).toHaveBeenCalledWith(
expect.objectContaining({
details: expect.objectContaining({
nodeId: "desktop-node-1"
}),
event: "agent_gui.conversation_rail.first_pages_failed"
})
)
);
unmount();
engine.dispose();
});
it("fails batch deletion closed and reports a partial runtime contract", async () => {
const engine = createTestAgentSessionEngine("workspace-1");
const reportDiagnostic = vi.fn();
const runtime = {
deleteSessionsBatch: vi.fn(),
getSessionEngine: () => engine,
reportDiagnostic
} as unknown as AgentGUIRuntime;
const wrapper = ({ children }: PropsWithChildren) => (
<AgentGUIRuntimeProvider runtime={runtime}>
{children}
</AgentGUIRuntimeProvider>
);
const { result } = renderHook(
() =>
useAgentGUIConversationRailQuery({
activeConversationId: null,
conversationFilter: { kind: "all" },
conversationQuery: "",
userProjects: [],
workspaceId: "workspace-1"
}),
{ wrapper }
);
expect(result.current.batchDeletionAvailable).toBe(false);
await waitFor(() =>
expect(reportDiagnostic).toHaveBeenCalledWith({
details: {
missingMethods: ["listSessionSectionDeletionCandidates"]
},
event: "agent.gui.conversation_batch_delete.capability_incomplete",
level: "warn",
source: "agent-gui",
workspaceId: "workspace-1"
})
);
});
it("commits the latest dragover target even before React renders it", async () => {
const projects = ["Alpha", "Beta", "Gamma"].map((label) => ({
id: label.toLowerCase(),
label,
path: `/workspace/${label.toLowerCase()}`,
pinnedAtUnixMs: 0,
sectionKey: `project:/workspace/${label.toLowerCase()}`
}));
const moveProject = vi.fn(() => Promise.resolve());
const header = document.createElement("div");
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.dataset.projectDragIcon = "true";
header.append(icon);
document.body.append(header);
const dataTransfer = {
dropEffect: "none",
effectAllowed: "none",
setData() {},
setDragImage() {}
};
const event = {
clientY: 0,
currentTarget: header,
dataTransfer,
preventDefault: vi.fn(),
target: header
} as unknown as React.DragEvent<HTMLElement>;
const alphaSection = {
id: projects[0]?.sectionKey ?? "",
items: [],
kind: "project" as const,
label: "Alpha",
project: projects[0] ?? null
};
const gammaSection = {
id: projects[2]?.sectionKey ?? "",
items: [],
kind: "project" as const,
label: "Gamma",
project: projects[2] ?? null
};
const { result } = renderHook(() =>
useAgentGUIProjectDrag({
disabled: false,
onMoveProject: moveProject,
scrollViewportRef: { current: null },
userProjects: projects
})
);
act(() => result.current.start(alphaSection, event));
const updateTargetBeforeRender = result.current.updateTarget;
const dropBeforeRender = result.current.drop;
await act(async () => {
updateTargetBeforeRender(gammaSection, "after", event);
await dropBeforeRender(event);
});
expect(moveProject).toHaveBeenCalledWith("alpha", null);
act(() => result.current.start(gammaSection, event));
const updateTopTargetBeforeRender = result.current.updateTarget;
const dropAtTopBeforeRender = result.current.drop;
await act(async () => {
updateTopTargetBeforeRender(alphaSection, "before", event);
await dropAtTopBeforeRender(event);
});
expect(moveProject).toHaveBeenNthCalledWith(2, "gamma", "alpha");
header.remove();
});
it("rejects cross-partition drops and resolves the end of the pinned partition as null", async () => {
const projects = [
{
id: "alpha",
label: "Alpha",
path: "/alpha",
pinnedAtUnixMs: 20,
sectionKey: "project:/alpha"
},
{
id: "beta",
label: "Beta",
path: "/beta",
pinnedAtUnixMs: 10,
sectionKey: "project:/beta"
},
{
id: "gamma",
label: "Gamma",
path: "/gamma",
pinnedAtUnixMs: 0,
sectionKey: "project:/gamma"
}
];
const moveProject = vi.fn(() => Promise.resolve());
const header = document.createElement("div");
const folderIcon = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg"
);
folderIcon.dataset.projectDragIcon = "true";
header.append(folderIcon);
document.body.append(header);
const dataTransfer = {
dropEffect: "none",
effectAllowed: "none",
setData() {},
setDragImage: vi.fn()
};
const event = {
clientY: 0,
currentTarget: header,
dataTransfer,
preventDefault: vi.fn(),
target: header
} as unknown as React.DragEvent<HTMLElement>;
const sectionFor = (index: number) => ({
id: projects[index]?.sectionKey ?? "",
items: [],
kind: "project" as const,
label: projects[index]?.label ?? "",
project: projects[index] ?? null
});
const { result } = renderHook(() =>
useAgentGUIProjectDrag({
disabled: false,
onMoveProject: moveProject,
scrollViewportRef: { current: null },
userProjects: projects
})
);
act(() => result.current.start(sectionFor(0), event));
const dragImage = dataTransfer.setDragImage.mock
.calls[0]?.[0] as HTMLElement;
expect(dragImage.querySelectorAll("svg")).toHaveLength(1);
act(() => result.current.updateTarget(sectionFor(2), "before", event));
expect(dataTransfer.dropEffect).toBe("none");
expect(result.current.dragState?.indicator).toBeNull();
await act(async () => result.current.drop(event));
expect(moveProject).not.toHaveBeenCalled();
act(() => result.current.start(sectionFor(0), event));
act(() => result.current.updateTarget(sectionFor(1), "after", event));
await act(async () => result.current.drop(event));
expect(moveProject).toHaveBeenCalledWith("alpha", null);
header.remove();
});
it("marks a newly rendered target unresolved before its controller effect settles", async () => {
const engine = createTestAgentSessionEngine("workspace-1");
const sectionResolvers: Array<() => void> = [];
const runtime = {
getSessionEngine: () => engine,
listSessionSections: vi.fn(
(input: { workspaceId: string }) =>
new Promise<{ sections: []; workspaceId: string }>((resolve) => {
sectionResolvers.push(() =>
resolve({ sections: [], workspaceId: input.workspaceId })
);
})
),
listSessionSectionPage: vi.fn()
} as unknown as AgentGUIRuntime;
const wrapper = ({ children }: PropsWithChildren) => (
<AgentGUIRuntimeProvider runtime={runtime}>
{children}
</AgentGUIRuntimeProvider>
);
const layoutObservations: Array<{
agentTargetId: string;
scopeResolved: boolean;
}> = [];
function ScopeResolutionProbe({
agentTargetId
}: {
agentTargetId: string;
}): null {
const query = useAgentGUIConversationRailQuery({
activeConversationId: null,
conversationFilter: { agentTargetId, kind: "agentTarget" },
conversationQuery: "",
userProjects: [],
workspaceId: "workspace-1"
});
useLayoutEffect(() => {
layoutObservations.push({
agentTargetId,
scopeResolved: query.runtimeRailScopeResolved
});
}, [agentTargetId, query.runtimeRailScopeResolved]);
return null;
}
const view = render(<ScopeResolutionProbe agentTargetId="local:codex" />, {
wrapper
});
await waitFor(() => expect(sectionResolvers).toHaveLength(1));
await act(async () => sectionResolvers.shift()?.());
await waitFor(() =>
expect(layoutObservations).toContainEqual({
agentTargetId: "local:codex",
scopeResolved: true
})
);
const observationCountBeforeSwitch = layoutObservations.length;
view.rerender(<ScopeResolutionProbe agentTargetId="local:claude-code" />);
expect(layoutObservations[observationCountBeforeSwitch]).toEqual({
agentTargetId: "local:claude-code",
scopeResolved: false
});
view.unmount();
engine.dispose();
});
it("searches every backend page and stores returned entities in the workspace engine", async () => {
const engine = createTestAgentSessionEngine("workspace-1");
const calls: AgentActivityRuntimeListSessionsPageInput[] = [];
const firstSession = searchSession("session-1", "backend first result", 2);
const secondSession = searchSession("session-2", "backend older result", 1);
const runtime = {
getSessionEngine: () => engine,
async listSessionsPage(input: AgentActivityRuntimeListSessionsPageInput) {
calls.push(input);
return input.cursor
? {
hasMore: false,
sessions: [secondSession],
workspaceId: input.workspaceId
}
: {
hasMore: true,
nextCursor: "2|session-1",
sessions: [firstSession],
workspaceId: input.workspaceId
};
}
} as unknown as AgentGUIRuntime;
const wrapper = ({ children }: PropsWithChildren) => (
<AgentGUIRuntimeProvider runtime={runtime}>
{children}
</AgentGUIRuntimeProvider>
);
const { result } = renderHook(
() =>
useAgentGUIConversationRailQuery({
activeConversationId: null,
conversationFilter: {
agentTargetId: " target-1 ",
kind: "agentTarget"
},
conversationQuery: " backend ",
userProjects: [],
workspaceId: "workspace-1"
}),
{ wrapper }
);
await waitFor(() => {
expect(calls).toHaveLength(1);
expect(result.current.railSearch.sessionIds).toEqual(["session-1"]);
});
expect(calls[0]).toMatchObject({
agentTargetId: "target-1",
limit: 100,
searchQuery: "backend",
workspaceId: "workspace-1"
});
expect(result.current.railSearch.hasMore).toBe(true);
expect(
engine.getSnapshot().sessionLifecycle.sessionsById["session-1"]?.title
).toBe("backend first result");
const loadMoreSectionConversations =
result.current.loadMoreSectionConversations;
const loadMoreSearchResults = result.current.railSearch.loadMore;
const retrySearchResults = result.current.railSearch.retry;
await act(async () => {
result.current.railSearch.loadMore();
await Promise.resolve();
});
expect(calls[1]).toMatchObject({
cursor: "2|session-1",
searchQuery: "backend",
workspaceId: "workspace-1"
});
expect(result.current.railSearch.sessionIds).toEqual([
"session-1",
"session-2"
]);
expect(result.current.railSearch.hasMore).toBe(false);
expect(result.current.loadMoreSectionConversations).toBe(
loadMoreSectionConversations
);
expect(result.current.railSearch.loadMore).toBe(loadMoreSearchResults);
expect(result.current.railSearch.retry).toBe(retrySearchResults);
expect(
engine.getSnapshot().sessionLifecycle.sessionsById["session-2"]?.title
).toBe("backend older result");
});
it("exposes retry after an initial backend search failure", async () => {
const engine = createTestAgentSessionEngine("workspace-1");
let requestCount = 0;
const runtime = {
getSessionEngine: () => engine,
async listSessionsPage(input: AgentActivityRuntimeListSessionsPageInput) {
requestCount += 1;
if (requestCount === 1) throw new Error("search unavailable");
return {
hasMore: false,
sessions: [searchSession("session-retried", "retried result", 1)],
workspaceId: input.workspaceId
};
}
} as unknown as AgentGUIRuntime;
const wrapper = ({ children }: PropsWithChildren) => (
<AgentGUIRuntimeProvider runtime={runtime}>
{children}
</AgentGUIRuntimeProvider>
);
const { result } = renderHook(
() =>
useAgentGUIConversationRailQuery({
activeConversationId: null,
conversationFilter: { kind: "all" },
conversationQuery: "backend",
userProjects: [],
workspaceId: "workspace-1"
}),
{ wrapper }
);
await waitFor(() => expect(result.current.railSearch.failed).toBe(true));
act(() => result.current.railSearch.retry());
await waitFor(() =>
expect(result.current.railSearch.sessionIds).toEqual(["session-retried"])
);
expect(result.current.railSearch.failed).toBe(false);
});
it("keeps the rail query idle when a streaming turn update does not change rail presentation", async () => {
const engine = createTestAgentSessionEngine("workspace-1");
engine.dispatch({
type: "session/upserted",
session: normalizeAgentActivitySession({
activeTurnId: "turn-1",
agentSessionId: "session-1",
agentTargetId: "target-1",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey: "conversations",
title: "streaming session",
updatedAtUnixMs: 1,
workspaceId: "workspace-1"
})
});
engine.dispatch({
live: true,
type: "turn/upserted",
turn: runningTurn(1)
});
const runtime = {
getSessionEngine: () => engine
} as unknown as AgentGUIRuntime;
const wrapper = ({ children }: PropsWithChildren) => (
<AgentGUIRuntimeProvider runtime={runtime}>
{children}
</AgentGUIRuntimeProvider>
);
let renderCount = 0;
const { result } = renderHook(
() => {
renderCount += 1;
return useAgentGUIConversationRailQuery({
activeConversationId: "session-1",
conversationFilter: { kind: "all" },
conversationQuery: "",
userProjects: [],
workspaceId: "workspace-1"
});
},
{ wrapper }
);
await waitFor(() =>
expect(result.current.runtimeRailConversations).toHaveLength(1)
);
const previousResult = result.current;
const previousRenderCount = renderCount;
act(() => {
engine.dispatch({
live: true,
type: "turn/upserted",
turn: runningTurn(2)
});
});
expect(renderCount).toBe(previousRenderCount);
expect(result.current).toBe(previousResult);
});
it("keeps the mounted rail idle for render-irrelevant engine updates", async () => {
const engine = createTestAgentSessionEngine("workspace-1");
engine.dispatch({
type: "session/upserted",
session: normalizeAgentActivitySession({
activeTurnId: "turn-1",
agentSessionId: "session-1",
agentTargetId: "target-1",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
railSectionKey: "conversations",
title: "streaming session",
updatedAtUnixMs: 1,
workspaceId: "workspace-1"
})
});
engine.dispatch({
live: true,
type: "turn/upserted",
turn: runningTurn(1)
});
const runtime = {
getSessionEngine: () => engine
} as unknown as AgentGUIRuntime;
let railCommitCount = 0;
function RailHarness(): React.JSX.Element {
const railQuery = useAgentGUIConversationRailQuery({
activeConversationId: "session-1",
conversationFilter: { kind: "all" },
conversationQuery: "streaming",
userProjects: [],
workspaceId: "workspace-1"
});
const activeConversation = railQuery.runtimeRailConversations[0] ?? null;
return (
<Profiler
id="conversation-rail"
onRender={() => {
railCommitCount += 1;
}}
>
<AgentGUIConversationRailPane
revealRequest={null}
activeConversation={activeConversation}
activeConversationId="session-1"
agentTargets={[]}
agentTargetsLoading={false}
conversationFilter={{ kind: "all" }}
conversationQuery="streaming"
conversations={railQuery.runtimeRailConversations}
createConversationDisabled={false}
isCollapsed={false}
isDeletingConversation={false}
isDeletingProjectConversations={false}
isLoadingConversations={false}
labels={RAIL_LABELS}
pendingDeleteConversationId={null}
railQuery={railQuery}
uiLanguage="en"
userProjects={[]}
workspaceId="workspace-1"
workspaceUserProjectI18n={RAIL_PROJECT_I18N}
onCancelDeleteConversation={() => {}}
onConfirmDeleteConversation={() => {}}
onConfirmDeleteConversations={async () => true}
onConfirmDeleteProjectConversations={async () => []}
onConversationQueryChange={() => {}}
onCreateConversation={() => {}}
onRemoveProject={async () => true}
onMoveProject={async () => {}}
onRequestDeleteConversation={() => {}}
onRequestRenameConversation={() => {}}
onSelectConversation={() => {}}
onSelectConversationFilterTarget={() => {}}
onToggleConversationPinned={() => {}}
onToggleProjectPinned={async () => {}}
onMarkConversationUnread={() => {}}
onUpdateConversationFilter={() => {}}
/>
</Profiler>
);
}
render(
<AgentGUIRuntimeProvider runtime={runtime}>
<TooltipProvider>
<RailHarness />
</TooltipProvider>
</AgentGUIRuntimeProvider>
);
await screen.findByText("streaming session");
const previousRailCommitCount = railCommitCount;
act(() => {
engine.dispatch({
live: true,
type: "turn/upserted",
turn: runningTurn(2)
});
});
expect(railCommitCount).toBe(previousRailCommitCount);
});
it("keeps preloaded service projects ordered, visible, and draggable during local search fallback", async () => {
const engine = createTestAgentSessionEngine("workspace-1");
const runtime = {
getSessionEngine: () => engine,
async listSessionSections() {
throw new Error("section membership unavailable");
},
async listSessionSectionPage() {
throw new Error("section membership unavailable");
}
} as unknown as AgentGUIRuntime;
const userProjectsApi = {
service: {
getSnapshot: () => ({
error: null,
initialized: true,
isLoading: false,
projects: [
{
id: "workspace-project",
label: "Workspace",
path: "/workspace",
sectionKey: "project:/workspace",
createdAtUnixMs: 1,
updatedAtUnixMs: 1,
lastUsedAtUnixMs: 1,
pinnedAtUnixMs: 10
},
{
id: "other-project",
label: "Other",
path: "/other",
sectionKey: "project:/other",
createdAtUnixMs: 1,
updatedAtUnixMs: 1,
lastUsedAtUnixMs: 1,
pinnedAtUnixMs: 5
}
],
revision: 1
})
}
} as AgentHostUserProjectsApi;
function RailHarness(): React.JSX.Element {
const { userProjects } = useAgentGUILocalState({
data: { lastActiveAgentSessionId: null } as AgentGUINodeData,
userProjectsApi
});
const railQuery = useAgentGUIConversationRailQuery({
activeConversationId: null,
conversationFilter: { kind: "all" },
conversationQuery: "does-not-match",
userProjects,
workspaceId: "workspace-1"
});
return (
<AgentGUIConversationRailPane
revealRequest={null}
activeConversation={null}
activeConversationId={null}
agentTargets={[]}
agentTargetsLoading={false}
conversationFilter={{ kind: "all" }}
conversationQuery="does-not-match"
conversations={[]}
createConversationDisabled={false}
isCollapsed={false}
isDeletingConversation={false}
isDeletingProjectConversations={false}
isLoadingConversations={false}
labels={RAIL_LABELS}
pendingDeleteConversationId={null}
railQuery={railQuery}
uiLanguage="en"
userProjects={userProjects}
workspaceId="workspace-1"
workspaceUserProjectI18n={RAIL_PROJECT_I18N}
onCancelDeleteConversation={() => {}}
onConfirmDeleteConversation={() => {}}
onConfirmDeleteConversations={async () => true}
onConfirmDeleteProjectConversations={async () => []}
onConversationQueryChange={() => {}}
onCreateConversation={() => {}}
onRemoveProject={async () => true}
onMoveProject={async () => {}}
onRequestDeleteConversation={() => {}}
onRequestRenameConversation={() => {}}
onSelectConversation={() => {}}
onSelectConversationFilterTarget={() => {}}
onToggleConversationPinned={() => {}}
onToggleProjectPinned={async () => {}}
onMarkConversationUnread={() => {}}
onUpdateConversationFilter={() => {}}
/>
);
}
render(
<AgentGUIRuntimeProvider runtime={runtime}>
<TooltipProvider>
<RailHarness />
</TooltipProvider>
</AgentGUIRuntimeProvider>
);
const workspaceTitle = await screen.findByText("Workspace");
const otherTitle = screen.getByText("Other");
const pinnedTitle = screen.getByText("Pinned");
const projectTitle = screen.getByText("Project");
expect(
pinnedTitle.compareDocumentPosition(workspaceTitle) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
workspaceTitle.compareDocumentPosition(otherTitle) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
otherTitle.compareDocumentPosition(projectTitle) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
screen.getByRole("combobox", { name: "Project unavailable" })
).toBeInTheDocument();
const workspaceSection = workspaceTitle.closest("section");
expect(workspaceSection).toBeTruthy();
const workspaceHeader = workspaceSection?.firstElementChild;
expect(workspaceHeader).toBeTruthy();
await waitFor(() =>
expect((workspaceHeader as HTMLElement).draggable).toBe(true)
);
expect(screen.queryByText("Conversations")).toBeNull();
expect(screen.queryByText("Conversation unavailable")).toBeNull();
});
it("drags a project header to the end without reordering until drop", async () => {
const animationFrames: FrameRequestCallback[] = [];
const requestAnimationFrame = vi
.spyOn(window, "requestAnimationFrame")
.mockImplementation((callback) => {
animationFrames.push(callback);
return animationFrames.length;
});
const cancelAnimationFrame = vi
.spyOn(window, "cancelAnimationFrame")
.mockImplementation(() => {});
const engine = createTestAgentSessionEngine("workspace-1");
const runtime = {
async deleteSessionsBatch() {
return {
cleanupFailedSessionIds: [],
removedMessages: 0,
removedSessionIds: [],
removedSessions: 0
};
},
getSessionEngine: () => engine,
async listSessionSections() {
throw new Error("section membership unavailable");
},
async listSessionSectionPage() {
throw new Error("section membership unavailable");
},
async listSessionSectionDeletionCandidates() {
return {
excludePinned: false,
sectionKey: "project:/workspace/alpha",
sessionIds: [],
workspaceId: "workspace-1"
};
}
} as unknown as AgentGUIRuntime;
const userProjects = ["Alpha", "Beta", "Gamma"].map((label) => ({
createdAtUnixMs: 1,
id: label.toLowerCase(),
label,
lastUsedAtUnixMs: 1,
path: `/workspace/${label.toLowerCase()}`,
pinnedAtUnixMs: 0,
sectionKey: `project:/workspace/${label.toLowerCase()}`,
updatedAtUnixMs: 1
}));
let resolveMoveProject!: () => void;
const moveProjectPromise = new Promise<void>((resolve) => {
resolveMoveProject = resolve;
});
const moveProject = vi.fn(() => moveProjectPromise);
function RailHarness({
isMutationPending = false
}: {
isMutationPending?: boolean;
}): React.JSX.Element {
const railQuery = useAgentGUIConversationRailQuery({
activeConversationId: null,
conversationFilter: { kind: "all" },
conversationQuery: "",
userProjects,
workspaceId: "workspace-1"
});
return (
<AgentGUIConversationRailPane
revealRequest={null}
activeConversation={null}
activeConversationId={null}
agentTargets={[]}
agentTargetsLoading={false}
conversationFilter={{ kind: "all" }}
conversationQuery=""
conversations={[]}
createConversationDisabled={false}
isCollapsed={false}
isDeletingConversation={false}
isDeletingProjectConversations={false}
isLoadingConversations={false}
isUserProjectMutationPending={isMutationPending}
labels={RAIL_LABELS}
pendingDeleteConversationId={null}
railQuery={railQuery}
uiLanguage="en"
userProjects={userProjects}
workspaceId="workspace-1"
workspaceUserProjectI18n={RAIL_PROJECT_I18N}
onCancelDeleteConversation={() => {}}
onConfirmDeleteConversation={() => {}}
onConfirmDeleteConversations={async () => true}
onConfirmDeleteProjectConversations={async () => []}
onConversationQueryChange={() => {}}
onCreateConversation={() => {}}
onMarkConversationUnread={() => {}}
onMoveProject={moveProject}
onRemoveProject={async () => true}
onRequestDeleteConversation={() => {}}
onRequestRenameConversation={() => {}}
onSelectConversation={() => {}}
onSelectConversationFilterTarget={() => {}}
onToggleConversationPinned={() => {}}
onToggleProjectPinned={async () => {}}
onUpdateConversationFilter={() => {}}
/>
);
}
const rendered = render(
<AgentGUIRuntimeProvider runtime={runtime}>
<TooltipProvider>
<RailHarness />
</TooltipProvider>
</AgentGUIRuntimeProvider>
);
const alphaSection = (await screen.findByText("Alpha")).closest("section");
const gammaSection = screen.getByText("Gamma").closest("section");
const conversationsSection = screen
.getByText("Conversations")
.closest("section");
const alphaHeader = alphaSection?.firstElementChild as HTMLElement;
const setDragImage = vi.fn();
const dataTransfer = {
dropEffect: "none",
effectAllowed: "none",
setData() {},
setDragImage
};
expect(
conversationsSection?.getAttribute("data-project-dragging")
).toBeNull();
await waitFor(() => expect(alphaHeader.draggable).toBe(true));
const alphaToggle = alphaHeader.querySelector("button") as HTMLElement;
const moreButton = screen.getAllByRole("button", {
name: "Project actions"
})[0] as HTMLElement;
fireEvent.pointerDown(moreButton, { button: 0, ctrlKey: false });
const removeMenuItem = await screen.findByText("Remove");
expect(alphaHeader.draggable).toBe(false);
fireEvent.click(removeMenuItem);
await screen.findByText("Remove project?");
expect(alphaHeader.draggable).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(screen.queryByText("Remove project?")).toBeNull()
);
expect(alphaHeader.draggable).toBe(true);
fireEvent.click(alphaToggle);
expect(alphaToggle.getAttribute("aria-expanded")).toBe("false");
const alphaAction = alphaHeader.querySelector(
"[data-project-drag-block] button"
) as HTMLElement;
fireEvent.dragStart(alphaAction, { dataTransfer });
expect(alphaSection?.getAttribute("data-project-dragging")).toBeNull();
fireEvent.dragStart(alphaHeader, { dataTransfer });
expect(alphaSection?.getAttribute("data-project-dragging")).toBe("true");
expect(alphaSection?.getAttribute("data-collapsed")).toBe("true");
const dragImage = setDragImage.mock.calls[0]?.[0] as HTMLElement;
expect(dragImage.querySelectorAll("svg")).toHaveLength(1);
expect(dragImage.querySelector("[data-project-drag-icon]")).toBeTruthy();
expect(dragImage.textContent).toBe("Alpha");
expect(
[...document.querySelectorAll('section[data-kind="project"]')].map(
(section) =>
section.querySelector("[class*=conversation-section-label]")
?.textContent
)
).toEqual(["Alpha", "Beta", "Gamma"]);
const viewports = [
...document.querySelectorAll<HTMLElement>(
'[data-slot="scroll-area-viewport"]'
)
];
for (const viewport of viewports) {
viewport.scrollTop = 100;
vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
bottom: 200,
height: 200,
left: 0,
right: 100,
top: 0,
width: 100,
x: 0,
y: 0,
toJSON: () => ({})
});
}
animationFrames.length = 0;
fireEvent.dragOver(gammaSection as HTMLElement, {
clientY: 1,
dataTransfer
});
expect(animationFrames.length).toBeGreaterThan(0);
expect(projectDragAutoScrollDelta(1, { bottom: 200, top: 0 })).toBeLessThan(
0
);
expect(
projectDragAutoScrollDelta(199, { bottom: 200, top: 0 })
).toBeGreaterThan(0);
expect(gammaSection?.getAttribute("data-project-drop-indicator")).toBe(
"after"
);
fireEvent.drop(
gammaSection?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement,
{ dataTransfer }
);
await waitFor(() =>
expect(moveProject).toHaveBeenCalledWith("alpha", null)
);
expect(
[...document.querySelectorAll('section[data-kind="project"]')].every(
(section) => !(section.firstElementChild as HTMLElement).draggable
)
).toBe(true);
resolveMoveProject();
await waitFor(() => expect(alphaHeader.draggable).toBe(true));
expect(alphaSection?.getAttribute("data-project-dragging")).toBeNull();
expect(dragImage.isConnected).toBe(false);
expect(cancelAnimationFrame).toHaveBeenCalled();
fireEvent.dragStart(alphaHeader, { dataTransfer });
const outsideImage = setDragImage.mock.calls.at(-1)?.[0] as HTMLElement;
fireEvent.drop(document, { dataTransfer });
expect(outsideImage.isConnected).toBe(false);
fireEvent.dragStart(alphaHeader, { dataTransfer });
const dragEndImage = setDragImage.mock.calls.at(-1)?.[0] as HTMLElement;
fireEvent.dragEnd(alphaHeader, { dataTransfer });
expect(dragEndImage.isConnected).toBe(false);
rendered.rerender(
<AgentGUIRuntimeProvider runtime={runtime}>
<TooltipProvider>
<RailHarness isMutationPending />
</TooltipProvider>
</AgentGUIRuntimeProvider>
);
const lockedHeader = screen.getByText("Alpha").closest("section")
?.firstElementChild as HTMLElement;
expect(lockedHeader.draggable).toBe(false);
const dragImageCallCount = setDragImage.mock.calls.length;
fireEvent.dragStart(lockedHeader, { dataTransfer });
expect(setDragImage).toHaveBeenCalledTimes(dragImageCallCount);
rendered.rerender(
<AgentGUIRuntimeProvider runtime={runtime}>
<TooltipProvider>
<RailHarness />
</TooltipProvider>
</AgentGUIRuntimeProvider>
);
const unlockedHeader = screen.getByText("Alpha").closest("section")
?.firstElementChild as HTMLElement;
fireEvent.dragStart(unlockedHeader, { dataTransfer });
const unmountImage = setDragImage.mock.calls.at(-1)?.[0] as HTMLElement;
rendered.unmount();
expect(unmountImage.isConnected).toBe(false);
requestAnimationFrame.mockRestore();
cancelAnimationFrame.mockRestore();
});
});
const RAIL_LABELS = {
batchDeleteProjectSessions: "Delete conversations",
cancel: "Cancel",
conversationUnavailable: "Conversation unavailable",
conversationsSectionMoreActions: "Conversation actions",
deleteSession: "Delete",
deleteSessionConfirm: "Confirm delete",
emptyProjectConversations: "No conversations",
fallbackAgentTitle: "Agent",
loadingConversations: "Loading conversations",
newConversation: "New conversation",
noConversations: "No conversations",
openConversationWindow: "Open in window",
pinSession: "Pin",
projectRailCreateProject: "Create project",
projectRailLinkExistingProject: "Link project",
projectSectionMoreActions: "Project actions",
projectSectionViewFiles: "View files",
pinProject: "Pin project",
unpinProject: "Unpin project",
pinnedProjectAccessibleName: (label: string) => `Pinned project: ${label}`,
renameSession: "Rename",
removeProject: "Remove",
removeProjectConfirmDescription: (label: string) => `Remove ${label}`,
removeProjectConfirmTitle: "Remove project?",
retrySearch: "Retry",
searchFailed: "Search failed",
searchNoConversations: "No search results",
searchPlaceholder: "Search",
sectionConversations: "Conversations",
sectionPinned: "Pinned",
showLessConversations: "Show less",
showMoreConversations: "Show more",
unpinSession: "Unpin"
} as AgentGUIViewLabels;
const RAIL_PROJECT_I18N = createDefaultWorkspaceUserProjectI18nRuntime();
function runningTurn(updatedAtUnixMs: number): AgentActivityTurn {
return {
agentSessionId: "session-1",
origin: "user_prompt",
phase: "running",
startedAtUnixMs: 1,
turnId: "turn-1",
updatedAtUnixMs
};
}
function searchSession(
agentSessionId: string,
title: string,
updatedAtUnixMs: number
) {
return normalizeAgentActivitySession({
activeTurnId: null,
agentSessionId,
agentTargetId: "target-1",
cwd: "/workspace",
latestTurnInteractions: [],
pendingInteractions: [],
provider: "codex",
title,
updatedAtUnixMs,
workspaceId: "workspace-1"
});
}