fix(quick-pane): refresh runtime model catalog
This commit is contained in:
+17
-104
@@ -717,16 +717,14 @@ select:focus-visible {
|
||||
border-radius: 5px;
|
||||
background: #303030;
|
||||
}
|
||||
.pi-controls label,
|
||||
.model-control {
|
||||
.pi-controls label {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
.pi-controls select,
|
||||
.model-trigger {
|
||||
.pi-controls select {
|
||||
min-width: 0;
|
||||
appearance: none;
|
||||
color-scheme: dark;
|
||||
@@ -738,13 +736,11 @@ select:focus-visible {
|
||||
no-repeat right 7px center;
|
||||
color: #f2ece4;
|
||||
}
|
||||
.pi-controls select:hover,
|
||||
.model-trigger:hover {
|
||||
.pi-controls select:hover {
|
||||
border-color: #d88735;
|
||||
background-color: #383129;
|
||||
}
|
||||
.pi-controls select:focus,
|
||||
.model-trigger:focus {
|
||||
.pi-controls select:focus {
|
||||
border-color: #f0a347;
|
||||
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
|
||||
}
|
||||
@@ -756,101 +752,6 @@ select:focus-visible {
|
||||
background: #4b3420;
|
||||
color: #fff2df;
|
||||
}
|
||||
.model-selector {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.model-trigger {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
font-weight: 650;
|
||||
}
|
||||
.model-trigger > span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
width: min(400px, calc(100vw - 40px));
|
||||
max-height: min(420px, calc(100vh - 80px));
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid #70522f;
|
||||
border-radius: 7px;
|
||||
background: #292724;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.model-menu input {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #5b5146;
|
||||
border-radius: 4px;
|
||||
background: #1f1e1c;
|
||||
color: #f2ece4;
|
||||
}
|
||||
.model-result-count {
|
||||
margin: 0;
|
||||
color: #aaa39a;
|
||||
font-size: 10px;
|
||||
}
|
||||
.model-results {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.model-provider-group {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.model-provider-heading {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 4px 5px 3px;
|
||||
background: #292724;
|
||||
color: #f0a347;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.model-option {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
padding: 6px 7px;
|
||||
border: 1px solid transparent;
|
||||
background: #35322e;
|
||||
color: #f2ece4;
|
||||
text-align: left;
|
||||
}
|
||||
.model-option:hover {
|
||||
border-color: #8c6338;
|
||||
background: #44382d;
|
||||
}
|
||||
.model-option[aria-selected="true"] {
|
||||
border-color: #f0a347;
|
||||
background: #4b3420;
|
||||
color: #fff2df;
|
||||
}
|
||||
.model-option small {
|
||||
overflow-wrap: anywhere;
|
||||
color: #aaa39a;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.model-empty {
|
||||
margin: 5px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.session-status-strip {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1055,13 +956,25 @@ select:focus-visible {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
.quick-pane-settings label {
|
||||
.quick-pane-settings label,
|
||||
.quick-pane-model-setting {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: #ddd8d0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.quick-pane-model-setting .model-control {
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
font-size: 12px;
|
||||
}
|
||||
.quick-pane-model-setting .model-menu {
|
||||
width: min(400px, calc(100vw - 80px));
|
||||
}
|
||||
.quick-pane-model-setting small {
|
||||
color: #b9afa4;
|
||||
font-weight: 400;
|
||||
}
|
||||
.quick-pane-settings input,
|
||||
.quick-pane-settings textarea {
|
||||
width: 100%;
|
||||
|
||||
+681
-142
@@ -1,7 +1,18 @@
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeSummary, UiState, Workspace } from "./workspace/types";
|
||||
import type {
|
||||
Model,
|
||||
RuntimeSummary,
|
||||
UiState,
|
||||
Workspace,
|
||||
} from "./workspace/types";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
@@ -96,7 +107,9 @@ describe("model catalog identity", () => {
|
||||
provider: "openrouter",
|
||||
modelId: "anthropic/claude/sonnet",
|
||||
};
|
||||
expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(identity);
|
||||
expect(modelIdentityFromValue(modelIdentityValue(identity))).toEqual(
|
||||
identity,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +135,7 @@ describe("App command progress and errors", () => {
|
||||
expect(status).toHaveTextContent("Ready for your next prompt");
|
||||
});
|
||||
|
||||
it("loads shared catalog selectors and derives providers from model choices", async () => {
|
||||
it("loads categorized snapshot selectors and derives providers from choices", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded: UiState = {
|
||||
...uiState,
|
||||
@@ -138,36 +151,45 @@ describe("App command progress and errors", () => {
|
||||
},
|
||||
};
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "get_model_catalog")
|
||||
return Promise.resolve({
|
||||
models: [
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
{ provider: "openrouter", id: "anthropic/claude/sonnet" },
|
||||
],
|
||||
});
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({ runtime: { runtimeId: "settings-runtime" } });
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [
|
||||
{ provider: "openai", id: "gpt-5", name: "GPT 5" },
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "anthropic/claude/sonnet",
|
||||
name: "Claude",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("get_model_catalog"),
|
||||
);
|
||||
const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
|
||||
expect(quickModel).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /Follow-up model/ }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Quick provider/)).toBeNull();
|
||||
expect(screen.queryByLabelText(/Follow-up provider/)).toBeNull();
|
||||
|
||||
await user.selectOptions(
|
||||
quickModel,
|
||||
modelIdentityValue({
|
||||
provider: "openrouter",
|
||||
modelId: "anthropic/claude/sonnet",
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-runtime",
|
||||
}),
|
||||
);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog");
|
||||
|
||||
const quickModel = screen.getByRole("button", {
|
||||
name: /Quick model openai · GPT 5/i,
|
||||
});
|
||||
await user.click(quickModel);
|
||||
expect(screen.getByRole("group", { name: "openrouter" })).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("option", { name: /Claude.*anthropic\/claude\/sonnet/i }),
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
);
|
||||
@@ -185,10 +207,75 @@ describe("App command progress and errors", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes available models whenever Settings opens", async () => {
|
||||
it("uses and always closes a fresh temporary runtime whenever Settings opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
let catalogCalls = 0;
|
||||
const loaded: UiState = {
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state")
|
||||
return Promise.resolve({
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
});
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
const id = payload?.runtimeId === "settings-1" ? "old-model" : "new-model";
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · old-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-1",
|
||||
}),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-2",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores older overlapping Settings snapshot while closing both runtimes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
@@ -201,143 +288,593 @@ describe("App command progress and errors", () => {
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const first = deferred<{
|
||||
runtime: Record<string, never>;
|
||||
extensions: never[];
|
||||
models: { data: { models: { provider: string; id: string }[] } };
|
||||
}>();
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "settings-1") return first.promise;
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id: "new-model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await act(async () =>
|
||||
first.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id: "stale-model" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-1",
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("retains a stale-generation probe when its close fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const staleSnapshot = deferred<{
|
||||
runtime: Record<string, never>;
|
||||
extensions: never[];
|
||||
models: { data: { models: { provider: string; id: string }[] } };
|
||||
}>();
|
||||
let runtimeCount = 0;
|
||||
let staleCloseCount = 0;
|
||||
const lifecycle: string[] = [];
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
lifecycle.push(`create:settings-${runtimeCount}`);
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `settings-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "settings-1") return staleSnapshot.promise;
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: { models: [{ provider: "openai", id: "new-model" }] },
|
||||
},
|
||||
});
|
||||
}
|
||||
if (command === "close_quick_runtime") {
|
||||
if (payload?.runtimeId === "settings-1") {
|
||||
staleCloseCount += 1;
|
||||
lifecycle.push(`close:settings-1:${staleCloseCount}`);
|
||||
if (staleCloseCount === 1)
|
||||
return Promise.reject(new Error("stale close failed"));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await act(async () =>
|
||||
staleSnapshot.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id: "stale-model" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Could not load model catalog: Error: stale close failed",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(3));
|
||||
expect(lifecycle.indexOf("close:settings-1:2")).toBeLessThan(
|
||||
lifecycle.indexOf("create:settings-3"),
|
||||
);
|
||||
});
|
||||
|
||||
it("best-effort closes an in-flight Settings probe on unmount", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const snapshot = deferred<unknown>();
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "get_model_catalog") {
|
||||
catalogCalls += 1;
|
||||
return Promise.resolve({
|
||||
models: [
|
||||
{
|
||||
provider: "openai",
|
||||
id: catalogCalls === 1 ? "old-model" : "new-model",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({ runtime: { runtimeId: "in-flight-probe" } });
|
||||
if (command === "get_session_runtime_snapshot") return snapshot.promise;
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
const app = render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
const quickModel = await screen.findByRole("combobox", {
|
||||
name: /Quick model/,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/old-model" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
const refreshedQuickModel = screen.getByRole("combobox", {
|
||||
name: /Quick model/,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(refreshedQuickModel).getByRole("option", {
|
||||
name: "openai/new-model",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
within(refreshedQuickModel).queryByRole("option", {
|
||||
name: "openai/old-model",
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("get_session_runtime_snapshot", {
|
||||
runtimeId: "in-flight-probe",
|
||||
}),
|
||||
);
|
||||
app.unmount();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "in-flight-probe",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(refreshedQuickModel).toHaveValue(
|
||||
modelIdentityValue({ provider: "openai", modelId: "new-model" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an older catalog response that resolves after a newer refresh", async () => {
|
||||
it("closes temporary runtime and blocks save for snapshot failure or empty catalog", async () => {
|
||||
const user = userEvent.setup();
|
||||
const firstCatalog = deferred<{ models: { provider: string; id: string }[] }>();
|
||||
const secondCatalog = deferred<{ models: { provider: string; id: string }[] }>();
|
||||
let catalogCalls = 0;
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "get_model_catalog") {
|
||||
catalogCalls += 1;
|
||||
return catalogCalls === 1 ? firstCatalog.promise : secondCatalog.promise;
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
let openCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
openCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `settings-${openCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "settings-1")
|
||||
return Promise.reject(new Error("offline"));
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: { data: { models: [] } },
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(1));
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
|
||||
await act(async () =>
|
||||
secondCatalog.resolve({
|
||||
models: [{ provider: "openai", id: "new-model" }],
|
||||
}),
|
||||
);
|
||||
const quickModel = screen.getByRole("combobox", { name: /Quick model/ });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/new-model" }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
firstCatalog.resolve({
|
||||
models: [{ provider: "openai", id: "stale-model" }],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
within(quickModel).getByRole("option", { name: "openai/new-model" }),
|
||||
await screen.findByText("Could not load model catalog: Error: offline"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(quickModel).queryByRole("option", { name: "openai/stale-model" }),
|
||||
).toBeNull();
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-1",
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "settings-2",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("clears catalog refresh error after a successful retry", async () => {
|
||||
it("invalidates selections and refreshes catalog for an edited workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
let catalogCalls = 0;
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "get_model_catalog") {
|
||||
catalogCalls += 1;
|
||||
if (catalogCalls === 1)
|
||||
return Promise.reject(new Error("catalog offline"));
|
||||
return Promise.resolve({
|
||||
models: [{ provider: "openai", id: "recovered-model" }],
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(
|
||||
command: string,
|
||||
payload?: { runtimeId?: string; worktreePath?: string },
|
||||
) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
const id = payload?.runtimeId === "probe-1" ? "old-model" : "new-model";
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · old-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||
target: { value: "/other" },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: /old-model/i })).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("create_quick_runtime", {
|
||||
worktreePath: "/other",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model openai · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not reconcile current settings from an out-of-order old-workspace response", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "old-model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "old-model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const oldSnapshot = deferred<{
|
||||
runtime: Record<string, never>;
|
||||
extensions: never[];
|
||||
models: { data: { models: Model[] } };
|
||||
}>();
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "probe-1") return oldSnapshot.promise;
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "new-provider", id: "new-model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(runtimeCount).toBe(1));
|
||||
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||
target: { value: "/other" },
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("button", {
|
||||
name: /Quick model new-provider · new-model/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
await act(async () =>
|
||||
oldSnapshot.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "old-provider", id: "stale-model" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /stale-model/i })).toBeNull();
|
||||
expect(screen.getByLabelText("Workspace")).toHaveValue("/other");
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||
runtimeId: "probe-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a retained probe close before creating the next Settings probe", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
let runtimeCount = 0;
|
||||
let firstProbeCloseCount = 0;
|
||||
const lifecycle: string[] = [];
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
lifecycle.push(`create:probe-${runtimeCount}`);
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: { models: [{ provider: "openai", id: "model" }] },
|
||||
},
|
||||
});
|
||||
if (command === "close_quick_runtime") {
|
||||
if (payload?.runtimeId === "probe-1") {
|
||||
firstProbeCloseCount += 1;
|
||||
lifecycle.push(`close:probe-1:${firstProbeCloseCount}`);
|
||||
if (firstProbeCloseCount === 1)
|
||||
return Promise.reject(new Error("transient close failure"));
|
||||
} else {
|
||||
lifecycle.push(`close:${payload?.runtimeId}`);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
"Could not load model catalog: Error: catalog offline",
|
||||
"Could not load model catalog: Error: transient close failure",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(runtimeCount).toBe(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(catalogCalls).toBe(2));
|
||||
await waitFor(() => expect(runtimeCount).toBe(2));
|
||||
await waitFor(() => expect(lifecycle).toContain("close:probe-2"));
|
||||
expect(lifecycle.indexOf("close:probe-1:2")).toBeLessThan(
|
||||
lifecycle.indexOf("create:probe-2"),
|
||||
);
|
||||
expect(firstProbeCloseCount).toBe(2);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("blocks new probes and save while retained probe cleanup keeps failing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
let createCount = 0;
|
||||
let closeCount = 0;
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
createCount += 1;
|
||||
return Promise.resolve({ runtime: { runtimeId: "probe" } });
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: { data: { models: [{ provider: "openai", id: "model" }] } },
|
||||
});
|
||||
if (command === "close_quick_runtime") {
|
||||
closeCount += 1;
|
||||
return Promise.reject(new Error("close failed"));
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
expect(
|
||||
await screen.findByText("Could not load model catalog: Error: close failed"),
|
||||
).toBeInTheDocument();
|
||||
expect(createCount).toBe(1);
|
||||
expect(closeCount).toBe(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back" }));
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() => expect(closeCount).toBe(2));
|
||||
expect(createCount).toBe(1);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeDisabled();
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"save_ui_state",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves only when ready catalog workspace exactly matches settings workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
const loaded = {
|
||||
...uiState,
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "openai",
|
||||
model: "model",
|
||||
followUpProvider: "openai",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay concise.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
const nextSnapshot = deferred<unknown>();
|
||||
let runtimeCount = 0;
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: { runtimeId?: string }) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(loaded);
|
||||
if (command === "create_quick_runtime") {
|
||||
runtimeCount += 1;
|
||||
return Promise.resolve({
|
||||
runtime: { runtimeId: `probe-${runtimeCount}` },
|
||||
});
|
||||
}
|
||||
if (command === "get_session_runtime_snapshot") {
|
||||
if (payload?.runtimeId === "probe-2") return nextSnapshot.promise;
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: {
|
||||
data: {
|
||||
models: [{ provider: "openai", id: "model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Could not load model catalog: Error: catalog offline",
|
||||
),
|
||||
).toBeNull(),
|
||||
screen.getByRole("button", { name: "Save Quick Pane settings" }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Workspace"), {
|
||||
target: { value: "/other" },
|
||||
});
|
||||
const save = screen.getByRole("button", { name: "Save Quick Pane settings" });
|
||||
expect(save).toBeDisabled();
|
||||
fireEvent.click(save);
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"save_ui_state",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("combobox", { name: /Quick model/ })).getByRole(
|
||||
"option",
|
||||
{ name: "openai/recovered-model" },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows restart progress until the replacement snapshot is restored", async () => {
|
||||
@@ -347,8 +884,14 @@ describe("App command progress and errors", () => {
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "restart") return restart.promise;
|
||||
if (command === "load_ui_state") return Promise.resolve(uiState);
|
||||
if (command === "get_model_catalog")
|
||||
return Promise.resolve({ models: [] });
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({ runtime: { runtimeId: "settings-runtime" } });
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve({
|
||||
runtime: {},
|
||||
extensions: [],
|
||||
models: { data: { models: [] } },
|
||||
});
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
||||
@@ -363,12 +906,8 @@ describe("App command progress and errors", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
||||
await user.click(restarting);
|
||||
expect(mocks.invoke).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state");
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "get_model_catalog");
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(3, "restart", {
|
||||
agentId: "agent-a",
|
||||
});
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("restart", { agentId: "agent-a" });
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith("get_model_catalog");
|
||||
|
||||
await act(async () => restart.resolve(undefined));
|
||||
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
|
||||
|
||||
+219
-100
@@ -5,25 +5,20 @@ import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "./App.css";
|
||||
import { ConversationWorkspace } from "./components/ConversationWorkspace";
|
||||
import { ModelSelector } from "./components/ModelSelector";
|
||||
import { DirectorySidebar } from "./components/DirectorySidebar";
|
||||
import { ExtensionDialog } from "./components/ExtensionDialog";
|
||||
import { CommandFormDialog } from "./components/CommandFormDialog";
|
||||
import type { RpcCommand } from "./commands/rpc";
|
||||
import { SessionPicker } from "./components/SessionPicker";
|
||||
import { SessionTabs } from "./components/SessionTabs";
|
||||
import {
|
||||
catalogModels,
|
||||
getModelCatalog,
|
||||
modelIdentity,
|
||||
modelIdentityFromValue,
|
||||
modelIdentityValue,
|
||||
reconcileModelIdentity,
|
||||
} from "./model-catalog";
|
||||
import { modelIdentity, reconcileModelIdentity } from "./model-catalog";
|
||||
import type {
|
||||
DirectorySession,
|
||||
DirectoryWorkspace,
|
||||
Model,
|
||||
QuickPaneSettings,
|
||||
RuntimeSnapshot,
|
||||
RuntimeView,
|
||||
UiState,
|
||||
} from "./workspace/types";
|
||||
@@ -170,17 +165,131 @@ export default function App() {
|
||||
const [quickPaneSettingsStatus, setQuickPaneSettingsStatus] = useState("");
|
||||
const [quickPaneSettingsError, setQuickPaneSettingsError] = useState<string>();
|
||||
const [modelCatalog, setModelCatalog] = useState<Model[]>([]);
|
||||
const [modelCatalogStatus, setModelCatalogStatus] = useState<
|
||||
"idle" | "loading" | "ready" | "error"
|
||||
>("idle");
|
||||
const [modelCatalogError, setModelCatalogError] = useState<string>();
|
||||
const [catalogWorkspace, setCatalogWorkspace] = useState<string>();
|
||||
const modelCatalogRequest = useRef(0);
|
||||
const catalogProbeRuntimeIds = useRef(new Set<string>());
|
||||
const retainedCatalogProbeRuntimeIds = useRef(new Set<string>());
|
||||
const retainedCatalogProbeErrors = useRef(new Map<string, unknown>());
|
||||
const closingCatalogProbeRuntimes = useRef(new Map<string, Promise<void>>());
|
||||
const quickPaneSettingsRef = useRef(quickPaneSettings);
|
||||
quickPaneSettingsRef.current = quickPaneSettings;
|
||||
const uiStateLoaded = uiState !== undefined;
|
||||
const refreshModelCatalog = useCallback(async () => {
|
||||
const requestId = ++modelCatalogRequest.current;
|
||||
try {
|
||||
const freshModels = await getModelCatalog();
|
||||
if (requestId !== modelCatalogRequest.current) return;
|
||||
setModelCatalog(freshModels);
|
||||
const closeCatalogProbe = useCallback((runtimeId: string) => {
|
||||
if (!catalogProbeRuntimeIds.current.has(runtimeId)) return Promise.resolve();
|
||||
const pendingClose = closingCatalogProbeRuntimes.current.get(runtimeId);
|
||||
if (pendingClose) return pendingClose;
|
||||
const close = Promise.resolve()
|
||||
.then(() => invoke("close_quick_runtime", { runtimeId }))
|
||||
.then(() => {
|
||||
catalogProbeRuntimeIds.current.delete(runtimeId);
|
||||
retainedCatalogProbeRuntimeIds.current.delete(runtimeId);
|
||||
retainedCatalogProbeErrors.current.delete(runtimeId);
|
||||
})
|
||||
.catch((error) => {
|
||||
retainedCatalogProbeRuntimeIds.current.add(runtimeId);
|
||||
retainedCatalogProbeErrors.current.set(runtimeId, error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
closingCatalogProbeRuntimes.current.delete(runtimeId);
|
||||
});
|
||||
closingCatalogProbeRuntimes.current.set(runtimeId, close);
|
||||
return close;
|
||||
}, []);
|
||||
const drainRetainedCatalogProbes = useCallback(async () => {
|
||||
const retainedIds = [...retainedCatalogProbeRuntimeIds.current];
|
||||
const results = await Promise.allSettled(
|
||||
retainedIds.map((runtimeId) => closeCatalogProbe(runtimeId)),
|
||||
);
|
||||
const failedClose = results.find(
|
||||
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||
);
|
||||
if (failedClose) throw failedClose.reason;
|
||||
}, [closeCatalogProbe]);
|
||||
const refreshModelCatalog = useCallback(
|
||||
async (workspace: string) => {
|
||||
const requestId = ++modelCatalogRequest.current;
|
||||
setModelCatalog([]);
|
||||
setCatalogWorkspace(undefined);
|
||||
setModelCatalogStatus("loading");
|
||||
setModelCatalogError(undefined);
|
||||
setQuickPaneSettingsStatus("");
|
||||
let temporaryRuntime: { runtimeId: string } | undefined;
|
||||
let snapshot: RuntimeSnapshot | undefined;
|
||||
let failure: unknown;
|
||||
let cleanupFailure: unknown;
|
||||
try {
|
||||
await drainRetainedCatalogProbes();
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
requestId !== modelCatalogRequest.current ||
|
||||
quickPaneSettingsRef.current.workspace !== workspace
|
||||
)
|
||||
return;
|
||||
const result = await invoke<{ runtime: { runtimeId: string } }>(
|
||||
"create_quick_runtime",
|
||||
{ worktreePath: workspace },
|
||||
);
|
||||
temporaryRuntime = result.runtime;
|
||||
catalogProbeRuntimeIds.current.add(temporaryRuntime.runtimeId);
|
||||
if (
|
||||
mountedRef.current &&
|
||||
requestId === modelCatalogRequest.current &&
|
||||
quickPaneSettingsRef.current.workspace === workspace
|
||||
) {
|
||||
snapshot = await invoke<RuntimeSnapshot>("get_session_runtime_snapshot", {
|
||||
runtimeId: temporaryRuntime.runtimeId,
|
||||
});
|
||||
if (snapshot.models?.error) throw new Error(snapshot.models.error);
|
||||
}
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
if (temporaryRuntime) {
|
||||
try {
|
||||
await closeCatalogProbe(temporaryRuntime.runtimeId);
|
||||
} catch (error) {
|
||||
cleanupFailure = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
if (cleanupFailure || retainedCatalogProbeRuntimeIds.current.size > 0) {
|
||||
setModelCatalog([]);
|
||||
setCatalogWorkspace(undefined);
|
||||
setModelCatalogStatus("error");
|
||||
const retainedFailure = retainedCatalogProbeErrors.current
|
||||
.values()
|
||||
.next().value;
|
||||
setModelCatalogError(
|
||||
`Could not load model catalog: ${String(
|
||||
cleanupFailure ?? retainedFailure ?? failure,
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
requestId !== modelCatalogRequest.current ||
|
||||
quickPaneSettingsRef.current.workspace !== workspace
|
||||
)
|
||||
return;
|
||||
if (failure) {
|
||||
setModelCatalogStatus("error");
|
||||
setModelCatalogError(`Could not load model catalog: ${String(failure)}`);
|
||||
return;
|
||||
}
|
||||
const freshModels = snapshot?.models?.data?.models ?? [];
|
||||
setModelCatalog(freshModels);
|
||||
setCatalogWorkspace(workspace);
|
||||
setModelCatalogStatus("ready");
|
||||
if (freshModels.length === 0)
|
||||
setQuickPaneSettingsStatus("No models are currently available.");
|
||||
setQuickPaneSettings((current) => {
|
||||
if (current.workspace !== workspace) return current;
|
||||
const quickModel = reconcileModelIdentity(freshModels, {
|
||||
provider: current.provider,
|
||||
modelId: current.model,
|
||||
@@ -210,11 +319,9 @@ export default function App() {
|
||||
);
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
if (requestId !== modelCatalogRequest.current) return;
|
||||
setModelCatalogError(`Could not load model catalog: ${String(error)}`);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[closeCatalogProbe, drainRetainedCatalogProbes],
|
||||
);
|
||||
const setRuntimeStatus = (runtimeId: string, detail: string) =>
|
||||
setRuntimeStatuses((current) => ({ ...current, [runtimeId]: detail }));
|
||||
const clearRuntimeStatus = (runtimeId: string) =>
|
||||
@@ -254,8 +361,10 @@ export default function App() {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
for (const runtimeId of catalogProbeRuntimeIds.current)
|
||||
void closeCatalogProbe(runtimeId).catch(() => undefined);
|
||||
};
|
||||
}, []);
|
||||
}, [closeCatalogProbe]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -280,8 +389,21 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "settings" && uiStateLoaded) void refreshModelCatalog();
|
||||
}, [refreshModelCatalog, uiStateLoaded, view]);
|
||||
if (view !== "settings" || !uiStateLoaded) return;
|
||||
const workspace = quickPaneSettings.workspace;
|
||||
if (!workspace.startsWith("/")) {
|
||||
setModelCatalogStatus("idle");
|
||||
return;
|
||||
}
|
||||
const refreshTimer = window.setTimeout(
|
||||
() => void refreshModelCatalog(workspace),
|
||||
150,
|
||||
);
|
||||
return () => {
|
||||
window.clearTimeout(refreshTimer);
|
||||
modelCatalogRequest.current += 1;
|
||||
};
|
||||
}, [quickPaneSettings.workspace, refreshModelCatalog, uiStateLoaded, view]);
|
||||
|
||||
const selectedRuntimeId = selected?.summary.runtimeId;
|
||||
useEffect(() => {
|
||||
@@ -304,6 +426,14 @@ export default function App() {
|
||||
}, [state.runtimesById]);
|
||||
|
||||
async function saveQuickPaneSettings() {
|
||||
if (
|
||||
modelCatalogStatus !== "ready" ||
|
||||
catalogWorkspace !== quickPaneSettings.workspace ||
|
||||
modelCatalog.length === 0
|
||||
) {
|
||||
setQuickPaneSettingsError("Model catalog is not ready.");
|
||||
return;
|
||||
}
|
||||
const validationError = validateQuickPaneSettings(quickPaneSettings);
|
||||
if (validationError) {
|
||||
setQuickPaneSettingsError(validationError);
|
||||
@@ -749,21 +879,6 @@ export default function App() {
|
||||
.map((entry) => entry.runtimeId as string),
|
||||
);
|
||||
const sameDirectoryCollision = directory && directory.openCount > 1;
|
||||
const quickPaneModelOptions = catalogModels(modelCatalog, []);
|
||||
const quickModelValue =
|
||||
quickPaneSettings.provider && quickPaneSettings.model
|
||||
? modelIdentityValue({
|
||||
provider: quickPaneSettings.provider,
|
||||
modelId: quickPaneSettings.model,
|
||||
})
|
||||
: "";
|
||||
const followUpModelValue =
|
||||
quickPaneSettings.followUpProvider && quickPaneSettings.followUpModel
|
||||
? modelIdentityValue({
|
||||
provider: quickPaneSettings.followUpProvider,
|
||||
modelId: quickPaneSettings.followUpModel,
|
||||
})
|
||||
: "";
|
||||
return (
|
||||
<main
|
||||
className="app-shell"
|
||||
@@ -938,78 +1053,71 @@ export default function App() {
|
||||
Workspace
|
||||
<input
|
||||
value={quickPaneSettings.workspace}
|
||||
onChange={(event) =>
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
workspace: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
onChange={(event) => {
|
||||
const workspace = event.currentTarget.value;
|
||||
modelCatalogRequest.current += 1;
|
||||
setModelCatalog([]);
|
||||
setCatalogWorkspace(undefined);
|
||||
setModelCatalogStatus("idle");
|
||||
setModelCatalogError(undefined);
|
||||
setQuickPaneSettingsStatus("");
|
||||
setQuickPaneSettings((current) => ({
|
||||
...current,
|
||||
workspace,
|
||||
provider: "",
|
||||
model: "",
|
||||
followUpProvider: "",
|
||||
followUpModel: "",
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Quick model
|
||||
<select
|
||||
value={quickModelValue}
|
||||
onChange={(event) => {
|
||||
const identity = modelIdentityFromValue(event.currentTarget.value);
|
||||
if (!identity) return;
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
<div className="quick-pane-model-setting">
|
||||
<ModelSelector
|
||||
label="Quick model"
|
||||
placement="down"
|
||||
models={modelCatalog}
|
||||
current={{
|
||||
provider: quickPaneSettings.provider,
|
||||
id: quickPaneSettings.model,
|
||||
}}
|
||||
disabled={modelCatalogStatus !== "ready"}
|
||||
onSelect={async (model) => {
|
||||
const identity = modelIdentity(model);
|
||||
if (!identity) return false;
|
||||
setQuickPaneSettings((current) => ({
|
||||
...current,
|
||||
provider: identity.provider,
|
||||
model: identity.modelId,
|
||||
});
|
||||
}));
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select model
|
||||
</option>
|
||||
{quickPaneModelOptions.map((model) => {
|
||||
const identity = modelIdentity(model);
|
||||
if (!identity) return null;
|
||||
return (
|
||||
<option
|
||||
key={modelIdentityValue(identity)}
|
||||
value={modelIdentityValue(identity)}
|
||||
>
|
||||
{identity.provider}/{identity.modelId}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
/>
|
||||
<small>Provider comes from selected catalog model.</small>
|
||||
</label>
|
||||
<label>
|
||||
Follow-up model
|
||||
<select
|
||||
value={followUpModelValue}
|
||||
onChange={(event) => {
|
||||
const identity = modelIdentityFromValue(event.currentTarget.value);
|
||||
if (!identity) return;
|
||||
setQuickPaneSettings({
|
||||
...quickPaneSettings,
|
||||
</div>
|
||||
<div className="quick-pane-model-setting">
|
||||
<ModelSelector
|
||||
label="Follow-up model"
|
||||
placement="down"
|
||||
models={modelCatalog}
|
||||
current={{
|
||||
provider: quickPaneSettings.followUpProvider,
|
||||
id: quickPaneSettings.followUpModel,
|
||||
}}
|
||||
disabled={modelCatalogStatus !== "ready"}
|
||||
onSelect={async (model) => {
|
||||
const identity = modelIdentity(model);
|
||||
if (!identity) return false;
|
||||
setQuickPaneSettings((current) => ({
|
||||
...current,
|
||||
followUpProvider: identity.provider,
|
||||
followUpModel: identity.modelId,
|
||||
});
|
||||
}));
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select model
|
||||
</option>
|
||||
{quickPaneModelOptions.map((model) => {
|
||||
const identity = modelIdentity(model);
|
||||
if (!identity) return null;
|
||||
return (
|
||||
<option
|
||||
key={modelIdentityValue(identity)}
|
||||
value={modelIdentityValue(identity)}
|
||||
>
|
||||
{identity.provider}/{identity.modelId}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
/>
|
||||
<small>Provider comes from selected catalog model.</small>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Maximum seconds
|
||||
<input
|
||||
@@ -1052,9 +1160,20 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={!uiState}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
!uiState ||
|
||||
modelCatalogStatus !== "ready" ||
|
||||
catalogWorkspace !== quickPaneSettings.workspace ||
|
||||
modelCatalog.length === 0
|
||||
}
|
||||
>
|
||||
Save Quick Pane settings
|
||||
</button>
|
||||
{modelCatalogStatus === "loading" && (
|
||||
<p role="status">Loading model catalog…</p>
|
||||
)}
|
||||
{quickPaneSettingsStatus && (
|
||||
<p role="status">{quickPaneSettingsStatus}</p>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
.model-control {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
.model-selector {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.model-trigger {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
appearance: none;
|
||||
padding: 5px 25px 5px 6px;
|
||||
border: 1px solid #70522f;
|
||||
border-radius: 4px;
|
||||
background: #2c2a27
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
|
||||
no-repeat right 7px center;
|
||||
color: #f2ece4;
|
||||
font: inherit;
|
||||
font-weight: 650;
|
||||
text-align: left;
|
||||
}
|
||||
.model-trigger:hover {
|
||||
border-color: #d88735;
|
||||
background-color: #383129;
|
||||
}
|
||||
.model-trigger:focus {
|
||||
border-color: #f0a347;
|
||||
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
|
||||
}
|
||||
.model-trigger > span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.model-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
width: min(400px, calc(100vw - 40px));
|
||||
max-height: min(420px, calc(100vh - 80px));
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid #70522f;
|
||||
border-radius: 7px;
|
||||
background: #292724;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.model-control-down .model-menu {
|
||||
top: calc(100% + 6px);
|
||||
bottom: auto;
|
||||
}
|
||||
.model-menu input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid #5b5146;
|
||||
border-radius: 4px;
|
||||
background: #1f1e1c;
|
||||
color: #f2ece4;
|
||||
}
|
||||
.model-result-count {
|
||||
margin: 0;
|
||||
color: #aaa39a;
|
||||
font-size: 10px;
|
||||
}
|
||||
.model-results {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.model-provider-group {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.model-provider-heading {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 4px 5px 3px;
|
||||
background: #292724;
|
||||
color: #f0a347;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.model-option {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
padding: 6px 7px;
|
||||
border: 1px solid transparent;
|
||||
background: #35322e;
|
||||
color: #f2ece4;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.model-option:hover {
|
||||
border-color: #8c6338;
|
||||
background: #44382d;
|
||||
}
|
||||
.model-option[aria-selected="true"] {
|
||||
border-color: #f0a347;
|
||||
background: #4b3420;
|
||||
color: #fff2df;
|
||||
}
|
||||
.model-option small {
|
||||
overflow-wrap: anywhere;
|
||||
color: #aaa39a;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.model-empty {
|
||||
margin: 5px 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -76,6 +76,22 @@ describe("ModelSelector", () => {
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it("supports labeled downward placement for toolbar and Settings reuse", () => {
|
||||
const { container } = render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
current={{ provider: "openai", id: "organization/gpt-5" }}
|
||||
label="Quick model"
|
||||
placement="down"
|
||||
onSelect={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Quick model openai · GPT 5/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(container.querySelector(".model-control-down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the selector open and restores controls when an update fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn().mockResolvedValue(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { AgentState, Model } from "../workspace/types";
|
||||
import "./ModelSelector.css";
|
||||
|
||||
type SelectableModel = Model & { provider: string; id: string };
|
||||
type ModelGroup = { provider: string; models: SelectableModel[] };
|
||||
@@ -87,11 +88,19 @@ export function ModelSelector({
|
||||
current,
|
||||
onSelect,
|
||||
pending = false,
|
||||
disabled = false,
|
||||
label = "Model",
|
||||
placement = "up",
|
||||
className = "",
|
||||
}: {
|
||||
models: Model[];
|
||||
current?: AgentState["model"];
|
||||
onSelect: (model: Model) => Promise<boolean>;
|
||||
pending?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
placement?: "up" | "down";
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -161,8 +170,11 @@ export function ModelSelector({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="model-control" aria-busy={busy}>
|
||||
<span id={labelId}>Model</span>
|
||||
<div
|
||||
className={`model-control model-control-${placement} ${className}`.trim()}
|
||||
aria-busy={busy}
|
||||
>
|
||||
<span id={labelId}>{label}</span>
|
||||
<div className="model-selector" ref={rootRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
@@ -172,7 +184,7 @@ export function ModelSelector({
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
disabled={availableModels.length === 0 || busy}
|
||||
disabled={availableModels.length === 0 || busy || disabled}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setOpen((value) => !value);
|
||||
@@ -219,7 +231,7 @@ export function ModelSelector({
|
||||
current={current}
|
||||
headingId={`${menuId}-provider-${index}`}
|
||||
onSelect={(model) => void chooseModel(model)}
|
||||
disabled={busy}
|
||||
disabled={busy || disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Model } from "./workspace/types";
|
||||
|
||||
export type ModelIdentity = { provider: string; modelId: string };
|
||||
@@ -66,8 +65,3 @@ export function catalogModels(
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
export async function getModelCatalog() {
|
||||
const catalog = await invoke<{ models?: Model[] }>("get_model_catalog");
|
||||
return catalog?.models ?? [];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+235
-116
@@ -5,11 +5,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ExtensionDialog } from "../components/ExtensionDialog";
|
||||
import { ModelSelector } from "../components/ModelSelector";
|
||||
import {
|
||||
catalogModels,
|
||||
getModelCatalog,
|
||||
modelIdentity,
|
||||
modelIdentityFromValue,
|
||||
modelIdentityValue,
|
||||
reconcileModelIdentity,
|
||||
type ModelIdentity,
|
||||
@@ -24,6 +22,8 @@ import type {
|
||||
} from "../workspace/types";
|
||||
|
||||
const ESCALATION_MARKER = "[ESCALATE]";
|
||||
const pendingQuickRuntimeCleanupIds = new Set<string>();
|
||||
const pendingQuickRuntimeCleanupPromises = new Map<string, Promise<void>>();
|
||||
|
||||
const defaults: QuickPaneSettings = {
|
||||
workspace: "",
|
||||
@@ -38,6 +38,7 @@ const defaults: QuickPaneSettings = {
|
||||
};
|
||||
|
||||
type Runtime = { agentId: string; runtimeId: string };
|
||||
type PreparedRuntime = { runtime: Runtime; models: Model[] };
|
||||
type HistoryEntry = { id: string; request: string; answer: string };
|
||||
type ActivitySnapshot = {
|
||||
activeTool?: string;
|
||||
@@ -109,6 +110,7 @@ export default function QuickPane() {
|
||||
const [request, setRequest] = useState("");
|
||||
const [currentRequest, setCurrentRequest] = useState("");
|
||||
const [submissionStarting, setSubmissionStarting] = useState(false);
|
||||
const [requestActive, setRequestActive] = useState(false);
|
||||
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [catalogStatus, setCatalogStatus] = useState<
|
||||
@@ -125,7 +127,6 @@ export default function QuickPane() {
|
||||
const [runtime, setRuntime] = useState<Runtime>();
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [completedRuntimeId, setCompletedRuntimeId] = useState<string>();
|
||||
const [failedRuntimeId, setFailedRuntimeId] = useState<string>();
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [selectedHistoryId, setSelectedHistoryId] = useState("");
|
||||
const [escalation, setEscalation] = useState<string>();
|
||||
@@ -135,6 +136,12 @@ export default function QuickPane() {
|
||||
});
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
||||
const preparedRuntimeRef = useRef<PreparedRuntime | undefined>(undefined);
|
||||
const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds);
|
||||
const mountedRef = useRef(true);
|
||||
const requestActiveRef = useRef(false);
|
||||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
const submittedRequestRef = useRef<
|
||||
{ runtimeId: string; request: string } | undefined
|
||||
>(undefined);
|
||||
@@ -142,21 +149,13 @@ export default function QuickPane() {
|
||||
const completedRuntimeRef = useRef<string | undefined>(undefined);
|
||||
const submissionStartingRef = useRef(false);
|
||||
const runStartedAtRef = useRef<number | undefined>(undefined);
|
||||
const catalogRequestRef = useRef(0);
|
||||
const preparationGenerationRef = useRef(0);
|
||||
const preparationLockRef = useRef(false);
|
||||
const catalogReadyRef = useRef(false);
|
||||
const selectedModelRef = useRef(selectedModel);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const invalid = useMemo(() => settingsError(settings), [settings]);
|
||||
const modelOptions = useMemo(() => catalogModels(models, []), [models]);
|
||||
const selectedModelValue =
|
||||
selectedModel.provider && selectedModel.modelId
|
||||
? modelIdentityValue(selectedModel)
|
||||
: "";
|
||||
const runActive =
|
||||
submissionStarting ||
|
||||
(!!runtime &&
|
||||
completedRuntimeId !== runtime.runtimeId &&
|
||||
failedRuntimeId !== runtime.runtimeId);
|
||||
const runActive = submissionStarting || requestActive;
|
||||
const viewingHistory = !!selectedHistoryId && !runActive;
|
||||
const displayedEntry = viewingHistory
|
||||
? history.find((entry) => entry.id === selectedHistoryId)
|
||||
@@ -179,48 +178,160 @@ export default function QuickPane() {
|
||||
? `${activityPhase} · ${elapsedSeconds}s`
|
||||
: activityPhase;
|
||||
|
||||
const dismissPane = () => invoke("dismiss_quick_pane");
|
||||
const focusInput = () => inputRef.current?.focus();
|
||||
const loadModelCatalog = useCallback(
|
||||
async (preferredModel?: ModelIdentity) => {
|
||||
const requestId = ++catalogRequestRef.current;
|
||||
const closeRuntime = useCallback(async (runtimeId: string) => {
|
||||
const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
|
||||
if (pending) return pending;
|
||||
|
||||
cleanupQueueRef.current.add(runtimeId);
|
||||
const cleanup = invoke("close_quick_runtime", { runtimeId })
|
||||
.then(() => {
|
||||
cleanupQueueRef.current.delete(runtimeId);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingQuickRuntimeCleanupPromises.delete(runtimeId);
|
||||
});
|
||||
pendingQuickRuntimeCleanupPromises.set(runtimeId, cleanup);
|
||||
return cleanup;
|
||||
}, []);
|
||||
const clearRuntimeIfCurrent = useCallback((runtimeId: string) => {
|
||||
if (runtimeRef.current?.runtimeId !== runtimeId) return;
|
||||
runtimeRef.current = undefined;
|
||||
preparedRuntimeRef.current = undefined;
|
||||
setRuntime(undefined);
|
||||
}, []);
|
||||
const prepareRuntime = useCallback(
|
||||
async (preferredModel?: ModelIdentity, ignoreLock = false) => {
|
||||
if (
|
||||
requestActiveRef.current ||
|
||||
(preparationLockRef.current && !ignoreLock)
|
||||
)
|
||||
return false;
|
||||
const generation = ++preparationGenerationRef.current;
|
||||
catalogReadyRef.current = false;
|
||||
setCatalogStatus("loading");
|
||||
setCatalogError(undefined);
|
||||
setCatalogNotice(undefined);
|
||||
setModels([]);
|
||||
|
||||
try {
|
||||
const freshModels = await getModelCatalog();
|
||||
if (requestId !== catalogRequestRef.current) return;
|
||||
for (const runtimeId of [...cleanupQueueRef.current]) {
|
||||
await closeRuntime(runtimeId);
|
||||
clearRuntimeIfCurrent(runtimeId);
|
||||
}
|
||||
|
||||
const previous = runtimeRef.current;
|
||||
if (previous) {
|
||||
await closeRuntime(previous.runtimeId);
|
||||
clearRuntimeIfCurrent(previous.runtimeId);
|
||||
}
|
||||
} catch (reason) {
|
||||
if (generation === preparationGenerationRef.current) {
|
||||
setCatalogStatus("error");
|
||||
setCatalogError(`Could not prepare model catalog: ${String(reason)}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (generation !== preparationGenerationRef.current) return false;
|
||||
|
||||
let created: Runtime | undefined;
|
||||
let retained = false;
|
||||
let preparationFailure: unknown;
|
||||
try {
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
"create_quick_runtime",
|
||||
{ worktreePath: settingsRef.current.workspace },
|
||||
);
|
||||
created = result.runtime;
|
||||
if (generation !== preparationGenerationRef.current) return false;
|
||||
const snapshot = await invoke<RuntimeSnapshot>(
|
||||
"get_session_runtime_snapshot",
|
||||
{ runtimeId: created.runtimeId },
|
||||
);
|
||||
if (generation !== preparationGenerationRef.current) return false;
|
||||
if (snapshot.models?.error) throw new Error(snapshot.models.error);
|
||||
const freshModels = snapshot.models?.data?.models ?? [];
|
||||
const preferred = preferredModel ?? selectedModelRef.current;
|
||||
const reconciled = reconcileModelIdentity(freshModels, preferred);
|
||||
|
||||
runtimeRef.current = created;
|
||||
preparedRuntimeRef.current = { runtime: created, models: freshModels };
|
||||
setRuntime(created);
|
||||
setModels(freshModels);
|
||||
setCatalogError(undefined);
|
||||
catalogReadyRef.current = true;
|
||||
setCatalogStatus("ready");
|
||||
retained = true;
|
||||
if (!reconciled) {
|
||||
const emptyModel = { provider: "", modelId: "" };
|
||||
selectedModelRef.current = emptyModel;
|
||||
setSelectedModel(emptyModel);
|
||||
setCatalogStatus("ready");
|
||||
setCatalogNotice("No models are currently available.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
selectedModelRef.current = reconciled;
|
||||
setSelectedModel(reconciled);
|
||||
catalogReadyRef.current = true;
|
||||
setCatalogStatus("ready");
|
||||
setCatalogNotice(
|
||||
modelIdentityValue(reconciled) === modelIdentityValue(preferred)
|
||||
? undefined
|
||||
: `Selected model unavailable; using ${reconciled.provider}/${reconciled.modelId}.`,
|
||||
);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
if (requestId !== catalogRequestRef.current) return;
|
||||
catalogReadyRef.current = false;
|
||||
setCatalogStatus("error");
|
||||
setCatalogError(`Could not load model catalog: ${String(reason)}`);
|
||||
preparationFailure = reason;
|
||||
if (generation === preparationGenerationRef.current) {
|
||||
catalogReadyRef.current = false;
|
||||
preparedRuntimeRef.current = undefined;
|
||||
setCatalogStatus("error");
|
||||
setCatalogError(`Could not prepare model catalog: ${String(reason)}`);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (created && !retained) {
|
||||
try {
|
||||
await closeRuntime(created.runtimeId);
|
||||
} catch (cleanupReason) {
|
||||
if (generation === preparationGenerationRef.current) {
|
||||
catalogReadyRef.current = false;
|
||||
setCatalogStatus("error");
|
||||
setCatalogError(
|
||||
preparationFailure
|
||||
? `Could not prepare model catalog: ${String(preparationFailure)}; cleanup failed: ${String(cleanupReason)}`
|
||||
: `Could not clean up stale quick runtime: ${String(cleanupReason)}`,
|
||||
);
|
||||
} else if (mountedRef.current) {
|
||||
setCatalogError(
|
||||
`Could not clean up stale quick runtime: ${String(cleanupReason)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
[clearRuntimeIfCurrent, closeRuntime],
|
||||
);
|
||||
|
||||
const closeIdleRuntime = useCallback(async () => {
|
||||
const current = runtimeRef.current;
|
||||
if (!current || requestActiveRef.current) return true;
|
||||
try {
|
||||
await closeRuntime(current.runtimeId);
|
||||
clearRuntimeIfCurrent(current.runtimeId);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setCatalogStatus("error");
|
||||
setCatalogError(`Could not close Quick runtime: ${String(reason)}`);
|
||||
return false;
|
||||
}
|
||||
}, [clearRuntimeIfCurrent, closeRuntime]);
|
||||
|
||||
const dismissPane = useCallback(async () => {
|
||||
if (await closeIdleRuntime()) await invoke("dismiss_quick_pane");
|
||||
}, [closeIdleRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
mountedRef.current = true;
|
||||
void (async () => {
|
||||
let nextSettings: QuickPaneSettings;
|
||||
try {
|
||||
@@ -232,6 +343,7 @@ export default function QuickPane() {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsRef.current = nextSettings;
|
||||
setSettings(nextSettings);
|
||||
setSettingsLoaded(true);
|
||||
const configuredModel = {
|
||||
@@ -241,17 +353,16 @@ export default function QuickPane() {
|
||||
selectedModelRef.current = configuredModel;
|
||||
setSelectedModel(configuredModel);
|
||||
setNotice("");
|
||||
if (active) await loadModelCatalog(configuredModel);
|
||||
if (active) await prepareRuntime(configuredModel);
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
mountedRef.current = false;
|
||||
preparationGenerationRef.current += 1;
|
||||
const current = runtimeRef.current;
|
||||
if (current)
|
||||
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (current) void closeRuntime(current.runtimeId).catch(() => undefined);
|
||||
};
|
||||
}, [loadModelCatalog]);
|
||||
}, [closeRuntime, prepareRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settingsLoaded) focusInput();
|
||||
@@ -279,6 +390,7 @@ export default function QuickPane() {
|
||||
"quick-pane-settings-saved",
|
||||
(event) => {
|
||||
const nextSettings = { ...defaults, ...event.payload };
|
||||
settingsRef.current = nextSettings;
|
||||
setSettings(nextSettings);
|
||||
const configuredModel = {
|
||||
provider: nextSettings.provider,
|
||||
@@ -286,17 +398,30 @@ export default function QuickPane() {
|
||||
};
|
||||
selectedModelRef.current = configuredModel;
|
||||
setSelectedModel(configuredModel);
|
||||
void loadModelCatalog(configuredModel);
|
||||
if (!requestActiveRef.current && !submissionStartingRef.current)
|
||||
void prepareRuntime(configuredModel);
|
||||
},
|
||||
);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") void dismissPane();
|
||||
if (event.key !== "Escape" || event.defaultPrevented) return;
|
||||
const target = event.target;
|
||||
const selector =
|
||||
target instanceof Element ? target.closest(".model-selector") : null;
|
||||
if (
|
||||
selector?.querySelector(".model-menu") ||
|
||||
document.querySelector(".model-selector .model-menu")
|
||||
)
|
||||
return;
|
||||
void dismissPane();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
const focusListener = paneWindow.onFocusChanged((event) => {
|
||||
if (event.payload) {
|
||||
focusInput();
|
||||
void loadModelCatalog();
|
||||
if (!requestActiveRef.current && !submissionStartingRef.current)
|
||||
void prepareRuntime();
|
||||
} else if (!submissionStartingRef.current) {
|
||||
void closeIdleRuntime();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -304,10 +429,10 @@ export default function QuickPane() {
|
||||
void settingsListener.then((unlisten) => unlisten());
|
||||
void focusListener.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [loadModelCatalog]);
|
||||
}, [closeIdleRuntime, dismissPane, prepareRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime) return;
|
||||
if (!runtime || !requestActive) return;
|
||||
let active = true;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
@@ -347,6 +472,8 @@ export default function QuickPane() {
|
||||
completedRuntimeRef.current !== runtime.runtimeId
|
||||
) {
|
||||
completedRuntimeRef.current = runtime.runtimeId;
|
||||
requestActiveRef.current = false;
|
||||
setRequestActive(false);
|
||||
setCompletedRuntimeId(runtime.runtimeId);
|
||||
setNotice("");
|
||||
const submitted = submittedRequestRef.current;
|
||||
@@ -370,7 +497,8 @@ export default function QuickPane() {
|
||||
setExtension(snapshot.extensions[0]);
|
||||
if (snapshot.runtime.error?.message) {
|
||||
setError(snapshot.runtime.error.message);
|
||||
setFailedRuntimeId(runtime.runtimeId);
|
||||
requestActiveRef.current = false;
|
||||
setRequestActive(false);
|
||||
setEscalation(
|
||||
"Quick runtime failed. Continue in a full session if needed.",
|
||||
);
|
||||
@@ -386,84 +514,94 @@ export default function QuickPane() {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [runtime]);
|
||||
}, [requestActive, runtime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime || completedRuntimeId === runtime.runtimeId) return;
|
||||
if (!runtime || !requestActive) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setEscalation(
|
||||
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
|
||||
);
|
||||
setFailedRuntimeId(runtime.runtimeId);
|
||||
requestActiveRef.current = false;
|
||||
setRequestActive(false);
|
||||
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
|
||||
}, settings.maximumSeconds * 1000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [completedRuntimeId, runtime, settings.maximumSeconds]);
|
||||
}, [requestActive, runtime, settings.maximumSeconds]);
|
||||
|
||||
async function run() {
|
||||
if (submissionStartingRef.current || runActive) return;
|
||||
if (!catalogReadyRef.current || !selectedModelRef.current.modelId)
|
||||
return setError("Model catalog is not ready.");
|
||||
if (submissionStartingRef.current || requestActiveRef.current) return;
|
||||
if (invalid || !request.trim())
|
||||
return setError(invalid ?? "Enter a request.");
|
||||
if (!catalogReadyRef.current || !selectedModelRef.current.modelId)
|
||||
return setError("Model catalog is not ready.");
|
||||
|
||||
submissionStartingRef.current = true;
|
||||
preparationLockRef.current = true;
|
||||
setSubmissionStarting(true);
|
||||
const submittedRequest = request;
|
||||
const submittedModel = selectedModelRef.current;
|
||||
setCurrentRequest(submittedRequest);
|
||||
setSelectedHistoryId("");
|
||||
runStartedAtRef.current = Date.now();
|
||||
setElapsedSeconds(0);
|
||||
try {
|
||||
const previous = runtimeRef.current;
|
||||
if (previous)
|
||||
await invoke("close_quick_runtime", {
|
||||
runtimeId: previous.runtimeId,
|
||||
}).catch(() => undefined);
|
||||
runtimeRef.current = undefined;
|
||||
submittedRequestRef.current = undefined;
|
||||
if (!preparedRuntimeRef.current) {
|
||||
const prepared = await prepareRuntime(selectedModelRef.current, true);
|
||||
if (!prepared) throw new Error("Model catalog is not ready.");
|
||||
}
|
||||
const prepared = preparedRuntimeRef.current;
|
||||
const submittedModel = selectedModelRef.current;
|
||||
if (
|
||||
!prepared ||
|
||||
!prepared.models.some((model) => {
|
||||
const identity = modelIdentity(model);
|
||||
return (
|
||||
identity?.provider === submittedModel.provider &&
|
||||
identity.modelId === submittedModel.modelId
|
||||
);
|
||||
})
|
||||
)
|
||||
throw new Error("Selected model is not in prepared runtime catalog.");
|
||||
|
||||
const submittedRuntime = prepared.runtime;
|
||||
preparedRuntimeRef.current = undefined;
|
||||
submittedRequestRef.current = {
|
||||
runtimeId: submittedRuntime.runtimeId,
|
||||
request: submittedRequest,
|
||||
};
|
||||
promptSubmittedRuntimeRef.current = undefined;
|
||||
completedRuntimeRef.current = undefined;
|
||||
setRuntime(undefined);
|
||||
setCurrentRequest(submittedRequest);
|
||||
setSelectedHistoryId("");
|
||||
setCompletedRuntimeId(undefined);
|
||||
setFailedRuntimeId(undefined);
|
||||
setError(undefined);
|
||||
setAnswer("");
|
||||
setEscalation(undefined);
|
||||
setExtension(undefined);
|
||||
setActivitySnapshot({ hasAssistantText: false });
|
||||
setNotice("");
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
"create_quick_runtime",
|
||||
{
|
||||
worktreePath: settings.workspace,
|
||||
},
|
||||
);
|
||||
runtimeRef.current = result.runtime;
|
||||
submittedRequestRef.current = {
|
||||
runtimeId: result.runtime.runtimeId,
|
||||
request: submittedRequest,
|
||||
};
|
||||
setRuntime(result.runtime);
|
||||
runStartedAtRef.current = Date.now();
|
||||
setElapsedSeconds(0);
|
||||
requestActiveRef.current = true;
|
||||
setRequestActive(true);
|
||||
|
||||
await invoke("set_model", {
|
||||
agentId: result.runtime.agentId,
|
||||
agentId: submittedRuntime.agentId,
|
||||
provider: submittedModel.provider,
|
||||
modelId: submittedModel.modelId,
|
||||
});
|
||||
await invoke("set_thinking_level", {
|
||||
agentId: result.runtime.agentId,
|
||||
agentId: submittedRuntime.agentId,
|
||||
level: thinkingLevel,
|
||||
});
|
||||
const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${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 budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${submittedRequest}`;
|
||||
await invoke("submit_prompt", {
|
||||
agentId: result.runtime.agentId,
|
||||
agentId: submittedRuntime.agentId,
|
||||
message: guardrails,
|
||||
});
|
||||
promptSubmittedRuntimeRef.current = result.runtime.runtimeId;
|
||||
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
|
||||
} catch (reason) {
|
||||
requestActiveRef.current = false;
|
||||
setRequestActive(false);
|
||||
setError(`Could not start quick request: ${String(reason)}`);
|
||||
setFailedRuntimeId(runtimeRef.current?.runtimeId);
|
||||
} finally {
|
||||
preparationLockRef.current = false;
|
||||
submissionStartingRef.current = false;
|
||||
setSubmissionStarting(false);
|
||||
focusInput();
|
||||
@@ -516,42 +654,23 @@ export default function QuickPane() {
|
||||
<main className="quick-pane">
|
||||
<header className="quick-pane-toolbar">
|
||||
<div className="quick-pane-primary-controls">
|
||||
<label className="quick-pane-field quick-pane-model">
|
||||
<span className="full-label">Model</span>
|
||||
<span className="compact-label" aria-hidden="true">
|
||||
M
|
||||
</span>
|
||||
<select
|
||||
aria-label="Model"
|
||||
value={selectedModelValue}
|
||||
disabled={runActive}
|
||||
onChange={(event) => {
|
||||
const identity = modelIdentityFromValue(
|
||||
event.currentTarget.value,
|
||||
);
|
||||
if (identity) {
|
||||
selectedModelRef.current = identity;
|
||||
setSelectedModel(identity);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select model
|
||||
</option>
|
||||
{modelOptions.map((model) => {
|
||||
const identity = modelIdentity(model);
|
||||
if (!identity) return null;
|
||||
return (
|
||||
<option
|
||||
key={modelIdentityValue(identity)}
|
||||
value={modelIdentityValue(identity)}
|
||||
>
|
||||
{identity.provider}/{identity.modelId}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<ModelSelector
|
||||
className="quick-pane-model-selector"
|
||||
placement="down"
|
||||
models={models}
|
||||
current={{
|
||||
provider: selectedModel.provider,
|
||||
id: selectedModel.modelId,
|
||||
}}
|
||||
disabled={runActive || catalogStatus !== "ready"}
|
||||
onSelect={async (model) => {
|
||||
const identity = modelIdentity(model);
|
||||
if (!identity) return false;
|
||||
selectedModelRef.current = identity;
|
||||
setSelectedModel(identity);
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
<label className="quick-pane-field quick-pane-thinking">
|
||||
<span className="full-label">Thinking</span>
|
||||
<span className="compact-label" aria-hidden="true">
|
||||
|
||||
@@ -22,7 +22,7 @@ body {
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
padding: 10px;
|
||||
color: #e6e3df;
|
||||
font:
|
||||
@@ -58,13 +58,11 @@ body {
|
||||
|
||||
.quick-pane-primary-controls {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-pane-header-actions {
|
||||
flex: 0 1 auto;
|
||||
justify-content: end;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-pane-field {
|
||||
@@ -76,9 +74,53 @@ body {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quick-pane-model {
|
||||
.quick-pane-model-selector {
|
||||
flex: 1 1 230px;
|
||||
max-width: 310px;
|
||||
color: #bbb7b0;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.quick-pane .model-trigger {
|
||||
height: 27px;
|
||||
border: 0;
|
||||
padding: 5px 25px 5px 8px;
|
||||
color: #e6e3df;
|
||||
background-color: #353535;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quick-pane .model-menu {
|
||||
width: min(400px, calc(100vw - 24px));
|
||||
max-height: min(350px, calc(100vh - 56px));
|
||||
color: #e6e3df;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.quick-pane .model-option {
|
||||
display: grid;
|
||||
padding: 6px 7px;
|
||||
border: 1px solid transparent;
|
||||
color: #f2ece4;
|
||||
background: #35322e;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quick-pane .model-option:hover {
|
||||
border-color: #8c6338;
|
||||
background: #44382d;
|
||||
}
|
||||
|
||||
.quick-pane .model-option[aria-selected="true"] {
|
||||
border-color: #f0a347;
|
||||
background: #4b3420;
|
||||
}
|
||||
|
||||
.quick-pane-thinking {
|
||||
|
||||
Reference in New Issue
Block a user