feat(quick-pane): add compact runtime controls
This commit is contained in:
@@ -230,7 +230,7 @@ describe("QuickPane", () => {
|
||||
await preparedTrigger();
|
||||
const pane = container.querySelector(".quick-pane");
|
||||
expect(
|
||||
Array.from(pane?.children ?? []).map((element) => element.className),
|
||||
Array.from(pane?.children ?? []).map((element) => element.classList[0]),
|
||||
).toEqual([
|
||||
"quick-pane-drag-strip",
|
||||
"quick-pane-toolbar",
|
||||
@@ -245,10 +245,144 @@ describe("QuickPane", () => {
|
||||
await preparedTrigger();
|
||||
fireEvent.pointerDown(container.querySelector(".quick-pane-drag-strip")!);
|
||||
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Close Quick Pane" }));
|
||||
fireEvent.pointerDown(
|
||||
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||
);
|
||||
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks pane interaction behind one catalog loading overlay", async () => {
|
||||
const tracker = deferredCatalogBridge();
|
||||
render(<QuickPane />);
|
||||
|
||||
const overlay = screen.getByRole("dialog", { name: "Model catalog" });
|
||||
expect(overlay).toHaveTextContent("Loading models…");
|
||||
expect(screen.getAllByText("Loading models…")).toHaveLength(1);
|
||||
expect(screen.getByRole("textbox", { name: "Request" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await act(async () =>
|
||||
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
|
||||
);
|
||||
expect(await preparedTrigger()).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: "Model catalog" })).toBeNull();
|
||||
expect(screen.getByRole("textbox", { name: "Request" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("restores textarea caret after Tab to Send and Shift+Tab", async () => {
|
||||
bridgeMock([defaultModels]);
|
||||
const user = userEvent.setup();
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Request",
|
||||
}) as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: "Inspect selection" } });
|
||||
input.focus();
|
||||
input.setSelectionRange(7, 7, "none");
|
||||
fireEvent.select(input);
|
||||
|
||||
await user.tab();
|
||||
expect(screen.getByRole("button", { name: "Send request" })).toHaveFocus();
|
||||
await user.tab({ shift: true });
|
||||
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
await waitFor(() => {
|
||||
expect(input.selectionStart).toBe(7);
|
||||
expect(input.selectionEnd).toBe(7);
|
||||
expect(input.selectionDirection).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps browser click selection instead of restoring stale range", async () => {
|
||||
bridgeMock([defaultModels]);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Request",
|
||||
}) as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: "Inspect selection" } });
|
||||
input.focus();
|
||||
input.setSelectionRange(2, 2, "none");
|
||||
fireEvent.select(input);
|
||||
input.blur();
|
||||
|
||||
fireEvent.pointerDown(input);
|
||||
input.focus();
|
||||
input.setSelectionRange(11, 11, "none");
|
||||
fireEvent.select(input);
|
||||
fireEvent.click(input);
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||
expect(input.selectionStart).toBe(11);
|
||||
expect(input.selectionEnd).toBe(11);
|
||||
});
|
||||
|
||||
it("restores selection direction on programmatic focus without rewriting value", async () => {
|
||||
bridgeMock([defaultModels]);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Request",
|
||||
}) as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: "Inspect selection" } });
|
||||
input.focus();
|
||||
input.setSelectionRange(2, 9, "backward");
|
||||
fireEvent.select(input);
|
||||
input.blur();
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)!;
|
||||
const setValue = vi.fn(function (this: HTMLTextAreaElement, value: string) {
|
||||
descriptor.set!.call(this, value);
|
||||
});
|
||||
Object.defineProperty(input, "value", {
|
||||
configurable: true,
|
||||
get: () => descriptor.get!.call(input),
|
||||
set: setValue,
|
||||
});
|
||||
|
||||
input.focus();
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
await waitFor(() => {
|
||||
expect(input.selectionStart).toBe(2);
|
||||
expect(input.selectionEnd).toBe(9);
|
||||
expect(input.selectionDirection).toBe("backward");
|
||||
});
|
||||
expect(setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restore stale selection after completion clears and focuses draft", async () => {
|
||||
let snapshot = runtimeSnapshot(defaultModels, "Partial", true);
|
||||
bridgeMock([defaultModels], () => snapshot);
|
||||
const user = userEvent.setup();
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
const input = screen.getByRole("textbox", {
|
||||
name: "Request",
|
||||
}) as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: "Inspect selection" } });
|
||||
input.focus();
|
||||
input.setSelectionRange(2, 9, "backward");
|
||||
fireEvent.select(input);
|
||||
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||
await waitFor(() => expect(input).toHaveAttribute("readonly"));
|
||||
|
||||
snapshot = runtimeSnapshot(defaultModels, "Completed", false);
|
||||
expect(await screen.findByText("Completed")).toBeInTheDocument();
|
||||
await waitFor(() => expect(input).toHaveValue(""));
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||
|
||||
expect(input.selectionStart).toBe(0);
|
||||
expect(input.selectionEnd).toBe(0);
|
||||
expect(input.selectionDirection).toBe("none");
|
||||
});
|
||||
|
||||
it("derives thinking choices from selected model metadata and falls back to Off", async () => {
|
||||
const thinkingModels: Model[] = [
|
||||
{
|
||||
@@ -258,14 +392,17 @@ describe("QuickPane", () => {
|
||||
thinkingLevelMap: { off: "off", high: "high" },
|
||||
},
|
||||
];
|
||||
bridgeMock([thinkingModels], () => runtimeSnapshot(thinkingModels, "", true));
|
||||
bridgeMock([thinkingModels], () =>
|
||||
runtimeSnapshot(thinkingModels, "", true),
|
||||
);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
const thinking = screen.getByRole("combobox", { name: "Thinking" });
|
||||
expect(within(thinking).getAllByRole("option").map((option) => option.textContent)).toEqual([
|
||||
"Off",
|
||||
"High",
|
||||
]);
|
||||
expect(
|
||||
within(thinking)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent),
|
||||
).toEqual(["Off", "High"]);
|
||||
await userEvent.selectOptions(thinking, "high");
|
||||
await ask("Inspect");
|
||||
await waitFor(() =>
|
||||
@@ -291,7 +428,9 @@ describe("QuickPane", () => {
|
||||
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("abort", { agentId: "agent-1" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Send request" })).toBeEnabled(),
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Send request" }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
|
||||
streaming = false;
|
||||
@@ -304,10 +443,13 @@ describe("QuickPane", () => {
|
||||
runtimeSnapshot(defaultModels, "Partial answer", true),
|
||||
);
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation((command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "abort") return Promise.reject(new Error("abort unavailable"));
|
||||
return baseImplementation?.(command, payload);
|
||||
});
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "abort")
|
||||
return Promise.reject(new Error("abort unavailable"));
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Inspect");
|
||||
@@ -340,18 +482,36 @@ describe("QuickPane", () => {
|
||||
).toHaveClass("model-results");
|
||||
});
|
||||
|
||||
it("closes an open selector on first Escape and dismisses pane on second", async () => {
|
||||
it("grows for an open selector, restores content height on Escape, then dismisses", async () => {
|
||||
const user = userEvent.setup();
|
||||
bridgeMock([defaultModels]);
|
||||
render(<QuickPane />);
|
||||
const trigger = await preparedTrigger();
|
||||
await user.click(trigger);
|
||||
await waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus());
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("resize_quick_pane", {
|
||||
height: 520,
|
||||
}),
|
||||
);
|
||||
const resizeCallCountBeforeClose = mocks.invoke.mock.calls.filter(
|
||||
([command]) => command === "resize_quick_pane",
|
||||
).length;
|
||||
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane");
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
await waitFor(() => {
|
||||
const resizeCalls = mocks.invoke.mock.calls.filter(
|
||||
([command]) => command === "resize_quick_pane",
|
||||
);
|
||||
expect(resizeCalls.length).toBeGreaterThan(resizeCallCountBeforeClose);
|
||||
expect(resizeCalls[resizeCalls.length - 1]).toEqual([
|
||||
"resize_quick_pane",
|
||||
{ height: 240 },
|
||||
]);
|
||||
});
|
||||
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
await waitFor(() =>
|
||||
@@ -359,6 +519,41 @@ describe("QuickPane", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retries a failed native menu expansion while selector remains open", async () => {
|
||||
bridgeMock([defaultModels]);
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
let failed = false;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { height?: number }) => {
|
||||
if (
|
||||
command === "resize_quick_pane" &&
|
||||
payload?.height === 520 &&
|
||||
!failed
|
||||
) {
|
||||
failed = true;
|
||||
return Promise.reject(new Error("resize unavailable"));
|
||||
}
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<QuickPane />);
|
||||
await user.click(await preparedTrigger());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
mocks.invoke.mock.calls.filter(
|
||||
([command, payload]) =>
|
||||
command === "resize_quick_pane" &&
|
||||
(payload as { height?: number })?.height === 520,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Choose model" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows only one submission start while commands are pending", async () => {
|
||||
const pendingModel = deferred<unknown>();
|
||||
const tracker = bridgeMock([defaultModels]);
|
||||
@@ -412,14 +607,21 @@ describe("QuickPane", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears only submitted draft and restores focus after completion", async () => {
|
||||
let current = runtimeSnapshot(defaultModels, "Partial answer", true);
|
||||
it("shows current humanized activity inside streaming assistant card", async () => {
|
||||
let current = runtimeSnapshot(defaultModels, "Partial answer", true, {
|
||||
...runtimeSnapshot(defaultModels).runtime,
|
||||
activeTool: "functions.read",
|
||||
});
|
||||
bridgeMock([defaultModels], () => current);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
const input = screen.getByRole("textbox", { name: "Request" });
|
||||
await ask("Inspect this");
|
||||
expect(await screen.findByText("Partial answer")).toBeInTheDocument();
|
||||
const partialAnswer = await screen.findByText("Partial answer");
|
||||
expect(partialAnswer).toBeInTheDocument();
|
||||
expect(
|
||||
within(partialAnswer.closest("article")!).getByText("Reading file"),
|
||||
).toHaveClass("quick-pane-assistant-activity");
|
||||
expect(input).toHaveValue("Inspect this");
|
||||
expect(input).toHaveAttribute("readonly");
|
||||
|
||||
@@ -435,6 +637,32 @@ describe("QuickPane", () => {
|
||||
expect(input).toHaveValue("Next draft");
|
||||
});
|
||||
|
||||
it("follows transcript near bottom without overriding manual scroll-up", async () => {
|
||||
let current = runtimeSnapshot(defaultModels, "Partial answer", true);
|
||||
bridgeMock([defaultModels], () => current);
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Inspect this");
|
||||
const response = screen.getByRole("region", { name: "Assistant response" });
|
||||
Object.defineProperties(response, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 1_000 },
|
||||
scrollTop: { configurable: true, value: 880, writable: true },
|
||||
});
|
||||
fireEvent.scroll(response);
|
||||
|
||||
current = runtimeSnapshot(defaultModels, "Updated answer", true);
|
||||
expect(await screen.findByText("Updated answer")).toBeInTheDocument();
|
||||
await waitFor(() => expect(response.scrollTop).toBe(1_000));
|
||||
|
||||
response.scrollTop = 100;
|
||||
fireEvent.scroll(response);
|
||||
current = runtimeSnapshot(defaultModels, "Latest answer", true);
|
||||
expect(await screen.findByText("Latest answer")).toBeInTheDocument();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 50));
|
||||
expect(response.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("shows manual continuation after a completed normal answer and hands off transcript", async () => {
|
||||
bridgeMock([defaultModels], () =>
|
||||
snapshotWithTranscript([
|
||||
@@ -459,7 +687,9 @@ describe("QuickPane", () => {
|
||||
const continueButton = await screen.findByRole("button", {
|
||||
name: "Continue in full session",
|
||||
});
|
||||
expect(continueButton.closest(".quick-pane-assistant-footer")).not.toBeNull();
|
||||
expect(
|
||||
continueButton.closest(".quick-pane-assistant-footer"),
|
||||
).not.toBeNull();
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"create_session_runtime",
|
||||
expect.anything(),
|
||||
@@ -533,7 +763,11 @@ describe("QuickPane", () => {
|
||||
const cards = screen
|
||||
.getByRole("region", { name: "Assistant response" })
|
||||
.querySelectorAll("article");
|
||||
expect(Array.from(cards).map((card) => card.textContent)).toEqual([
|
||||
expect(
|
||||
Array.from(cards).map((card) =>
|
||||
card.textContent?.replace(/\d+(?:m \d+)?s/, ""),
|
||||
),
|
||||
).toEqual([
|
||||
"YouFirst request",
|
||||
"AssistantFirst answer",
|
||||
"YouSecond request",
|
||||
@@ -541,6 +775,196 @@ describe("QuickPane", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps distinct elapsed times for duplicate follow-up requests", async () => {
|
||||
let now = 100_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const submittedPrompts: string[] = [];
|
||||
let firstComplete = false;
|
||||
let secondComplete = false;
|
||||
bridgeMock([defaultModels], () => {
|
||||
const messages: Message[] = [];
|
||||
if (submittedPrompts[0])
|
||||
messages.push(
|
||||
{ role: "user", content: submittedPrompts[0] },
|
||||
{ role: "assistant", content: "First answer" },
|
||||
);
|
||||
if (submittedPrompts[1])
|
||||
messages.push(
|
||||
{ role: "user", content: submittedPrompts[1] },
|
||||
{ role: "assistant", content: "Second answer" },
|
||||
);
|
||||
return snapshotWithTranscript(
|
||||
messages,
|
||||
submittedPrompts.length === 1 ? !firstComplete : !secondComplete,
|
||||
);
|
||||
});
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "submit_prompt" && payload?.message)
|
||||
submittedPrompts.push(String(payload.message));
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Same request");
|
||||
expect(await screen.findByText("First answer")).toBeInTheDocument();
|
||||
now = 165_000;
|
||||
firstComplete = true;
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("1m 5s")).toHaveClass(
|
||||
"quick-pane-assistant-elapsed",
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Request" }),
|
||||
).not.toHaveAttribute("readonly"),
|
||||
);
|
||||
|
||||
now = 166_000;
|
||||
await ask("Same request");
|
||||
expect(submittedPrompts[1]).toBe(submittedPrompts[0]);
|
||||
now = 168_000;
|
||||
secondComplete = true;
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("2s")).toHaveClass(
|
||||
"quick-pane-assistant-elapsed",
|
||||
),
|
||||
);
|
||||
const assistantCards = screen
|
||||
.getByRole("region", { name: "Assistant response" })
|
||||
.querySelectorAll(".quick-pane-assistant-message");
|
||||
expect(
|
||||
within(assistantCards[0]! as HTMLElement).getByText("1m 5s"),
|
||||
).toHaveClass("quick-pane-assistant-elapsed");
|
||||
expect(
|
||||
within(assistantCards[1]! as HTMLElement).getByText("2s"),
|
||||
).toHaveClass("quick-pane-assistant-elapsed");
|
||||
});
|
||||
|
||||
it.each(["set_model", "set_thinking_level", "submit_prompt"])(
|
||||
"keeps only retry timing when %s rejects before first submission",
|
||||
async (failingCommand) => {
|
||||
let now = 100_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
let failed = false;
|
||||
let acceptedPrompt = "";
|
||||
let complete = false;
|
||||
bridgeMock([defaultModels], () =>
|
||||
acceptedPrompt
|
||||
? snapshotWithTranscript(
|
||||
[
|
||||
{ role: "user", content: acceptedPrompt },
|
||||
{ role: "assistant", content: "Retry answer" },
|
||||
],
|
||||
!complete,
|
||||
)
|
||||
: runtimeSnapshot(defaultModels),
|
||||
);
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === failingCommand && !failed) {
|
||||
failed = true;
|
||||
return Promise.reject(new Error(`${failingCommand} unavailable`));
|
||||
}
|
||||
if (command === "submit_prompt" && payload?.message)
|
||||
acceptedPrompt = String(payload.message);
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Same request");
|
||||
expect(
|
||||
await screen.findByText(
|
||||
`Could not start quick request: Error: ${failingCommand} unavailable`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "Request" }),
|
||||
).not.toHaveAttribute("readonly"),
|
||||
);
|
||||
|
||||
now = 200_000;
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Send request" }),
|
||||
);
|
||||
await waitFor(() => expect(acceptedPrompt).not.toBe(""));
|
||||
now = 202_000;
|
||||
complete = true;
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByText("2s")).toHaveClass(
|
||||
"quick-pane-assistant-elapsed",
|
||||
),
|
||||
{ timeout: 2_000 },
|
||||
);
|
||||
expect(screen.queryByText("1m 42s")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not let a failed different request claim later timing", async () => {
|
||||
let now = 100_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
let failed = false;
|
||||
let acceptedPrompt = "";
|
||||
let complete = false;
|
||||
bridgeMock([defaultModels], () =>
|
||||
acceptedPrompt
|
||||
? snapshotWithTranscript(
|
||||
[
|
||||
{ role: "user", content: acceptedPrompt },
|
||||
{ role: "assistant", content: "Second answer" },
|
||||
],
|
||||
!complete,
|
||||
)
|
||||
: runtimeSnapshot(defaultModels),
|
||||
);
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "submit_prompt" && !failed) {
|
||||
failed = true;
|
||||
return Promise.reject(new Error("submit unavailable"));
|
||||
}
|
||||
if (command === "submit_prompt" && payload?.message)
|
||||
acceptedPrompt = String(payload.message);
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
await ask("Discard this");
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Could not start quick request: Error: submit unavailable",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
const input = screen.getByRole("textbox", { name: "Request" });
|
||||
fireEvent.change(input, { target: { value: "Keep this" } });
|
||||
|
||||
now = 200_000;
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send request" }));
|
||||
await waitFor(() => expect(acceptedPrompt).toContain("Keep this"));
|
||||
now = 202_000;
|
||||
complete = true;
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByText("2s")).toHaveClass(
|
||||
"quick-pane-assistant-elapsed",
|
||||
),
|
||||
{ timeout: 2_000 },
|
||||
);
|
||||
expect(screen.queryByText("Discard this")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not use prior-turn answer after follow-up runtime failure", async () => {
|
||||
const submittedPrompts: string[] = [];
|
||||
bridgeMock([defaultModels], () => {
|
||||
@@ -856,7 +1280,7 @@ describe("QuickPane", () => {
|
||||
});
|
||||
|
||||
render(<QuickPane />);
|
||||
const alert = await screen.findByRole("alert");
|
||||
const alert = await screen.findByRole("alertdialog");
|
||||
expect(alert).toHaveTextContent(
|
||||
"Could not load settings: Error: state unavailable",
|
||||
);
|
||||
@@ -890,7 +1314,7 @@ describe("QuickPane", () => {
|
||||
},
|
||||
);
|
||||
render(<QuickPane />);
|
||||
const alert = await screen.findByRole("alert");
|
||||
const alert = await screen.findByRole("alertdialog");
|
||||
expect(alert).toHaveTextContent(
|
||||
"Could not prepare model catalog: Error: offline",
|
||||
);
|
||||
@@ -997,53 +1421,6 @@ describe("QuickPane fresh runtime catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
action: "Close",
|
||||
dismiss: async () => {
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "Escape",
|
||||
dismiss: async () => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
},
|
||||
},
|
||||
])(
|
||||
"retains deferred catalog preparation across $action dismissal",
|
||||
async ({ dismiss }) => {
|
||||
const tracker = deferredCatalogBridge();
|
||||
render(<QuickPane />);
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith(
|
||||
"get_session_runtime_snapshot",
|
||||
{ runtimeId: "runtime-1" },
|
||||
),
|
||||
);
|
||||
|
||||
await dismiss();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane"),
|
||||
);
|
||||
await act(async () => mocks.focusHandler?.({ payload: false }));
|
||||
await act(async () =>
|
||||
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /provider · Stale/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
||||
expect(tracker.runtimeCount()).toBe(1);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("retains deferred catalog preparation across idle focus loss", async () => {
|
||||
const tracker = deferredCatalogBridge();
|
||||
render(<QuickPane />);
|
||||
@@ -1292,7 +1669,7 @@ describe("QuickPane fresh runtime catalog", () => {
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
render(<QuickPane />);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
expect(await screen.findByRole("alertdialog")).toHaveTextContent(
|
||||
"Could not prepare model catalog: Error: offline",
|
||||
);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
|
||||
+405
-87
@@ -6,12 +6,12 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ExtensionDialog } from "../components/ExtensionDialog";
|
||||
import { ModelSelector } from "../components/ModelSelector";
|
||||
import { ThinkingSelector } from "../components/ThinkingSelector";
|
||||
import {
|
||||
modelIdentity,
|
||||
modelIdentityValue,
|
||||
reconcileModelIdentity,
|
||||
reconcileThinkingLevel,
|
||||
thinkingLevelOptions,
|
||||
type ModelIdentity,
|
||||
} from "../model-catalog";
|
||||
import "./quick-pane.css";
|
||||
@@ -43,15 +43,32 @@ const defaults: QuickPaneSettings = {
|
||||
|
||||
type Runtime = { agentId: string; runtimeId: string };
|
||||
type PreparedRuntime = { runtime: Runtime; models: Model[] };
|
||||
type TurnTiming = {
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
};
|
||||
type SubmittedPromptTurn = {
|
||||
id: number;
|
||||
guardrails: string;
|
||||
request: string;
|
||||
timing: TurnTiming;
|
||||
};
|
||||
type TranscriptMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
markdown: string;
|
||||
timing?: TurnTiming;
|
||||
};
|
||||
type ActivitySnapshot = {
|
||||
activeTool?: string;
|
||||
hasAssistantText: boolean;
|
||||
};
|
||||
type TextSelection = {
|
||||
value: string;
|
||||
start: number;
|
||||
end: number;
|
||||
direction: HTMLTextAreaElement["selectionDirection"];
|
||||
};
|
||||
|
||||
const CHAT_RENEWAL_MS = 180_000;
|
||||
|
||||
@@ -71,39 +88,56 @@ function visibleAssistantText(text: string) {
|
||||
: text;
|
||||
}
|
||||
|
||||
function visibleUserText(
|
||||
text: string,
|
||||
submittedPromptRequests: ReadonlyMap<string, string>,
|
||||
) {
|
||||
const submittedRequest = submittedPromptRequests.get(text);
|
||||
if (submittedRequest !== undefined) return submittedRequest;
|
||||
function visibleUserText(text: string, submittedTurn?: SubmittedPromptTurn) {
|
||||
if (submittedTurn) return submittedTurn.request;
|
||||
return text.startsWith(QUICK_PANE_ADVISORY_PREFIX) ? "" : text;
|
||||
}
|
||||
|
||||
function transcriptMessages(
|
||||
snapshot: RuntimeSnapshot,
|
||||
submittedPromptRequests: ReadonlyMap<string, string>,
|
||||
submittedPromptTurns: readonly SubmittedPromptTurn[],
|
||||
): TranscriptMessage[] {
|
||||
let nextSubmittedTurn = 0;
|
||||
let activeTurn: SubmittedPromptTurn | undefined;
|
||||
return (snapshot.transcript?.data?.messages ?? []).flatMap(
|
||||
(message, index) => {
|
||||
if (message.role !== "user" && message.role !== "assistant") return [];
|
||||
const text = messageText(message);
|
||||
let submittedTurn: SubmittedPromptTurn | undefined;
|
||||
if (message.role === "user") {
|
||||
const candidate = submittedPromptTurns[nextSubmittedTurn];
|
||||
if (candidate?.guardrails === text) {
|
||||
submittedTurn = candidate;
|
||||
activeTurn = candidate;
|
||||
nextSubmittedTurn += 1;
|
||||
} else activeTurn = undefined;
|
||||
}
|
||||
const markdown =
|
||||
message.role === "assistant"
|
||||
? visibleAssistantText(text)
|
||||
: visibleUserText(text, submittedPromptRequests);
|
||||
: visibleUserText(text, submittedTurn);
|
||||
if (!markdown) return [];
|
||||
return [
|
||||
{
|
||||
id: `${index}-${message.role}`,
|
||||
role: message.role,
|
||||
markdown,
|
||||
timing: message.role === "assistant" ? activeTurn?.timing : undefined,
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function elapsedLabel(startedAt: number, completedAt?: number) {
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.floor(((completedAt ?? Date.now()) - startedAt) / 1_000),
|
||||
);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
}
|
||||
|
||||
function snapshotIsActive(snapshot: RuntimeSnapshot) {
|
||||
return (
|
||||
snapshot.state?.data?.isStreaming === true ||
|
||||
@@ -131,19 +165,17 @@ function settingsError(settings: QuickPaneSettings) {
|
||||
|
||||
function currentTurnAssistantText(
|
||||
snapshot: RuntimeSnapshot,
|
||||
submittedPromptRequests: ReadonlyMap<string, string>,
|
||||
submittedPromptTurns: readonly SubmittedPromptTurn[],
|
||||
) {
|
||||
const messages = snapshot.transcript?.data?.messages ?? [];
|
||||
let nextSubmittedTurn = 0;
|
||||
let submittedUserIndex = -1;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message.role === "user" &&
|
||||
submittedPromptRequests.has(messageText(message))
|
||||
) {
|
||||
submittedUserIndex = index;
|
||||
break;
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (message.role !== "user") continue;
|
||||
const candidate = submittedPromptTurns[nextSubmittedTurn];
|
||||
if (candidate?.guardrails !== messageText(message)) continue;
|
||||
submittedUserIndex = index;
|
||||
nextSubmittedTurn += 1;
|
||||
}
|
||||
if (submittedUserIndex === -1) return "";
|
||||
|
||||
@@ -176,6 +208,8 @@ export default function QuickPane() {
|
||||
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [request, setRequest] = useState("");
|
||||
const requestRef = useRef(request);
|
||||
requestRef.current = request;
|
||||
const [submissionStarting, setSubmissionStarting] = useState(false);
|
||||
const [requestActive, setRequestActive] = useState(false);
|
||||
const [chatReplacing, setChatReplacing] = useState(false);
|
||||
@@ -184,6 +218,7 @@ export default function QuickPane() {
|
||||
const [catalogStatus, setCatalogStatus] = useState<
|
||||
"loading" | "ready" | "error"
|
||||
>("loading");
|
||||
const [initialCatalogResolved, setInitialCatalogResolved] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
|
||||
provider: "",
|
||||
modelId: "",
|
||||
@@ -202,6 +237,7 @@ export default function QuickPane() {
|
||||
hasAssistantText: false,
|
||||
});
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
||||
const preparedRuntimeRef = useRef<PreparedRuntime | undefined>(undefined);
|
||||
const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds);
|
||||
@@ -212,7 +248,11 @@ export default function QuickPane() {
|
||||
const submittedRequestRef = useRef<
|
||||
{ runtimeId: string; request: string } | undefined
|
||||
>(undefined);
|
||||
const submittedPromptRequestsRef = useRef(new Map<string, string>());
|
||||
const submittedPromptTurnsRef = useRef<SubmittedPromptTurn[]>([]);
|
||||
const nextSubmittedTurnIdRef = useRef(0);
|
||||
const activeSubmittedTurnRef = useRef<SubmittedPromptTurn | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
|
||||
const completionHandledRef = useRef(false);
|
||||
const submissionStartingRef = useRef(false);
|
||||
@@ -227,11 +267,27 @@ export default function QuickPane() {
|
||||
const catalogReadyRef = useRef(false);
|
||||
const selectedModelRef = useRef(selectedModel);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputSelectionRef = useRef<TextSelection | undefined>(undefined);
|
||||
const inputFocusIntentRef = useRef<
|
||||
"keyboard" | "pointer" | "programmatic" | undefined
|
||||
>(undefined);
|
||||
const selectionRestoreFrameRef = useRef<number | undefined>(undefined);
|
||||
const selectionRestorePendingRef = useRef(false);
|
||||
const paneRef = useRef<HTMLElement>(null);
|
||||
const exchangeRef = useRef<HTMLElement>(null);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const transcriptFollowRef = useRef(true);
|
||||
const transcriptScrollFrameRef = useRef<number | undefined>(undefined);
|
||||
const resizeFrameRef = useRef<number | undefined>(undefined);
|
||||
const resizeRetryTimeoutRef = useRef<number | undefined>(undefined);
|
||||
const resizeRetryCountRef = useRef(0);
|
||||
const requestedPaneHeightRef = useRef<number | undefined>(undefined);
|
||||
const resizedPaneHeightRef = useRef<number | undefined>(undefined);
|
||||
const resizeInFlightRef = useRef(false);
|
||||
const modelMenuOpenRef = useRef(false);
|
||||
const requestPaneResizeRef = useRef<((height: number) => void) | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const invalid = useMemo(() => settingsError(settings), [settings]);
|
||||
const selectedCatalogModel = useMemo(
|
||||
() =>
|
||||
@@ -242,10 +298,6 @@ export default function QuickPane() {
|
||||
),
|
||||
[models, selectedModel],
|
||||
);
|
||||
const thinkingOptions = useMemo(
|
||||
() => thinkingLevelOptions(selectedCatalogModel),
|
||||
[selectedCatalogModel],
|
||||
);
|
||||
const runActive = submissionStarting || requestActive;
|
||||
const activityPhase = submissionStarting
|
||||
? "Starting"
|
||||
@@ -260,6 +312,8 @@ export default function QuickPane() {
|
||||
activityPhase === "Thinking"
|
||||
? `${activityPhase} · ${elapsedSeconds}s`
|
||||
: activityPhase;
|
||||
const catalogOverlayActive = !initialCatalogResolved;
|
||||
const catalogOverlayError = catalogError ?? error;
|
||||
const hasExchangeContent =
|
||||
messages.length > 0 ||
|
||||
runActive ||
|
||||
@@ -278,7 +332,121 @@ export default function QuickPane() {
|
||||
);
|
||||
}, [selectedCatalogModel]);
|
||||
|
||||
const focusInput = () => inputRef.current?.focus();
|
||||
const recordInputSelection = (input: HTMLTextAreaElement) => {
|
||||
if (
|
||||
selectionRestorePendingRef.current &&
|
||||
inputFocusIntentRef.current !== "pointer"
|
||||
)
|
||||
return;
|
||||
inputSelectionRef.current = {
|
||||
value: input.value,
|
||||
start: input.selectionStart,
|
||||
end: input.selectionEnd,
|
||||
direction: input.selectionDirection,
|
||||
};
|
||||
};
|
||||
const recordInputChangeSelection = (input: HTMLTextAreaElement) => {
|
||||
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
|
||||
selectionRestoreFrameRef.current = undefined;
|
||||
selectionRestorePendingRef.current = false;
|
||||
recordInputSelection(input);
|
||||
};
|
||||
const restoreInputSelection = useCallback(() => {
|
||||
const input = inputRef.current;
|
||||
const selection = inputSelectionRef.current;
|
||||
if (
|
||||
!input ||
|
||||
!selection ||
|
||||
selection.value !== input.value ||
|
||||
selection.value !== requestRef.current
|
||||
)
|
||||
return;
|
||||
const length = input.value.length;
|
||||
const start = Math.min(Math.max(selection.start, 0), length);
|
||||
const end = Math.min(Math.max(selection.end, 0), length);
|
||||
input.setSelectionRange(
|
||||
Math.min(start, end),
|
||||
Math.max(start, end),
|
||||
selection.direction,
|
||||
);
|
||||
}, []);
|
||||
const scheduleInputSelectionRestore = useCallback(() => {
|
||||
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
|
||||
selectionRestorePendingRef.current = true;
|
||||
selectionRestoreFrameRef.current = window.requestAnimationFrame(() => {
|
||||
selectionRestoreFrameRef.current = undefined;
|
||||
selectionRestorePendingRef.current = false;
|
||||
restoreInputSelection();
|
||||
});
|
||||
}, [restoreInputSelection]);
|
||||
const focusInput = useCallback(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input || document.activeElement === input) return;
|
||||
inputFocusIntentRef.current = "programmatic";
|
||||
input.focus();
|
||||
}, []);
|
||||
const handleInputFocus = () => {
|
||||
const intent = inputFocusIntentRef.current ?? "programmatic";
|
||||
inputFocusIntentRef.current = undefined;
|
||||
if (intent !== "pointer") scheduleInputSelectionRestore();
|
||||
};
|
||||
const requestPaneResize = useCallback((height: number) => {
|
||||
if (requestedPaneHeightRef.current !== height)
|
||||
resizeRetryCountRef.current = 0;
|
||||
requestedPaneHeightRef.current = height;
|
||||
if (resizeRetryTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(resizeRetryTimeoutRef.current);
|
||||
resizeRetryTimeoutRef.current = undefined;
|
||||
}
|
||||
if (resizeInFlightRef.current || resizedPaneHeightRef.current === height)
|
||||
return;
|
||||
|
||||
resizeInFlightRef.current = true;
|
||||
void invoke("resize_quick_pane", { height }).then(
|
||||
() => {
|
||||
resizeInFlightRef.current = false;
|
||||
resizedPaneHeightRef.current = height;
|
||||
const desiredHeight = requestedPaneHeightRef.current;
|
||||
if (desiredHeight !== undefined && desiredHeight !== height)
|
||||
requestPaneResizeRef.current?.(desiredHeight);
|
||||
},
|
||||
() => {
|
||||
resizeInFlightRef.current = false;
|
||||
const desiredHeight = requestedPaneHeightRef.current;
|
||||
if (desiredHeight === undefined) return;
|
||||
if (desiredHeight !== height) {
|
||||
requestPaneResizeRef.current?.(desiredHeight);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
(modelMenuOpenRef.current && height !== 520) ||
|
||||
resizeRetryCountRef.current >= 1
|
||||
)
|
||||
return;
|
||||
resizeRetryCountRef.current += 1;
|
||||
resizeRetryTimeoutRef.current = window.setTimeout(() => {
|
||||
resizeRetryTimeoutRef.current = undefined;
|
||||
if (
|
||||
mountedRef.current &&
|
||||
requestedPaneHeightRef.current === height &&
|
||||
(!modelMenuOpenRef.current || height === 520)
|
||||
)
|
||||
requestPaneResizeRef.current?.(height);
|
||||
}, 250);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
requestPaneResizeRef.current = requestPaneResize;
|
||||
const handleModelMenuOpen = useCallback(() => {
|
||||
modelMenuOpenRef.current = true;
|
||||
setModelMenuOpen(true);
|
||||
requestPaneResize(520);
|
||||
}, [requestPaneResize]);
|
||||
const handleModelMenuClose = useCallback(() => {
|
||||
modelMenuOpenRef.current = false;
|
||||
setModelMenuOpen(false);
|
||||
}, []);
|
||||
const closeRuntime = useCallback(async (runtimeId: string) => {
|
||||
const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
|
||||
if (pending) return pending;
|
||||
@@ -365,6 +533,7 @@ export default function QuickPane() {
|
||||
const emptyModel = { provider: "", modelId: "" };
|
||||
selectedModelRef.current = emptyModel;
|
||||
setSelectedModel(emptyModel);
|
||||
setInitialCatalogResolved(true);
|
||||
setCatalogStatus("ready");
|
||||
setCatalogNotice("No models are currently available.");
|
||||
return false;
|
||||
@@ -372,6 +541,7 @@ export default function QuickPane() {
|
||||
selectedModelRef.current = reconciled;
|
||||
setSelectedModel(reconciled);
|
||||
catalogReadyRef.current = true;
|
||||
setInitialCatalogResolved(true);
|
||||
setCatalogStatus("ready");
|
||||
setCatalogNotice(
|
||||
modelIdentityValue(reconciled) === modelIdentityValue(preferred)
|
||||
@@ -453,7 +623,9 @@ export default function QuickPane() {
|
||||
lastUserQuestionAtRef.current = undefined;
|
||||
lastCompletedAnswerAtRef.current = undefined;
|
||||
submittedRequestRef.current = undefined;
|
||||
submittedPromptRequestsRef.current.clear();
|
||||
submittedPromptTurnsRef.current = [];
|
||||
nextSubmittedTurnIdRef.current = 0;
|
||||
activeSubmittedTurnRef.current = undefined;
|
||||
promptSubmittedRuntimeRef.current = undefined;
|
||||
completionHandledRef.current = false;
|
||||
runStartedAtRef.current = undefined;
|
||||
@@ -474,7 +646,10 @@ export default function QuickPane() {
|
||||
setCatalogError(undefined);
|
||||
setCatalogNotice(undefined);
|
||||
setModels([]);
|
||||
if (clearDraft) setRequest("");
|
||||
if (clearDraft) {
|
||||
inputSelectionRef.current = undefined;
|
||||
setRequest("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const replaceChat = useCallback(
|
||||
@@ -537,6 +712,10 @@ export default function QuickPane() {
|
||||
void loadSettingsAndPrepare();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (resizeRetryTimeoutRef.current !== undefined)
|
||||
window.clearTimeout(resizeRetryTimeoutRef.current);
|
||||
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
|
||||
selectionRestorePendingRef.current = false;
|
||||
preparationGenerationRef.current += 1;
|
||||
const current = runtimeRef.current;
|
||||
if (current) void closeRuntime(current.runtimeId).catch(() => undefined);
|
||||
@@ -553,13 +732,16 @@ export default function QuickPane() {
|
||||
if (!pane || !exchange) return;
|
||||
const resizeToContent = () => {
|
||||
window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
|
||||
if (modelMenuOpenRef.current) return;
|
||||
resizeFrameRef.current = window.requestAnimationFrame(() => {
|
||||
if (modelMenuOpenRef.current) return;
|
||||
const visibleChildren = [...exchange.children].filter(
|
||||
(child) => !child.classList.contains("sr-only"),
|
||||
);
|
||||
const childHeight = visibleChildren.reduce(
|
||||
(total, child) =>
|
||||
total + Math.max(child.scrollHeight, child.getBoundingClientRect().height),
|
||||
total +
|
||||
Math.max(child.scrollHeight, child.getBoundingClientRect().height),
|
||||
0,
|
||||
);
|
||||
const styles = window.getComputedStyle(exchange);
|
||||
@@ -573,12 +755,12 @@ export default function QuickPane() {
|
||||
520,
|
||||
Math.max(
|
||||
240,
|
||||
Math.ceil(pane.clientHeight - exchange.clientHeight + contentHeight),
|
||||
Math.ceil(
|
||||
pane.clientHeight - exchange.clientHeight + contentHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (height === requestedPaneHeightRef.current) return;
|
||||
requestedPaneHeightRef.current = height;
|
||||
void invoke("resize_quick_pane", { height }).catch(() => undefined);
|
||||
requestPaneResize(height);
|
||||
});
|
||||
};
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
@@ -594,7 +776,18 @@ export default function QuickPane() {
|
||||
observer.disconnect();
|
||||
window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
|
||||
};
|
||||
}, [activity, catalogError, catalogNotice, error, escalation, messages, notice, responseComplete]);
|
||||
}, [
|
||||
activity,
|
||||
catalogError,
|
||||
catalogNotice,
|
||||
error,
|
||||
escalation,
|
||||
messages,
|
||||
modelMenuOpen,
|
||||
notice,
|
||||
requestPaneResize,
|
||||
responseComplete,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runActive) return;
|
||||
@@ -631,6 +824,11 @@ export default function QuickPane() {
|
||||
},
|
||||
);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Tab") inputFocusIntentRef.current = "keyboard";
|
||||
if (catalogOverlayActive) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "n") {
|
||||
event.preventDefault();
|
||||
void replaceChat(true, true);
|
||||
@@ -658,7 +856,13 @@ export default function QuickPane() {
|
||||
void settingsListener.then((unlisten) => unlisten());
|
||||
void focusListener.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [dismissPane, prepareRuntime, renewChatOnFocus, replaceChat]);
|
||||
}, [
|
||||
catalogOverlayActive,
|
||||
dismissPane,
|
||||
prepareRuntime,
|
||||
renewChatOnFocus,
|
||||
replaceChat,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime || !requestActive) return;
|
||||
@@ -673,14 +877,18 @@ export default function QuickPane() {
|
||||
if (!active || chatGeneration !== chatGenerationRef.current) return;
|
||||
const text = currentTurnAssistantText(
|
||||
snapshot,
|
||||
submittedPromptRequestsRef.current,
|
||||
submittedPromptTurnsRef.current,
|
||||
);
|
||||
const result = visibleAssistantText(text);
|
||||
const complete =
|
||||
!snapshotIsActive(snapshot) &&
|
||||
promptSubmittedRuntimeRef.current === runtime.runtimeId;
|
||||
if (complete) {
|
||||
const timing = activeSubmittedTurnRef.current?.timing;
|
||||
if (timing && !timing.completedAt) timing.completedAt = Date.now();
|
||||
}
|
||||
setMessages(
|
||||
transcriptMessages(snapshot, submittedPromptRequestsRef.current),
|
||||
transcriptMessages(snapshot, submittedPromptTurnsRef.current),
|
||||
);
|
||||
setActivitySnapshot({
|
||||
activeTool: snapshot.runtime.activeTool,
|
||||
@@ -702,9 +910,11 @@ export default function QuickPane() {
|
||||
setNotice("");
|
||||
const submitted = submittedRequestRef.current;
|
||||
if (submitted?.runtimeId === runtime.runtimeId)
|
||||
setRequest((current) =>
|
||||
current === submitted.request ? "" : current,
|
||||
);
|
||||
setRequest((current) => {
|
||||
if (current !== submitted.request) return current;
|
||||
inputSelectionRef.current = undefined;
|
||||
return "";
|
||||
});
|
||||
if (text.startsWith(ESCALATION_MARKER))
|
||||
setEscalation(result || "Quick agent requested a full session.");
|
||||
window.requestAnimationFrame(focusInput);
|
||||
@@ -730,7 +940,6 @@ export default function QuickPane() {
|
||||
};
|
||||
}, [requestActive, runtime]);
|
||||
|
||||
|
||||
async function run() {
|
||||
if (submissionStartingRef.current || requestActiveRef.current) return;
|
||||
if (invalid || !request.trim())
|
||||
@@ -764,9 +973,13 @@ export default function QuickPane() {
|
||||
|
||||
const submittedRuntime = prepared.runtime;
|
||||
if (chatGeneration !== chatGenerationRef.current) return;
|
||||
submittedRequestRef.current = {
|
||||
runtimeId: submittedRuntime.runtimeId,
|
||||
const submittedAt = Date.now();
|
||||
const guardrails = `[Quick Pane advisory mode: use at most ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more tool calls, start final response with [ESCALATE] and explain why.]\n${settings.instructions}${USER_REQUEST_DELIMITER}${submittedRequest}`;
|
||||
const submittedTurn = {
|
||||
id: ++nextSubmittedTurnIdRef.current,
|
||||
guardrails,
|
||||
request: submittedRequest,
|
||||
timing: { startedAt: submittedAt },
|
||||
};
|
||||
promptSubmittedRuntimeRef.current = undefined;
|
||||
completionHandledRef.current = false;
|
||||
@@ -777,7 +990,6 @@ export default function QuickPane() {
|
||||
setExtension(undefined);
|
||||
setActivitySnapshot({ hasAssistantText: false });
|
||||
setNotice("");
|
||||
const submittedAt = Date.now();
|
||||
lastUserQuestionAtRef.current = submittedAt;
|
||||
runStartedAtRef.current = submittedAt;
|
||||
setElapsedSeconds(0);
|
||||
@@ -795,14 +1007,18 @@ export default function QuickPane() {
|
||||
level: thinkingLevel,
|
||||
});
|
||||
if (chatGeneration !== chatGenerationRef.current) return;
|
||||
const guardrails = `[Quick Pane advisory mode: use at most ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more tool calls, start final response with [ESCALATE] and explain why.]\n${settings.instructions}${USER_REQUEST_DELIMITER}${submittedRequest}`;
|
||||
submittedPromptRequestsRef.current.set(guardrails, submittedRequest);
|
||||
await invoke("submit_prompt", {
|
||||
agentId: submittedRuntime.agentId,
|
||||
message: guardrails,
|
||||
});
|
||||
if (chatGeneration === chatGenerationRef.current)
|
||||
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
|
||||
if (chatGeneration !== chatGenerationRef.current) return;
|
||||
submittedRequestRef.current = {
|
||||
runtimeId: submittedRuntime.runtimeId,
|
||||
request: submittedRequest,
|
||||
};
|
||||
submittedPromptTurnsRef.current.push(submittedTurn);
|
||||
activeSubmittedTurnRef.current = submittedTurn;
|
||||
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
|
||||
} catch (reason) {
|
||||
if (chatGeneration === chatGenerationRef.current) {
|
||||
requestActiveRef.current = false;
|
||||
@@ -838,17 +1054,25 @@ export default function QuickPane() {
|
||||
request.trim() || submittedRequestRef.current?.request || "";
|
||||
const canContinue = !invalid && !!handoffRequest;
|
||||
const completedAssistantAnswer =
|
||||
responseComplete && messages.some((message) => message.role === "assistant");
|
||||
responseComplete &&
|
||||
messages.some((message) => message.role === "assistant");
|
||||
const completedAssistantMessageId = completedAssistantAnswer
|
||||
? [...messages].reverse().find((message) => message.role === "assistant")?.id
|
||||
? [...messages].reverse().find((message) => message.role === "assistant")
|
||||
?.id
|
||||
: undefined;
|
||||
const streamingAssistantMessageId = runActive
|
||||
? [...messages].reverse().find((message) => message.role === "assistant")
|
||||
?.id
|
||||
: undefined;
|
||||
const hasExchange =
|
||||
messages.length > 0 ||
|
||||
responseComplete ||
|
||||
submittedRequestRef.current !== undefined;
|
||||
const hasTranscript = messages.length > 0 || runActive || responseComplete;
|
||||
const transcriptHandoff = messages
|
||||
.map((message) =>
|
||||
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
|
||||
.map(
|
||||
(message) =>
|
||||
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
@@ -877,22 +1101,59 @@ export default function QuickPane() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTranscript) return;
|
||||
const transcript = transcriptRef.current;
|
||||
if (!transcript) return;
|
||||
transcriptFollowRef.current = true;
|
||||
const trackTranscriptScroll = () => {
|
||||
transcriptFollowRef.current =
|
||||
transcript.scrollHeight -
|
||||
transcript.clientHeight -
|
||||
transcript.scrollTop <=
|
||||
32;
|
||||
};
|
||||
transcript.addEventListener("scroll", trackTranscriptScroll);
|
||||
return () =>
|
||||
transcript.removeEventListener("scroll", trackTranscriptScroll);
|
||||
}, [hasTranscript]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTranscript || !transcriptFollowRef.current) return;
|
||||
window.cancelAnimationFrame(transcriptScrollFrameRef.current ?? 0);
|
||||
transcriptScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
if (!transcriptFollowRef.current) return;
|
||||
const transcript = transcriptRef.current;
|
||||
if (transcript) transcript.scrollTop = transcript.scrollHeight;
|
||||
});
|
||||
return () =>
|
||||
window.cancelAnimationFrame(transcriptScrollFrameRef.current ?? 0);
|
||||
}, [
|
||||
activity,
|
||||
catalogError,
|
||||
catalogNotice,
|
||||
error,
|
||||
escalation,
|
||||
extension,
|
||||
hasTranscript,
|
||||
messages,
|
||||
notice,
|
||||
responseComplete,
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="quick-pane" ref={paneRef}>
|
||||
<div
|
||||
className="quick-pane-drag-strip"
|
||||
aria-hidden="true"
|
||||
onPointerDown={() =>
|
||||
void getCurrentWindow().startDragging().catch(() => undefined)
|
||||
void getCurrentWindow()
|
||||
.startDragging()
|
||||
.catch(() => undefined)
|
||||
}
|
||||
/>
|
||||
<header className="quick-pane-toolbar">
|
||||
<div className="quick-pane-primary-controls">
|
||||
{catalogStatus === "loading" && (
|
||||
<span className="quick-pane-model-loading" role="status">
|
||||
Loading models…
|
||||
</span>
|
||||
)}
|
||||
<ModelSelector
|
||||
className="quick-pane-model-selector"
|
||||
placement="down"
|
||||
@@ -901,7 +1162,11 @@ export default function QuickPane() {
|
||||
provider: selectedModel.provider,
|
||||
id: selectedModel.modelId,
|
||||
}}
|
||||
disabled={runActive || catalogStatus !== "ready"}
|
||||
disabled={
|
||||
catalogOverlayActive || runActive || catalogStatus !== "ready"
|
||||
}
|
||||
onOpen={handleModelMenuOpen}
|
||||
onClose={handleModelMenuClose}
|
||||
onSelect={async (model) => {
|
||||
const identity = modelIdentity(model);
|
||||
if (!identity) return false;
|
||||
@@ -913,24 +1178,13 @@ export default function QuickPane() {
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
<label className="quick-pane-field quick-pane-thinking">
|
||||
<span className="full-label">Thinking</span>
|
||||
<span className="compact-label" aria-hidden="true">
|
||||
T
|
||||
</span>
|
||||
<select
|
||||
aria-label="Thinking"
|
||||
value={thinkingLevel}
|
||||
disabled={runActive || chatReplacing}
|
||||
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
||||
>
|
||||
{thinkingOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ThinkingSelector
|
||||
className="quick-pane-thinking"
|
||||
model={selectedCatalogModel}
|
||||
value={thinkingLevel}
|
||||
disabled={catalogOverlayActive || runActive || chatReplacing}
|
||||
onChange={setThinkingLevel}
|
||||
/>
|
||||
</div>
|
||||
<div className="quick-pane-header-actions">
|
||||
<button
|
||||
@@ -938,6 +1192,7 @@ export default function QuickPane() {
|
||||
type="button"
|
||||
aria-label="Close Quick Pane"
|
||||
title="Close Quick Pane (Esc)"
|
||||
disabled={catalogOverlayActive}
|
||||
onClick={dismissPane}
|
||||
>
|
||||
<span className="full-label">Close</span>
|
||||
@@ -955,9 +1210,23 @@ export default function QuickPane() {
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
readOnly={runActive}
|
||||
disabled={catalogOverlayActive}
|
||||
aria-busy={runActive}
|
||||
value={request}
|
||||
onChange={(event) => setRequest(event.currentTarget.value)}
|
||||
onPointerDown={() => {
|
||||
inputFocusIntentRef.current = "pointer";
|
||||
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
|
||||
selectionRestoreFrameRef.current = undefined;
|
||||
selectionRestorePendingRef.current = false;
|
||||
}}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={(event) => recordInputSelection(event.currentTarget)}
|
||||
onSelect={(event) => recordInputSelection(event.currentTarget)}
|
||||
onInput={(event) => recordInputChangeSelection(event.currentTarget)}
|
||||
onChange={(event) => {
|
||||
recordInputChangeSelection(event.currentTarget);
|
||||
setRequest(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
@@ -970,7 +1239,10 @@ export default function QuickPane() {
|
||||
}}
|
||||
placeholder="Ask a quick question or give a quick instruction"
|
||||
/>
|
||||
<div className="quick-pane-composer-actions" aria-label="Quick chat actions">
|
||||
<div
|
||||
className="quick-pane-composer-actions"
|
||||
aria-label="Quick chat actions"
|
||||
>
|
||||
{requestActive && (
|
||||
<button
|
||||
className="quick-pane-action"
|
||||
@@ -1019,7 +1291,7 @@ export default function QuickPane() {
|
||||
aria-label="Quick exchange"
|
||||
ref={exchangeRef}
|
||||
>
|
||||
{(messages.length > 0 || runActive || responseComplete) && (
|
||||
{hasTranscript && (
|
||||
<div
|
||||
className="quick-pane-transcript"
|
||||
ref={transcriptRef}
|
||||
@@ -1028,8 +1300,15 @@ export default function QuickPane() {
|
||||
aria-live="polite"
|
||||
aria-busy={runActive}
|
||||
>
|
||||
{messages.map((message) =>
|
||||
message.role === "user" ? (
|
||||
{messages.map((message) => {
|
||||
const elapsed =
|
||||
message.timing && (message.timing.completedAt || runActive)
|
||||
? elapsedLabel(
|
||||
message.timing.startedAt,
|
||||
message.timing.completedAt,
|
||||
)
|
||||
: undefined;
|
||||
return message.role === "user" ? (
|
||||
<div className="quick-pane-user-row" key={message.id}>
|
||||
<article className="quick-pane-user-message">
|
||||
<span className="message-author">You</span>
|
||||
@@ -1047,6 +1326,16 @@ export default function QuickPane() {
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.markdown}
|
||||
</ReactMarkdown>
|
||||
{elapsed && (
|
||||
<span className="quick-pane-assistant-elapsed">
|
||||
{elapsed}
|
||||
</span>
|
||||
)}
|
||||
{message.id === streamingAssistantMessageId && activity && (
|
||||
<span className="quick-pane-assistant-activity">
|
||||
{activity}
|
||||
</span>
|
||||
)}
|
||||
{message.id === completedAssistantMessageId &&
|
||||
!escalation &&
|
||||
canContinue && (
|
||||
@@ -1057,9 +1346,9 @@ export default function QuickPane() {
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
),
|
||||
)}
|
||||
{activity && (
|
||||
);
|
||||
})}
|
||||
{activity && !streamingAssistantMessageId && (
|
||||
<div className="quick-pane-activity" aria-hidden="true">
|
||||
<span className="typing-dots">
|
||||
<i />
|
||||
@@ -1094,7 +1383,7 @@ export default function QuickPane() {
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{error && (
|
||||
{error && initialCatalogResolved && (
|
||||
<aside className="quick-pane-notice error" role="alert">
|
||||
<p>{error}</p>
|
||||
{settingsLoaded ? (
|
||||
@@ -1115,7 +1404,7 @@ export default function QuickPane() {
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
{catalogError && (
|
||||
{catalogError && initialCatalogResolved && (
|
||||
<aside className="quick-pane-notice error" role="alert">
|
||||
<p>{catalogError}</p>
|
||||
<button
|
||||
@@ -1126,7 +1415,7 @@ export default function QuickPane() {
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
{notice && (
|
||||
{notice && initialCatalogResolved && (
|
||||
<p className="quick-pane-system-notice" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
@@ -1138,6 +1427,35 @@ export default function QuickPane() {
|
||||
)}
|
||||
<ExtensionDialog extension={extension} onRespond={respond} />
|
||||
</section>
|
||||
{catalogOverlayActive && (
|
||||
<aside
|
||||
className="quick-pane-catalog-overlay"
|
||||
role={catalogOverlayError ? "alertdialog" : "dialog"}
|
||||
aria-modal="true"
|
||||
aria-label="Model catalog"
|
||||
>
|
||||
{catalogOverlayError ? (
|
||||
<>
|
||||
<p>{catalogOverlayError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (catalogError)
|
||||
void prepareRuntime(selectedModelRef.current);
|
||||
else void loadSettingsAndPrepare();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="quick-pane-catalog-spinner" aria-hidden="true" />
|
||||
<span role="status">Loading models…</span>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ body {
|
||||
}
|
||||
|
||||
.quick-pane {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -53,7 +54,12 @@ body {
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
border-bottom: 1px solid #70522f;
|
||||
background: linear-gradient(90deg, transparent, rgba(240, 163, 71, 0.14), transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(240, 163, 71, 0.14),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.quick-pane-drag-strip::after {
|
||||
@@ -107,13 +113,43 @@ body {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quick-pane-model-loading {
|
||||
flex: 0 0 auto;
|
||||
.quick-pane-catalog-overlay {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
color: #f1e4d1;
|
||||
font-size: 11px;
|
||||
background: rgba(34, 34, 34, 0.98);
|
||||
border-radius: inherit;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.quick-pane-catalog-overlay p {
|
||||
max-width: 440px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-pane-catalog-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid rgba(240, 163, 71, 0.28);
|
||||
border-top-color: #f0a347;
|
||||
border-radius: 50%;
|
||||
animation: quick-pane-catalog-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes quick-pane-catalog-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-pane .model-trigger {
|
||||
height: 27px;
|
||||
border: 0;
|
||||
@@ -299,6 +335,22 @@ body {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-elapsed,
|
||||
.quick-pane-assistant-activity {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #96918b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-elapsed {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-activity {
|
||||
color: #c5beb5;
|
||||
}
|
||||
|
||||
.quick-pane-assistant-footer {
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #484848;
|
||||
|
||||
Reference in New Issue
Block a user