feat(quick-pane): promote chats into full sessions

This commit is contained in:
2026-08-28 13:43:49 +02:00
parent b44b1be4b7
commit 0eb163b6c4
11 changed files with 149 additions and 95 deletions
+3 -1
View File
@@ -92,6 +92,7 @@ export function ModelSelector({
label = "Model",
placement = "up",
className = "",
triggerClassName = "",
onOpen,
onClose,
}: {
@@ -103,6 +104,7 @@ export function ModelSelector({
label?: string;
placement?: "up" | "down";
className?: string;
triggerClassName?: string;
onOpen?: () => void;
onClose?: () => void;
}) {
@@ -209,7 +211,7 @@ export function ModelSelector({
<button
ref={triggerRef}
type="button"
className="model-trigger"
className={["model-trigger", triggerClassName].filter(Boolean).join(" ")}
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={open ? menuId : undefined}
+27 -27
View File
@@ -3,35 +3,35 @@ import { expect, it, vi } from "vitest";
import { ThinkingSelector } from "./ThinkingSelector";
it("uses selected model thinking capabilities and reconciles unavailable values", () => {
const onChange = vi.fn();
render(
<ThinkingSelector
model={{
reasoning: true,
thinkingLevelMap: {
minimal: null,
low: null,
medium: null,
high: "high",
},
}}
value="medium"
onChange={onChange}
/>,
);
const onChange = vi.fn();
render(
<ThinkingSelector
model={{
reasoning: true,
thinkingLevelMap: {
minimal: null,
low: null,
medium: null,
high: "high",
},
}}
value="medium"
onChange={onChange}
/>,
);
const selector = screen.getByRole("combobox", { name: "Thinking" });
expect(selector).toHaveValue("off");
expect(
within(selector)
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["Off", "High"]);
fireEvent.change(selector, { target: { value: "high" } });
expect(onChange).toHaveBeenCalledWith("high");
const selector = screen.getByRole("combobox", { name: "Thinking" });
expect(selector).toHaveValue("off");
expect(
within(selector)
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["Off", "High"]);
fireEvent.change(selector, { target: { value: "high" } });
expect(onChange).toHaveBeenCalledWith("high");
});
it("uses disabled native select semantics", () => {
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
expect(screen.getByRole("combobox", { name: "Thinking" })).toBeDisabled();
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
expect(screen.getByRole("combobox", { name: "Thinking" })).toBeDisabled();
});
+3
View File
@@ -7,6 +7,7 @@ type ThinkingSelectorProps = {
value: string;
disabled?: boolean;
className?: string;
controlClassName?: string;
onChange: (level: string) => void;
};
@@ -15,6 +16,7 @@ export function ThinkingSelector({
value,
disabled = false,
className,
controlClassName,
onChange,
}: ThinkingSelectorProps) {
const selected = reconcileThinkingLevel(model, value);
@@ -24,6 +26,7 @@ export function ThinkingSelector({
<label className={classes}>
<span>Thinking</span>
<select
className={controlClassName}
aria-label="Thinking"
value={selected}
disabled={disabled}
+30 -4
View File
@@ -239,6 +239,21 @@ describe("QuickPane", () => {
]);
});
it("applies shared toolbar-control classes to compact controls", async () => {
bridgeMock([defaultModels]);
render(<QuickPane />);
expect(await preparedTrigger()).toHaveClass("quick-pane-toolbar-control");
expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveClass(
"quick-pane-toolbar-control",
);
expect(screen.getByRole("button", { name: "Continue" })).toHaveClass(
"quick-pane-toolbar-control",
);
expect(
screen.getByRole("button", { name: "Close Quick Pane" }),
).toHaveClass("quick-pane-toolbar-control");
});
it("starts native dragging only from the strip above toolbar controls", async () => {
bridgeMock([defaultModels]);
const { container } = render(<QuickPane />);
@@ -390,7 +405,12 @@ describe("QuickPane", () => {
id: "quick/model",
name: "Quick Model",
reasoning: true,
thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high" },
thinkingLevelMap: {
minimal: null,
low: null,
medium: null,
high: "high",
},
},
];
bridgeMock([thinkingModels], () =>
@@ -424,7 +444,10 @@ describe("QuickPane", () => {
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();
expect(await screen.findByRole("button", { name: "Abort" })).toHaveClass(
"quick-pane-toolbar-control",
);
expect(screen.getByRole("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" });
@@ -436,7 +459,10 @@ describe("QuickPane", () => {
streaming = false;
await ask("Again");
expect(await screen.findByRole("button", { name: "Renew" })).toBeEnabled();
expect(await screen.findByRole("button", { name: "Renew" })).toHaveClass(
"quick-pane-toolbar-control",
);
expect(screen.getByRole("button", { name: "Renew" })).toBeEnabled();
});
it("reports Abort failures without replacing active chat", async () => {
@@ -510,7 +536,7 @@ describe("QuickPane", () => {
expect(resizeCalls.length).toBeGreaterThan(resizeCallCountBeforeClose);
expect(resizeCalls[resizeCalls.length - 1]).toEqual([
"resize_quick_pane",
{ height: 240 },
{ height: 360 },
]);
});
+18 -16
View File
@@ -71,6 +71,12 @@ type TextSelection = {
};
const CHAT_RENEWAL_MS = 180_000;
const QUICK_PANE_MIN_HEIGHT = 360;
const QUICK_PANE_MAX_HEIGHT = 520;
function clampQuickPaneHeight(height: number) {
return Math.min(QUICK_PANE_MAX_HEIGHT, Math.max(QUICK_PANE_MIN_HEIGHT, height));
}
function messageText(message: Message) {
if (typeof message.content === "string") return message.content;
@@ -391,6 +397,7 @@ export default function QuickPane() {
if (intent !== "pointer") scheduleInputSelectionRestore();
};
const requestPaneResize = useCallback((height: number) => {
height = clampQuickPaneHeight(height);
if (requestedPaneHeightRef.current !== height)
resizeRetryCountRef.current = 0;
requestedPaneHeightRef.current = height;
@@ -420,7 +427,7 @@ export default function QuickPane() {
}
if (
!mountedRef.current ||
(modelMenuOpenRef.current && height !== 520) ||
(modelMenuOpenRef.current && height !== QUICK_PANE_MAX_HEIGHT) ||
resizeRetryCountRef.current >= 1
)
return;
@@ -430,7 +437,7 @@ export default function QuickPane() {
if (
mountedRef.current &&
requestedPaneHeightRef.current === height &&
(!modelMenuOpenRef.current || height === 520)
(!modelMenuOpenRef.current || height === QUICK_PANE_MAX_HEIGHT)
)
requestPaneResizeRef.current?.(height);
}, 250);
@@ -441,7 +448,7 @@ export default function QuickPane() {
const handleModelMenuOpen = useCallback(() => {
modelMenuOpenRef.current = true;
setModelMenuOpen(true);
requestPaneResize(520);
requestPaneResize(QUICK_PANE_MAX_HEIGHT);
}, [requestPaneResize]);
const handleModelMenuClose = useCallback(() => {
modelMenuOpenRef.current = false;
@@ -750,16 +757,9 @@ export default function QuickPane() {
(Number.parseFloat(styles.rowGap || styles.gap || "0") || 0) +
(Number.parseFloat(styles.paddingTop) || 0) +
(Number.parseFloat(styles.paddingBottom) || 0);
const height = Math.min(
520,
Math.max(
240,
Math.ceil(
pane.clientHeight - exchange.clientHeight + contentHeight,
),
),
requestPaneResize(
pane.clientHeight - exchange.clientHeight + contentHeight,
);
requestPaneResize(height);
});
};
if (typeof ResizeObserver === "undefined") {
@@ -1137,6 +1137,7 @@ export default function QuickPane() {
<div className="quick-pane-primary-controls">
<ModelSelector
className="quick-pane-model-selector"
triggerClassName="quick-pane-toolbar-control"
placement="down"
models={models}
current={{
@@ -1161,6 +1162,7 @@ export default function QuickPane() {
/>
<ThinkingSelector
className="quick-pane-thinking"
controlClassName="quick-pane-toolbar-control"
model={selectedCatalogModel}
value={thinkingLevel}
disabled={catalogOverlayActive || runActive || chatReplacing}
@@ -1170,7 +1172,7 @@ export default function QuickPane() {
<div className="quick-pane-header-actions">
{runtime && (
<button
className="quick-pane-action"
className="quick-pane-toolbar-control quick-pane-action"
type="button"
disabled={catalogOverlayActive || promoting}
onClick={continueFull}
@@ -1179,7 +1181,7 @@ export default function QuickPane() {
</button>
)}
<button
className="quick-pane-close"
className="quick-pane-toolbar-control quick-pane-close"
type="button"
aria-label="Close Quick Pane"
title="Close Quick Pane (Esc)"
@@ -1236,7 +1238,7 @@ export default function QuickPane() {
>
{requestActive && (
<button
className="quick-pane-action"
className="quick-pane-toolbar-control quick-pane-action"
type="button"
disabled={submissionStarting || chatReplacing}
onClick={() => void replaceChat(false, true)}
@@ -1246,7 +1248,7 @@ export default function QuickPane() {
)}
{!runActive && hasExchange && (
<button
className="quick-pane-action"
className="quick-pane-toolbar-control quick-pane-action"
type="button"
disabled={submissionStarting || chatReplacing}
onClick={() => void replaceChat(false, false)}
+41 -34
View File
@@ -87,11 +87,11 @@ body {
}
.quick-pane-primary-controls {
flex: 1 1 auto;
flex: 1 1 0;
}
.quick-pane-header-actions {
flex: 0 1 auto;
flex: 0 0 auto;
justify-content: end;
}
@@ -105,8 +105,7 @@ body {
}
.quick-pane-model-selector {
flex: 1 1 230px;
max-width: 310px;
flex: 1 1 0;
color: #bbb7b0;
font-weight: 750;
letter-spacing: 0.04em;
@@ -150,16 +149,6 @@ body {
}
}
.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));
@@ -236,16 +225,48 @@ body {
}
.quick-pane button:focus-visible,
.quick-pane select:focus-visible,
.quick-pane-request:focus {
.quick-pane select:focus-visible {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
.quick-pane-close {
color: #e6e3df !important;
background: #45413c !important;
font-size: 11px !important;
.quick-pane-toolbar-control {
box-sizing: border-box;
height: 27px;
min-width: 0;
padding: 0 8px;
border: 1px solid #70522f;
border-radius: 5px;
color: #e6e3df;
background: #353535;
font: 500 11px / 1 Inter, ui-sans-serif, system-ui, sans-serif;
cursor: pointer;
}
.quick-pane .model-trigger.quick-pane-toolbar-control,
.quick-pane .thinking-selector.thinking-selector select.quick-pane-toolbar-control {
appearance: auto;
background: #353535;
}
.quick-pane-toolbar-control:hover:not(:disabled) {
border-color: #d88735;
background: #44382d;
}
.quick-pane-toolbar-control:focus-visible {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
.quick-pane-toolbar-control:disabled {
opacity: 0.58;
cursor: not-allowed;
}
.quick-pane-request:focus {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
.quick-pane-exchange {
@@ -577,13 +598,6 @@ body {
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;
@@ -643,13 +657,6 @@ body {
font-size: 10px;
}
.quick-pane-close {
width: 27px;
height: 27px;
padding: 0 !important;
font-size: 18px !important;
}
.quick-pane-user-message {
max-width: 88%;
}