feat(quick-pane): promote chats into full sessions
This commit is contained in:
@@ -13,6 +13,14 @@ test("Quick Pane polls snapshots, renders answers, and promotes runtime without
|
|||||||
assert.match(source, /className="quick-pane-notice escalation"/);
|
assert.match(source, /className="quick-pane-notice escalation"/);
|
||||||
assert.match(source, /className="quick-pane-assistant-footer"/);
|
assert.match(source, /className="quick-pane-assistant-footer"/);
|
||||||
assert.match(source, /Continue in full session/);
|
assert.match(source, /Continue in full session/);
|
||||||
|
const toolbar = source.slice(
|
||||||
|
source.indexOf('<header className="quick-pane-toolbar">'),
|
||||||
|
source.indexOf(
|
||||||
|
"</header>",
|
||||||
|
source.indexOf('<header className="quick-pane-toolbar">'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(toolbar, /continueFull|Continue/);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/await invoke<\{ runtime: Runtime \}>\("promote_quick_runtime", \{\s+runtimeId: current\.runtimeId,/,
|
/await invoke<\{ runtime: Runtime \}>\("promote_quick_runtime", \{\s+runtimeId: current\.runtimeId,/,
|
||||||
@@ -174,15 +182,24 @@ test("Quick Pane dismiss and mutually exclusive window visibility stay native-se
|
|||||||
test("native promotion owns visibility and Main consumes pending runtime after startup refresh", async () => {
|
test("native promotion owns visibility and Main consumes pending runtime after startup refresh", async () => {
|
||||||
const [nativeSource, workspaceSource] = await Promise.all([
|
const [nativeSource, workspaceSource] = await Promise.all([
|
||||||
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
|
readFile(new URL("../ui/src-tauri/src/lib.rs", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../ui/src/workspace/useWorkspace.ts", import.meta.url), "utf8"),
|
readFile(
|
||||||
|
new URL("../ui/src/workspace/useWorkspace.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
const promotion = nativeSource.slice(
|
const promotion = nativeSource.slice(
|
||||||
nativeSource.indexOf("fn show_main_for_promoted_runtime"),
|
nativeSource.indexOf("fn show_main_for_promoted_runtime"),
|
||||||
nativeSource.indexOf("fn toggle_quick_pane"),
|
nativeSource.indexOf("fn toggle_quick_pane"),
|
||||||
);
|
);
|
||||||
assert.match(promotion, /get_or_create_main_with_visibility\(app, false\)/);
|
assert.match(promotion, /get_or_create_main_with_visibility\(app, false\)/);
|
||||||
assert.ok(promotion.indexOf("quick_pane\n .hide()") < promotion.indexOf("window\n .show()"));
|
assert.ok(
|
||||||
assert.ok(promotion.indexOf('visibility.hidden("quick-pane")') < promotion.indexOf("window\n .show()"));
|
promotion.indexOf("quick_pane\n .hide()") <
|
||||||
|
promotion.indexOf("window\n .show()"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
promotion.indexOf('visibility.hidden("quick-pane")') <
|
||||||
|
promotion.indexOf("window\n .show()"),
|
||||||
|
);
|
||||||
assert.match(promotion, /\*pending = Some\(runtime_id\)/);
|
assert.match(promotion, /\*pending = Some\(runtime_id\)/);
|
||||||
assert.match(nativeSource, /fn get_pending_promoted_runtime/);
|
assert.match(nativeSource, /fn get_pending_promoted_runtime/);
|
||||||
assert.match(nativeSource, /fn consume_pending_promoted_runtime/);
|
assert.match(nativeSource, /fn consume_pending_promoted_runtime/);
|
||||||
@@ -329,15 +346,38 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
stylesheet,
|
stylesheet,
|
||||||
/\.quick-pane-toolbar-control\s*\{[\s\S]*?height:\s*27px[\s\S]*?padding:\s*0 8px[\s\S]*?border:[\s\S]*?border-radius:[\s\S]*?background:[\s\S]*?font:/,
|
/\.quick-pane button\.quick-pane-toolbar-control,[\s\S]*?height:\s*27px[\s\S]*?min-width:\s*48px[\s\S]*?padding:\s*0 8px[\s\S]*?border:[\s\S]*?border-radius:[\s\S]*?background:[\s\S]*?font:/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
stylesheet,
|
||||||
|
/\.quick-pane \.quick-pane-toolbar-control:hover:not\(:disabled\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
stylesheet,
|
||||||
|
/\.quick-pane \.quick-pane-toolbar-control:focus-visible/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
stylesheet,
|
||||||
|
/\.quick-pane \.quick-pane-toolbar-control:disabled/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
stylesheet,
|
||||||
|
/\.quick-pane-thinking\s*\{[\s\S]*?flex:\s*0 0 143px[\s\S]*?grid-template-columns:\s*auto minmax\(84px, 1fr\)/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
stylesheet,
|
||||||
|
/\.quick-pane-toolbar\s*\{[\s\S]*?flex-wrap:\s*nowrap/,
|
||||||
);
|
);
|
||||||
assert.match(stylesheet, /\.quick-pane-toolbar-control:hover:not\(:disabled\)/);
|
|
||||||
assert.match(stylesheet, /\.quick-pane-toolbar-control:focus-visible/);
|
|
||||||
assert.match(stylesheet, /\.quick-pane-toolbar-control:disabled/);
|
|
||||||
assert.match(source, /triggerClassName="quick-pane-toolbar-control"/);
|
assert.match(source, /triggerClassName="quick-pane-toolbar-control"/);
|
||||||
assert.match(source, /controlClassName="quick-pane-toolbar-control"/);
|
assert.match(source, /controlClassName="quick-pane-toolbar-control"/);
|
||||||
assert.match(source, /className="quick-pane-toolbar-control quick-pane-action"/);
|
assert.match(
|
||||||
assert.match(source, /className="quick-pane-toolbar-control quick-pane-close"/);
|
source,
|
||||||
|
/className="quick-pane-toolbar-control quick-pane-action"/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/className="quick-pane-toolbar-control quick-pane-close"/,
|
||||||
|
);
|
||||||
assert.match(stylesheet, /#f0a347/);
|
assert.match(stylesheet, /#f0a347/);
|
||||||
assert.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/);
|
assert.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/);
|
||||||
|
|
||||||
|
|||||||
@@ -3,35 +3,62 @@ import { expect, it, vi } from "vitest";
|
|||||||
import { ThinkingSelector } from "./ThinkingSelector";
|
import { ThinkingSelector } from "./ThinkingSelector";
|
||||||
|
|
||||||
it("uses selected model thinking capabilities and reconciles unavailable values", () => {
|
it("uses selected model thinking capabilities and reconciles unavailable values", () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ThinkingSelector
|
<ThinkingSelector
|
||||||
model={{
|
model={{
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
thinkingLevelMap: {
|
thinkingLevelMap: {
|
||||||
minimal: null,
|
minimal: null,
|
||||||
low: null,
|
low: null,
|
||||||
medium: null,
|
medium: null,
|
||||||
high: "high",
|
high: "high",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
value="medium"
|
value="medium"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const selector = screen.getByRole("combobox", { name: "Thinking" });
|
const selector = screen.getByRole("combobox", { name: "Thinking" });
|
||||||
expect(selector).toHaveValue("off");
|
expect(selector).toHaveValue("off");
|
||||||
expect(
|
expect(
|
||||||
within(selector)
|
within(selector)
|
||||||
.getAllByRole("option")
|
.getAllByRole("option")
|
||||||
.map((option) => option.textContent),
|
.map((option) => option.textContent),
|
||||||
).toEqual(["Off", "High"]);
|
).toEqual(["Off", "High"]);
|
||||||
fireEvent.change(selector, { target: { value: "high" } });
|
fireEvent.change(selector, { target: { value: "high" } });
|
||||||
expect(onChange).toHaveBeenCalledWith("high");
|
expect(onChange).toHaveBeenCalledWith("high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps complete native labels available through XHigh", () => {
|
||||||
|
render(
|
||||||
|
<ThinkingSelector
|
||||||
|
model={{
|
||||||
|
reasoning: true,
|
||||||
|
thinkingLevelMap: {
|
||||||
|
minimal: "minimal",
|
||||||
|
low: "low",
|
||||||
|
medium: "medium",
|
||||||
|
high: "high",
|
||||||
|
xhigh: "xhigh",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value="medium"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("combobox", { name: "Thinking" }))
|
||||||
|
.getAllByRole("option")
|
||||||
|
.map((option) => option.textContent),
|
||||||
|
).toEqual(["Off", "Minimal", "Low", "Medium", "High", "XHigh"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses disabled native select semantics", () => {
|
it("uses disabled native select semantics", () => {
|
||||||
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
|
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
|
||||||
expect(screen.getByRole("combobox", { name: "Thinking" })).toBeDisabled();
|
expect(
|
||||||
|
screen.getByRole("combobox", { name: "Thinking" }),
|
||||||
|
).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -239,19 +239,43 @@ describe("QuickPane", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies shared toolbar-control classes to compact controls", async () => {
|
it("keeps full thinking labels and promotion out of the 640px toolbar", async () => {
|
||||||
bridgeMock([defaultModels]);
|
bridgeMock([defaultModels]);
|
||||||
render(<QuickPane />);
|
const { container } = render(<QuickPane />);
|
||||||
expect(await preparedTrigger()).toHaveClass("quick-pane-toolbar-control");
|
const model = await preparedTrigger();
|
||||||
expect(screen.getByRole("combobox", { name: "Thinking" })).toHaveClass(
|
const thinking = screen.getByRole("combobox", { name: "Thinking" });
|
||||||
"quick-pane-toolbar-control",
|
const close = screen.getByRole("button", { name: "Close Quick Pane" });
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", { name: "Continue" })).toHaveClass(
|
for (const control of [model, thinking, close])
|
||||||
"quick-pane-toolbar-control",
|
expect(control).toHaveClass("quick-pane-toolbar-control");
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
|
within(thinking)
|
||||||
|
.getAllByRole("option")
|
||||||
|
.map((option) => option.textContent),
|
||||||
|
).toEqual(["Off", "Minimal", "Low", "Medium", "High"]);
|
||||||
|
expect(
|
||||||
|
within(container.querySelector(".quick-pane-toolbar")!).queryByRole(
|
||||||
|
"button",
|
||||||
|
{ name: "Continue" },
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders one toolbar control set at 640px and 520px screenshot widths", async () => {
|
||||||
|
bridgeMock([defaultModels]);
|
||||||
|
const { container } = render(<QuickPane />);
|
||||||
|
const pane = container.querySelector<HTMLElement>(".quick-pane")!;
|
||||||
|
const toolbar = container.querySelector(".quick-pane-toolbar")!;
|
||||||
|
const controls = [
|
||||||
|
await preparedTrigger(),
|
||||||
|
screen.getByRole("combobox", { name: "Thinking" }),
|
||||||
screen.getByRole("button", { name: "Close Quick Pane" }),
|
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||||
).toHaveClass("quick-pane-toolbar-control");
|
];
|
||||||
|
|
||||||
|
for (const width of [640, 520]) {
|
||||||
|
pane.style.width = `${width}px`;
|
||||||
|
for (const control of controls) expect(toolbar).toContainElement(control);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts native dragging only from the strip above toolbar controls", async () => {
|
it("starts native dragging only from the strip above toolbar controls", async () => {
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ const QUICK_PANE_MIN_HEIGHT = 360;
|
|||||||
const QUICK_PANE_MAX_HEIGHT = 520;
|
const QUICK_PANE_MAX_HEIGHT = 520;
|
||||||
|
|
||||||
function clampQuickPaneHeight(height: number) {
|
function clampQuickPaneHeight(height: number) {
|
||||||
return Math.min(QUICK_PANE_MAX_HEIGHT, Math.max(QUICK_PANE_MIN_HEIGHT, height));
|
return Math.min(
|
||||||
|
QUICK_PANE_MAX_HEIGHT,
|
||||||
|
Math.max(QUICK_PANE_MIN_HEIGHT, height),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageText(message: Message) {
|
function messageText(message: Message) {
|
||||||
@@ -1066,7 +1069,8 @@ export default function QuickPane() {
|
|||||||
|
|
||||||
async function continueFull() {
|
async function continueFull() {
|
||||||
const current = runtimeRef.current;
|
const current = runtimeRef.current;
|
||||||
if (invalid || !current) return setError(invalid ?? "Quick runtime is unavailable.");
|
if (invalid || !current)
|
||||||
|
return setError(invalid ?? "Quick runtime is unavailable.");
|
||||||
setPromoting(true);
|
setPromoting(true);
|
||||||
try {
|
try {
|
||||||
await invoke<{ runtime: Runtime }>("promote_quick_runtime", {
|
await invoke<{ runtime: Runtime }>("promote_quick_runtime", {
|
||||||
@@ -1170,16 +1174,6 @@ export default function QuickPane() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="quick-pane-header-actions">
|
<div className="quick-pane-header-actions">
|
||||||
{runtime && (
|
|
||||||
<button
|
|
||||||
className="quick-pane-toolbar-control quick-pane-action"
|
|
||||||
type="button"
|
|
||||||
disabled={catalogOverlayActive || promoting}
|
|
||||||
onClick={continueFull}
|
|
||||||
>
|
|
||||||
{promoting ? "Continuing…" : "Continue"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="quick-pane-toolbar-control quick-pane-close"
|
className="quick-pane-toolbar-control quick-pane-close"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ body {
|
|||||||
|
|
||||||
.quick-pane-toolbar {
|
.quick-pane-toolbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: nowrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -88,6 +89,7 @@ body {
|
|||||||
|
|
||||||
.quick-pane-primary-controls {
|
.quick-pane-primary-controls {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-header-actions {
|
.quick-pane-header-actions {
|
||||||
@@ -106,6 +108,7 @@ body {
|
|||||||
|
|
||||||
.quick-pane-model-selector {
|
.quick-pane-model-selector {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
color: #bbb7b0;
|
color: #bbb7b0;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
@@ -180,7 +183,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-thinking {
|
.quick-pane-thinking {
|
||||||
flex: 0 1 122px;
|
display: grid;
|
||||||
|
flex: 0 0 143px;
|
||||||
|
grid-template-columns: auto minmax(84px, 1fr);
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane .compact-label {
|
.quick-pane .compact-label {
|
||||||
@@ -230,36 +236,46 @@ body {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-toolbar-control {
|
.quick-pane button.quick-pane-toolbar-control,
|
||||||
|
.quick-pane select.quick-pane-toolbar-control,
|
||||||
|
.quick-pane .model-trigger.quick-pane-toolbar-control,
|
||||||
|
.quick-pane
|
||||||
|
.thinking-selector.thinking-selector
|
||||||
|
select.quick-pane-toolbar-control {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 27px;
|
height: 27px;
|
||||||
min-width: 0;
|
min-width: 48px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
border: 1px solid #70522f;
|
border: 1px solid #70522f;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
color: #e6e3df;
|
color: #e6e3df;
|
||||||
background: #353535;
|
background: #353535;
|
||||||
font: 500 11px / 1 Inter, ui-sans-serif, system-ui, sans-serif;
|
font:
|
||||||
|
650 11px / 1 Inter,
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane .model-trigger.quick-pane-toolbar-control,
|
.quick-pane .model-trigger.quick-pane-toolbar-control,
|
||||||
.quick-pane .thinking-selector.thinking-selector select.quick-pane-toolbar-control {
|
.quick-pane
|
||||||
|
.thinking-selector.thinking-selector
|
||||||
|
select.quick-pane-toolbar-control {
|
||||||
appearance: auto;
|
appearance: auto;
|
||||||
background: #353535;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-toolbar-control:hover:not(:disabled) {
|
.quick-pane .quick-pane-toolbar-control:hover:not(:disabled) {
|
||||||
border-color: #d88735;
|
border-color: #d88735;
|
||||||
background: #44382d;
|
background: #44382d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-toolbar-control:focus-visible {
|
.quick-pane .quick-pane-toolbar-control:focus-visible {
|
||||||
outline: 3px solid #f0a347;
|
outline: 3px solid #f0a347;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-toolbar-control:disabled {
|
.quick-pane .quick-pane-toolbar-control:disabled {
|
||||||
opacity: 0.58;
|
opacity: 0.58;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -598,7 +614,6 @@ body {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.quick-pane-send {
|
.quick-pane-send {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user