feat(quick-pane): add chat-style exchange
This commit is contained in:
@@ -112,8 +112,20 @@ describe("QuickPane", () => {
|
||||
screen.getByPlaceholderText(/Ask a quick question/),
|
||||
"Inspect",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Ask" }));
|
||||
await screen.findByText(/Quick request running/);
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
const exchange = screen.getByRole("region", { name: "Quick exchange" });
|
||||
expect(within(exchange).getByText("Inspect")).toBeInTheDocument();
|
||||
const visualActivity = within(exchange).getByText(/Thinking · \d+s/);
|
||||
expect(visualActivity.closest(".quick-pane-activity")).toHaveAttribute(
|
||||
"aria-hidden",
|
||||
"true",
|
||||
);
|
||||
const activityAnnouncement = within(exchange).getByText("Thinking", {
|
||||
selector: ".quick-pane-activity-announcement",
|
||||
});
|
||||
expect(activityAnnouncement).toHaveAttribute("aria-live", "polite");
|
||||
expect(activityAnnouncement).toHaveTextContent("Thinking");
|
||||
expect(activityAnnouncement).not.toHaveTextContent("·");
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
expect(
|
||||
@@ -167,7 +179,7 @@ describe("QuickPane", () => {
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
fireEvent.change(input, { target: { value: "Inspect once" } });
|
||||
const ask = screen.getByRole("button", { name: "Ask" });
|
||||
const ask = screen.getByRole("button", { name: "Send request" });
|
||||
fireEvent.click(ask);
|
||||
fireEvent.click(ask);
|
||||
|
||||
@@ -176,7 +188,11 @@ describe("QuickPane", () => {
|
||||
([command]) => command === "create_quick_runtime",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Starting…" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Starting request" }),
|
||||
).toBeDisabled();
|
||||
expect(input).toHaveFocus();
|
||||
expect(input).toHaveAttribute("readonly");
|
||||
|
||||
await act(async () => {
|
||||
resolveCreate?.({
|
||||
@@ -186,6 +202,65 @@ describe("QuickPane", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("mounts an empty busy live region before the first snapshot and keeps it through completion", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveSnapshot!: (value: ReturnType<typeof snapshot>) => void;
|
||||
const firstSnapshot = new Promise<ReturnType<typeof snapshot>>(
|
||||
(resolve) => {
|
||||
resolveSnapshot = resolve;
|
||||
},
|
||||
);
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
case "create_quick_runtime":
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
case "get_session_runtime_snapshot":
|
||||
return firstSnapshot;
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByRole("textbox", { name: "Request" });
|
||||
await user.type(input, "Inspect this");
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
|
||||
const assistantResponse = screen.getByRole("region", {
|
||||
name: "Assistant response",
|
||||
});
|
||||
expect(assistantResponse).toBeEmptyDOMElement();
|
||||
expect(assistantResponse).toHaveAttribute("aria-live", "polite");
|
||||
expect(assistantResponse).toHaveAttribute("aria-busy", "true");
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
|
||||
agentId: "agent-1",
|
||||
message: expect.any(String),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
resolveSnapshot(snapshot("Final answer", false));
|
||||
await firstSnapshot;
|
||||
});
|
||||
expect(await screen.findByText("Final answer")).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: "Assistant response" })).toBe(
|
||||
assistantResponse,
|
||||
);
|
||||
expect(assistantResponse).toHaveAttribute("aria-busy", "false");
|
||||
expect(
|
||||
within(assistantResponse).getByText("Response complete."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears only the submitted request after a completed non-streaming answer", async () => {
|
||||
const user = userEvent.setup();
|
||||
let currentSnapshot = snapshot("Partial answer", true);
|
||||
@@ -211,20 +286,44 @@ describe("QuickPane", () => {
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
await user.type(input, "Inspect this");
|
||||
await user.click(screen.getByRole("button", { name: "Ask" }));
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
|
||||
expect(await screen.findByText("Partial answer")).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole("region", { name: "Quick exchange" })).getByText(
|
||||
"Inspect this",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const assistantResponse = screen.getByRole("region", {
|
||||
name: "Assistant response",
|
||||
});
|
||||
expect(assistantResponse).toHaveAttribute("aria-live", "polite");
|
||||
expect(assistantResponse).toHaveAttribute("aria-busy", "true");
|
||||
expect(
|
||||
within(assistantResponse).queryByText("Response complete."),
|
||||
).toBeNull();
|
||||
expect(input).toHaveValue("Inspect this");
|
||||
expect(input).toHaveAttribute("readonly");
|
||||
expect(input).toHaveFocus();
|
||||
|
||||
currentSnapshot = snapshot("Final answer", false);
|
||||
expect(
|
||||
await screen.findByText("Final answer", {}, { timeout: 2_000 }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
expect(assistantResponse).toHaveAttribute("aria-busy", "false");
|
||||
expect(
|
||||
within(assistantResponse).getByText("Response complete."),
|
||||
).toBeInTheDocument();
|
||||
expect(input).not.toHaveAttribute("readonly");
|
||||
expect(input).toHaveFocus();
|
||||
|
||||
await user.type(input, "Next draft");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 900));
|
||||
expect(input).toHaveValue("Next draft");
|
||||
expect(
|
||||
within(assistantResponse).getByText("Response complete."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps five in-memory answers and history selection preserves current answer", async () => {
|
||||
@@ -263,7 +362,7 @@ describe("QuickPane", () => {
|
||||
const input = await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
await user.type(input, `Request ${index}`);
|
||||
await user.click(screen.getByRole("button", { name: "Ask" }));
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
expect(await screen.findByText(`Answer ${index}`)).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
}
|
||||
@@ -277,17 +376,320 @@ describe("QuickPane", () => {
|
||||
within(history).getByRole("option", { name: "Request 2" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.type(input, "Composer draft");
|
||||
await user.selectOptions(history, "runtime-2");
|
||||
expect(screen.getByText("Answer 2")).toBeInTheDocument();
|
||||
const exchange = screen.getByRole("region", { name: "Quick exchange" });
|
||||
expect(within(exchange).getByText("Request 2")).toBeInTheDocument();
|
||||
expect(within(exchange).getByText("Answer 2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Answer 6")).toBeNull();
|
||||
expect(
|
||||
within(exchange).getByRole("region", { name: "Assistant response" }),
|
||||
).toHaveAttribute("aria-live", "off");
|
||||
expect(input).toHaveValue("Composer draft");
|
||||
await user.selectOptions(history, "");
|
||||
expect(screen.getByText("Answer 6")).toBeInTheDocument();
|
||||
expect(within(exchange).getByText("Request 6")).toBeInTheDocument();
|
||||
expect(within(exchange).getByText("Answer 6")).toBeInTheDocument();
|
||||
expect(
|
||||
within(exchange).getByRole("region", { name: "Assistant response" }),
|
||||
).toHaveAttribute("aria-live", "polite");
|
||||
expect(input).toHaveValue("Composer draft");
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"save_ui_state",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses partial escalation prefixes and delays its notice until completion", async () => {
|
||||
const user = userEvent.setup();
|
||||
let currentSnapshot = snapshot("", true);
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
case "create_quick_runtime":
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
case "get_session_runtime_snapshot":
|
||||
return Promise.resolve(currentSnapshot);
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByRole("textbox", { name: "Request" });
|
||||
await user.type(input, "Change files");
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
|
||||
const assistantResponse = screen.getByRole("region", {
|
||||
name: "Assistant response",
|
||||
});
|
||||
const prefixes = ["[", "[ESCAL", "[ESCALATE]"];
|
||||
for (const [index, prefix] of prefixes.entries()) {
|
||||
currentSnapshot = {
|
||||
...snapshot(prefix, true),
|
||||
runtime: { activeTool: `functions.prefix_${index + 1}` },
|
||||
};
|
||||
expect(
|
||||
await screen.findByText(
|
||||
`Prefix ${index + 1}`,
|
||||
{ selector: ".quick-pane-activity > span:last-child" },
|
||||
{ timeout: 2_000 },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(assistantResponse).toBeEmptyDOMElement();
|
||||
expect(
|
||||
screen.queryByText("Continue in full session"),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
|
||||
currentSnapshot = snapshot("[ESCALATE]Needs full session.", true);
|
||||
expect(
|
||||
await within(assistantResponse).findByText(
|
||||
"Needs full session.",
|
||||
{},
|
||||
{ timeout: 2_000 },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(assistantResponse).toHaveAttribute("aria-busy", "true");
|
||||
expect(
|
||||
screen.queryByText("Continue in full session"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
currentSnapshot = snapshot("[ESCALATE]Needs full session.", false);
|
||||
await waitFor(
|
||||
() => expect(assistantResponse).toHaveAttribute("aria-busy", "false"),
|
||||
{ timeout: 2_000 },
|
||||
);
|
||||
expect(assistantResponse).toHaveAttribute("aria-live", "polite");
|
||||
expect(assistantResponse).toHaveAttribute("aria-busy", "false");
|
||||
expect(
|
||||
within(assistantResponse).getAllByText("Response complete."),
|
||||
).toHaveLength(1);
|
||||
expect(screen.getByText("Continue in full session")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never renders or stores a completed partial escalation marker", async () => {
|
||||
const user = userEvent.setup();
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
case "create_quick_runtime":
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
case "get_session_runtime_snapshot":
|
||||
return Promise.resolve(snapshot("[ESCAL", false));
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByRole("textbox", { name: "Request" });
|
||||
await user.type(input, "Needs review");
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
|
||||
const assistantResponse = screen.getByRole("region", {
|
||||
name: "Assistant response",
|
||||
});
|
||||
await waitFor(
|
||||
() => expect(assistantResponse).toHaveAttribute("aria-busy", "false"),
|
||||
{ timeout: 2_000 },
|
||||
);
|
||||
expect(assistantResponse).not.toHaveTextContent("[ESCAL");
|
||||
expect(screen.queryByText("Continue in full session")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Answer history" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps runtime error and extension visible while viewing history", async () => {
|
||||
const user = userEvent.setup();
|
||||
let runtimeCount = 0;
|
||||
let runtimeFailed = false;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
case "create_quick_runtime":
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: {
|
||||
agentId: `agent-${runtimeCount}`,
|
||||
runtimeId: `runtime-${runtimeCount}`,
|
||||
},
|
||||
});
|
||||
case "get_session_runtime_snapshot":
|
||||
if (payload?.runtimeId === "runtime-1")
|
||||
return Promise.resolve(snapshot("First answer", false));
|
||||
return Promise.resolve({
|
||||
runtime: {
|
||||
activeTool: "functions.read",
|
||||
error: runtimeFailed
|
||||
? { message: "Runtime disconnected." }
|
||||
: undefined,
|
||||
},
|
||||
state: { data: { isStreaming: true } },
|
||||
transcript: { data: { messages: [] } },
|
||||
extensions: [
|
||||
{
|
||||
id: "permission-1",
|
||||
method: "confirm",
|
||||
title: "Run guarded command",
|
||||
message: "Approval needed",
|
||||
},
|
||||
],
|
||||
});
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByRole("textbox", { name: "Request" });
|
||||
await user.type(input, "First request");
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
expect(
|
||||
await screen.findByText("First answer", {}, { timeout: 2_000 }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
|
||||
const history = screen.getByRole("combobox", { name: "Answer history" });
|
||||
await user.type(input, "Active request");
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "Review request" }),
|
||||
).toBeInTheDocument();
|
||||
expect(history).toBeDisabled();
|
||||
expect(history).toHaveValue("");
|
||||
|
||||
runtimeFailed = true;
|
||||
expect(
|
||||
await screen.findByText("Runtime disconnected.", {}, { timeout: 2_000 }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(history).not.toBeDisabled());
|
||||
await user.selectOptions(history, "runtime-1");
|
||||
|
||||
const exchange = screen.getByRole("region", { name: "Quick exchange" });
|
||||
expect(within(exchange).getByText("First request")).toBeInTheDocument();
|
||||
expect(within(exchange).getByText("First answer")).toBeInTheDocument();
|
||||
expect(within(exchange).getByRole("alert")).toHaveTextContent(
|
||||
"Runtime disconnected.",
|
||||
);
|
||||
expect(within(exchange).getByRole("status")).toHaveTextContent(
|
||||
"Quick runtime failed.",
|
||||
);
|
||||
expect(
|
||||
within(exchange).getByRole("dialog", { name: "Review request" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Run guarded command")).toBeInTheDocument();
|
||||
expect(screen.getByText("Approval needed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders safe GFM Markdown and reports active tools without tool cards", async () => {
|
||||
const user = userEvent.setup();
|
||||
let currentSnapshot: ReturnType<typeof snapshot> & {
|
||||
runtime: { activeTool?: string };
|
||||
} = {
|
||||
...snapshot("", true),
|
||||
runtime: { activeTool: "functions.grep" },
|
||||
};
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
switch (command) {
|
||||
case "load_ui_state":
|
||||
return Promise.resolve(uiState);
|
||||
case "get_model_catalog":
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
case "create_quick_runtime":
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
case "get_session_runtime_snapshot":
|
||||
return Promise.resolve(currentSnapshot);
|
||||
default:
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByRole("textbox", { name: "Request" });
|
||||
await user.type(input, "Explain output");
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
expect(
|
||||
await screen.findByText("Searching files", {
|
||||
selector: ".quick-pane-activity > span:last-child",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
currentSnapshot = snapshot(
|
||||
"## Result\n\n- item\n\n`const safe = true`\n\n[unsafe](javascript:alert(1))\n\n<img src=x onerror=alert(1)>",
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole(
|
||||
"heading",
|
||||
{ name: "Result" },
|
||||
{ timeout: 2_000 },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("const safe = true")).toBeInTheDocument();
|
||||
expect(screen.getByText("unsafe").closest("a")).toHaveAttribute("href", "");
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
expect(document.querySelector("details.tool-result")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses Enter to send and leaves Shift+Enter available for newlines", async () => {
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "get_model_catalog")
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "provider", id: "quick/model" }],
|
||||
});
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-1", runtimeId: "runtime-1" },
|
||||
});
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve(snapshot("", true));
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
const input = await screen.findByRole("textbox", { name: "Request" });
|
||||
fireEvent.change(input, { target: { value: "Inspect" } });
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"create_quick_runtime",
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("create_quick_runtime", {
|
||||
worktreePath: "/repo",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves close interception native while preserving close and Escape hiding", async () => {
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
@@ -300,7 +702,9 @@ describe("QuickPane", () => {
|
||||
await screen.findByPlaceholderText(/Ask a quick question/);
|
||||
expect(mocks.onCloseRequested).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||
);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mocks.hide).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user