feat(quick-pane): polish compact chat controls

This commit is contained in:
2026-08-27 17:18:16 +02:00
parent 60fb91f375
commit 880cddf3cf
11 changed files with 317 additions and 40 deletions
+4 -4
View File
@@ -284,7 +284,7 @@ async fn get_model_catalog() -> Result<Value, String> {
}
fn quick_pane_height(height: f64) -> f64 {
height.clamp(360.0, 520.0)
height.clamp(240.0, 520.0)
}
#[tauri::command]
@@ -790,9 +790,9 @@ mod tests {
.unwrap();
assert_eq!(quick_pane.width, 640.0);
assert_eq!(quick_pane.height, 360.0);
assert_eq!(quick_pane.height, 240.0);
assert_eq!(quick_pane.min_width, Some(520.0));
assert_eq!(quick_pane.min_height, Some(360.0));
assert_eq!(quick_pane.min_height, Some(240.0));
assert_eq!(quick_pane.url.to_string(), "index.html");
assert!(!quick_pane.visible);
assert!(quick_pane.transparent);
@@ -802,7 +802,7 @@ mod tests {
#[test]
fn quick_pane_height_stays_within_content_limit() {
assert_eq!(quick_pane_height(200.0), 360.0);
assert_eq!(quick_pane_height(200.0), 240.0);
assert_eq!(quick_pane_height(420.0), 420.0);
assert_eq!(quick_pane_height(900.0), 520.0);
}
+2 -2
View File
@@ -29,9 +29,9 @@
"label": "quick-pane",
"title": "Pi Quick Pane",
"width": 640,
"height": 360,
"height": 240,
"minWidth": 520,
"minHeight": 360,
"minHeight": 240,
"center": true,
"visible": false,
"decorations": false,
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
catalogModels,
reconcileThinkingLevel,
thinkingLevelOptions,
} from "./model-catalog";
describe("thinking model catalog metadata", () => {
it("uses catalog thinking levels and reconciles removed selections", () => {
const model = {
provider: "provider",
id: "reasoning",
thinkingLevelMap: { off: "off", low: "low", high: "high" },
};
expect(thinkingLevelOptions(model)).toEqual([
{ value: "off", label: "Off" },
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
]);
expect(reconcileThinkingLevel(model, "high")).toBe("high");
expect(reconcileThinkingLevel(model, "medium")).toBe("off");
});
it("keeps unknown and dynamically configured local models at Off", () => {
expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]);
const [dynamic] = catalogModels([], [
{ provider: "localai", modelId: "local/unknown" },
]);
expect(dynamic.thinkingLevelMap).toEqual({ off: "off" });
expect(thinkingLevelOptions(dynamic)).toEqual([
{ value: "off", label: "Off" },
]);
});
});
+28 -1
View File
@@ -1,6 +1,9 @@
import type { Model } from "./workspace/types";
export type ModelIdentity = { provider: string; modelId: string };
export type ThinkingLevelOption = { value: string; label: string };
const OFF_THINKING_LEVEL: ThinkingLevelOption = { value: "off", label: "Off" };
export function modelIdentity(model: Model): ModelIdentity | undefined {
if (!model.provider || !model.id) return undefined;
@@ -45,6 +48,25 @@ export function reconcileModelIdentity(
);
}
export function thinkingLevelOptions(model?: Model): ThinkingLevelOption[] {
const map = model?.thinkingLevelMap;
if (!map || typeof map !== "object") return [OFF_THINKING_LEVEL];
const options = Object.keys(map)
.filter((level) => level.trim() && typeof map[level] === "string")
.map((level) => ({
value: level,
label: level === "off" ? "Off" : level[0].toUpperCase() + level.slice(1),
}));
return options.length ? options : [OFF_THINKING_LEVEL];
}
export function reconcileThinkingLevel(model: Model | undefined, level: string) {
const options = thinkingLevelOptions(model);
return options.some((option) => option.value === level)
? level
: options.find((option) => option.value === "off")?.value ?? options[0].value;
}
export function catalogModels(
models: Model[],
configured: ModelIdentity[],
@@ -60,7 +82,12 @@ export function catalogModels(
if (!identity.provider || !identity.modelId) continue;
const key = modelIdentityValue(identity);
if (keys.has(key)) continue;
available.unshift({ provider: identity.provider, id: identity.modelId });
available.unshift({
provider: identity.provider,
id: identity.modelId,
// Configured/local models are not catalog-confirmed. Do not imply thinking support.
thinkingLevelMap: { off: "off" },
});
keys.add(key);
}
return available;
+91 -2
View File
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
listen: vi.fn(),
onFocusChanged: vi.fn(),
startDragging: vi.fn(),
focusHandler: undefined as
| ((event: { payload: boolean }) => void)
| undefined,
@@ -28,7 +29,10 @@ const mocks = vi.hoisted(() => ({
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ onFocusChanged: mocks.onFocusChanged }),
getCurrentWindow: () => ({
onFocusChanged: mocks.onFocusChanged,
startDragging: mocks.startDragging,
}),
}));
import QuickPane from "./QuickPane";
@@ -212,6 +216,7 @@ describe("QuickPane", () => {
vi.restoreAllMocks();
vi.clearAllMocks();
mocks.focusHandler = undefined;
mocks.startDragging.mockResolvedValue(undefined);
mocks.listen.mockResolvedValue(vi.fn());
mocks.onFocusChanged.mockImplementation((handler) => {
mocks.focusHandler = handler;
@@ -227,12 +232,95 @@ describe("QuickPane", () => {
expect(
Array.from(pane?.children ?? []).map((element) => element.className),
).toEqual([
"quick-pane-drag-strip",
"quick-pane-toolbar",
"quick-pane-composer",
"quick-pane-exchange",
]);
});
it("starts native dragging only from the strip above toolbar controls", async () => {
bridgeMock([defaultModels]);
const { container } = render(<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" }));
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
});
it("derives thinking choices from selected model metadata and falls back to Off", async () => {
const thinkingModels: Model[] = [
{
provider: "provider",
id: "quick/model",
name: "Quick Model",
thinkingLevelMap: { off: "off", high: "high" },
},
];
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",
]);
await userEvent.selectOptions(thinking, "high");
await ask("Inspect");
await waitFor(() =>
expect(mocks.invoke).toHaveBeenCalledWith("set_thinking_level", {
agentId: "agent-1",
level: "high",
}),
);
});
it("keeps Abort active-only and Renew idle after an exchange", async () => {
let streaming = true;
const tracker = bridgeMock([defaultModels, defaultModels], () =>
runtimeSnapshot(defaultModels, "Partial answer", streaming),
);
render(<QuickPane />);
await preparedTrigger();
expect(screen.queryByRole("button", { name: "Abort" })).toBeNull();
expect(screen.queryByRole("button", { name: "Renew" })).toBeNull();
await ask("Inspect");
expect(await screen.findByRole("button", { name: "Abort" })).toBeEnabled();
await userEvent.click(screen.getByRole("button", { name: "Abort" }));
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(),
);
streaming = false;
await ask("Again");
expect(await screen.findByRole("button", { name: "Renew" })).toBeEnabled();
});
it("reports Abort failures without replacing active chat", async () => {
bridgeMock([defaultModels], () =>
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);
});
render(<QuickPane />);
await preparedTrigger();
await ask("Inspect");
await userEvent.click(await screen.findByRole("button", { name: "Abort" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not abort quick request: Error: abort unavailable",
);
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
runtimeId: "runtime-1",
});
expect(screen.getByRole("button", { name: "Abort" })).toBeEnabled();
});
it("keeps downward model menu inside the scroll-contained pane", async () => {
const user = userEvent.setup();
bridgeMock([defaultModels]);
@@ -371,6 +459,7 @@ describe("QuickPane", () => {
const continueButton = await screen.findByRole("button", {
name: "Continue in full session",
});
expect(continueButton.closest(".quick-pane-assistant-footer")).not.toBeNull();
expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_session_runtime",
expect.anything(),
@@ -448,7 +537,7 @@ describe("QuickPane", () => {
"YouFirst request",
"AssistantFirst answer",
"YouSecond request",
"AssistantSecond answer",
"AssistantSecond answerContinue in full session",
]);
});
+85 -19
View File
@@ -10,6 +10,8 @@ import {
modelIdentity,
modelIdentityValue,
reconcileModelIdentity,
reconcileThinkingLevel,
thinkingLevelOptions,
type ModelIdentity,
} from "../model-catalog";
import "./quick-pane.css";
@@ -176,6 +178,7 @@ export default function QuickPane() {
const [request, setRequest] = useState("");
const [submissionStarting, setSubmissionStarting] = useState(false);
const [requestActive, setRequestActive] = useState(false);
const [chatReplacing, setChatReplacing] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]);
const [catalogStatus, setCatalogStatus] = useState<
@@ -230,6 +233,19 @@ export default function QuickPane() {
const resizeFrameRef = useRef<number | undefined>(undefined);
const requestedPaneHeightRef = useRef<number | undefined>(undefined);
const invalid = useMemo(() => settingsError(settings), [settings]);
const selectedCatalogModel = useMemo(
() =>
models.find(
(model) =>
model.provider === selectedModel.provider &&
model.id === selectedModel.modelId,
),
[models, selectedModel],
);
const thinkingOptions = useMemo(
() => thinkingLevelOptions(selectedCatalogModel),
[selectedCatalogModel],
);
const runActive = submissionStarting || requestActive;
const activityPhase = submissionStarting
? "Starting"
@@ -245,6 +261,12 @@ export default function QuickPane() {
? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase;
useEffect(() => {
setThinkingLevel((current) =>
reconcileThinkingLevel(selectedCatalogModel, current),
);
}, [selectedCatalogModel]);
const focusInput = () => inputRef.current?.focus();
const closeRuntime = useCallback(async (runtimeId: string) => {
const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
@@ -448,6 +470,7 @@ export default function QuickPane() {
async (clearDraft: boolean, abortActive: boolean) => {
if (resetStartingRef.current) return false;
resetStartingRef.current = true;
setChatReplacing(true);
preparationGenerationRef.current += 1;
preparationLockRef.current = false;
catalogReadyRef.current = false;
@@ -456,9 +479,12 @@ export default function QuickPane() {
requestActiveRef.current || submissionStartingRef.current;
try {
if (abortActive && wasActive && current) {
await invoke("abort", { agentId: current.agentId }).catch(
() => undefined,
);
try {
await invoke("abort", { agentId: current.agentId });
} catch (reason) {
setError(`Could not abort quick request: ${String(reason)}`);
return false;
}
}
clearChatState(clearDraft);
if (current) {
@@ -472,6 +498,7 @@ export default function QuickPane() {
return await prepareRuntime(selectedModelRef.current, true);
} finally {
resetStartingRef.current = false;
setChatReplacing(false);
window.requestAnimationFrame(focusInput);
}
},
@@ -534,7 +561,7 @@ export default function QuickPane() {
const height = Math.min(
520,
Math.max(
360,
240,
Math.ceil(pane.clientHeight - exchange.clientHeight + contentHeight),
),
);
@@ -801,6 +828,13 @@ export default function QuickPane() {
const canContinue = !invalid && !!handoffRequest;
const completedAssistantAnswer =
responseComplete && messages.some((message) => message.role === "assistant");
const completedAssistantMessageId = completedAssistantAnswer
? [...messages].reverse().find((message) => message.role === "assistant")?.id
: undefined;
const hasExchange =
messages.length > 0 ||
responseComplete ||
submittedRequestRef.current !== undefined;
const transcriptHandoff = messages
.map((message) =>
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
@@ -834,6 +868,13 @@ export default function QuickPane() {
return (
<main className="quick-pane" ref={paneRef}>
<div
className="quick-pane-drag-strip"
aria-hidden="true"
onPointerDown={() =>
void getCurrentWindow().startDragging().catch(() => undefined)
}
/>
<header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls">
{catalogStatus === "loading" && (
@@ -855,6 +896,9 @@ export default function QuickPane() {
if (!identity) return false;
selectedModelRef.current = identity;
setSelectedModel(identity);
setThinkingLevel((current) =>
reconcileThinkingLevel(model, current),
);
return true;
}}
/>
@@ -866,12 +910,14 @@ export default function QuickPane() {
<select
aria-label="Thinking"
value={thinkingLevel}
disabled={runActive}
disabled={runActive || chatReplacing}
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
{thinkingOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
@@ -913,6 +959,28 @@ 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">
{requestActive && (
<button
className="quick-pane-action"
type="button"
disabled={submissionStarting || chatReplacing}
onClick={() => void replaceChat(false, true)}
>
{chatReplacing ? "Aborting…" : "Abort"}
</button>
)}
{!runActive && hasExchange && (
<button
className="quick-pane-action"
type="button"
disabled={submissionStarting || chatReplacing}
onClick={() => void replaceChat(false, false)}
>
{chatReplacing ? "Renewing…" : "Renew"}
</button>
)}
</div>
<button
className="quick-pane-send"
type="button"
@@ -968,6 +1036,15 @@ export default function QuickPane() {
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.markdown}
</ReactMarkdown>
{message.id === completedAssistantMessageId &&
!escalation &&
canContinue && (
<footer className="quick-pane-assistant-footer">
<button type="button" onClick={continueFull}>
Continue in full session
</button>
</footer>
)}
</article>
),
)}
@@ -993,17 +1070,6 @@ export default function QuickPane() {
>
{activityPhase}
</span>
{completedAssistantAnswer && !escalation && canContinue && (
<aside className="quick-pane-notice continue" role="status">
<div>
<strong>Continue in full session</strong>
<p>Open this chat in a normal session when more work is needed.</p>
</div>
<button type="button" onClick={continueFull}>
Continue in full session
</button>
</aside>
)}
{escalation && (
<aside className="quick-pane-notice escalation" role="status">
<div>
+48 -3
View File
@@ -46,6 +46,13 @@ body {
align-items: center;
}
.quick-pane-drag-strip {
height: 8px;
flex: 0 0 8px;
cursor: move;
touch-action: none;
}
.quick-pane-toolbar {
flex: 0 0 auto;
justify-content: space-between;
@@ -195,16 +202,19 @@ body {
flex: 1 1 auto;
flex-direction: column;
gap: 9px;
overflow-x: hidden;
overflow-y: auto;
overflow: hidden;
padding: 2px 3px 8px;
scroll-behavior: smooth;
}
.quick-pane-transcript {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 9px;
overflow-x: hidden;
overflow-y: auto;
}
.quick-pane-user-row {
@@ -266,6 +276,25 @@ body {
margin-bottom: 0;
}
.quick-pane-assistant-footer {
margin-top: 8px;
border-top: 1px solid #484848;
padding-top: 6px;
}
.quick-pane-assistant-footer button {
padding: 0;
color: #ffc170;
background: transparent;
font-size: 11px;
font-weight: 650;
}
.quick-pane-assistant-footer button:hover {
color: #fff0d8;
background: transparent;
}
.quick-pane-assistant-message p,
.quick-pane-assistant-message ul,
.quick-pane-assistant-message ol,
@@ -450,7 +479,7 @@ body {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 11px 50px 29px 11px;
padding: 11px 150px 29px 11px;
resize: none;
border: 1px solid #55514c;
border-radius: 8px;
@@ -465,6 +494,22 @@ body {
background: #272727;
}
.quick-pane-composer-actions {
position: absolute;
right: 50px;
bottom: 9px;
display: flex;
gap: 4px;
}
.quick-pane-action {
min-height: 30px;
padding: 4px 7px !important;
color: #e6e3df !important;
background: #45413c !important;
font-size: 10px !important;
}
.quick-pane-send {
position: absolute;
right: 8px;
+6 -1
View File
@@ -89,7 +89,12 @@ export type SessionStats = {
};
};
export type Command = { name?: string; description?: string };
export type Model = { provider?: string; id?: string; name?: string };
export type Model = {
provider?: string;
id?: string;
name?: string;
thinkingLevelMap?: Record<string, string>;
};
export type Extension = {
id?: string;
method?: string;