feat(quick-pane): promote chats into full sessions
This commit is contained in:
@@ -40,7 +40,7 @@ The app creates or focuses a dedicated Quick Pane window. `Esc` and **Close** hi
|
||||
|
||||
`Ctrl+N` starts a new chat: it aborts active work when needed, closes the old runtime with retryable cleanup, clears draft/transcript and submitted-prompt mappings, then prepares a fresh runtime and model catalog. Automatic stale renewal performs the same idle-chat replacement while preserving the current draft.
|
||||
|
||||
Quick Pane opens at 640×240 with a 520×240 minimum. Frontend content measurement requests native growth up to 520px tall after transcript updates; beyond that cap, transcript and model results scroll inside pane while outer document and pane remain clipped.
|
||||
Quick Pane opens at 640×360 with a 520×360 minimum. Frontend content measurement requests native growth up to 520px tall after transcript updates; beyond that cap, transcript and model results scroll inside pane while outer document and pane remain clipped.
|
||||
|
||||
## Escalation
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
- `Esc` and **Close** hide without cleanup. Reopening an idle chat at or under three minutes preserves runtime, draft/catalog, and transcript; reopening after more than three minutes renews it.
|
||||
- `Ctrl+N` prevents browser default, aborts active work when needed, retries queued cleanup, clears draft/transcript/raw-prompt mappings, and prepares a fresh runtime/catalog. Idle reset does not send an abort.
|
||||
- Stale runtimes created by superseded preparation are queued after cleanup failure and retried before later stale-chat renewal.
|
||||
- Quick Pane opens at 640×240 with a 520×240 native minimum. Content-driven native growth stops at 520px tall; transcript and model results scroll internally after that.
|
||||
- Quick Pane opens at 640×360 with a 520×360 native minimum. Content-driven native growth stops at 520px tall; transcript and model results scroll internally after that.
|
||||
- Settings-startup and model-catalog errors show **Retry**. Catalog retry cleans up failed runtime before replacement preparation; model selection and Send stay disabled until ready.
|
||||
- **Continue in full session** appears after first completed assistant answer with valid handoff settings, plus preserved request/runtime escalation notices. It never starts automatically. Handoff creates normal session with transcript-derived current-chat context, workspace, and follow-up model. Current answer extraction starts only after exact mapped submitted prompt in snapshot transcript; failed follow-ups with no current assistant message never reuse prior answer as partial findings.
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
7. Continue; verify normal session receives handoff and follow-up model.
|
||||
8. Close Quick Pane and reopen within three minutes; verify idle runtime, draft/catalog, and full transcript remain. Repeat after more than three idle minutes; verify fresh chat/runtime appears.
|
||||
9. Press `Ctrl+N` while idle and active; verify fresh empty chat in both cases and active work aborts before replacement.
|
||||
10. Verify pane opens at 640×240, grows only to 520px tall as transcript grows, then keeps transcript/model results scrolling internally.
|
||||
10. Verify pane opens at 640×360, grows only to 520px tall as transcript grows, then keeps transcript/model results scrolling internally.
|
||||
11. Induce settings-startup or model-catalog failure; verify visible **Retry**, disabled model/Send controls until recovery, and successful retry. Verify empty catalog stays blocked without unsupported actions.
|
||||
|
||||
## Exit criteria
|
||||
|
||||
@@ -242,9 +242,9 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
||||
},
|
||||
{
|
||||
width: 640,
|
||||
height: 240,
|
||||
height: 360,
|
||||
minWidth: 520,
|
||||
minHeight: 240,
|
||||
minHeight: 360,
|
||||
transparent: true,
|
||||
},
|
||||
);
|
||||
@@ -257,7 +257,7 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
||||
);
|
||||
assert.match(nativeSource, /get_or_create_quick_pane/);
|
||||
assert.match(nativeSource, /fn resize_quick_pane/);
|
||||
assert.match(nativeSource, /height\.clamp\(240\.0, 520\.0\)/);
|
||||
assert.match(nativeSource, /height\.clamp\(360\.0, 520\.0\)/);
|
||||
assert.match(source, /new ResizeObserver\(resizeToContent\)/);
|
||||
assert.match(source, /invoke\("resize_quick_pane", \{ height \}\)/);
|
||||
assert.doesNotMatch(source, /onCloseRequested/);
|
||||
@@ -267,7 +267,10 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
||||
assert.match(source, /className=\{`quick-pane-exchange/);
|
||||
assert.match(source, /onOpen=\{handleModelMenuOpen\}/);
|
||||
assert.match(source, /onClose=\{handleModelMenuClose\}/);
|
||||
assert.match(source, /requestPaneResize\(520\)/);
|
||||
assert.match(source, /requestPaneResize\(QUICK_PANE_MAX_HEIGHT\)/);
|
||||
assert.match(source, /const QUICK_PANE_MIN_HEIGHT = 360/);
|
||||
assert.match(source, /const QUICK_PANE_MAX_HEIGHT = 520/);
|
||||
assert.match(source, /height = clampQuickPaneHeight\(height\)/);
|
||||
assert.match(source, /if \(modelMenuOpenRef\.current\) return;/);
|
||||
assert.match(source, /submittedPromptTurnsRef/);
|
||||
assert.match(source, /function elapsedLabel/);
|
||||
@@ -324,6 +327,17 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
||||
stylesheet,
|
||||
/\.quick-pane-header-actions\s*\{[^}]*overflow:\s*(?:hidden|clip)/,
|
||||
);
|
||||
assert.match(
|
||||
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:/,
|
||||
);
|
||||
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, /controlClassName="quick-pane-toolbar-control"/);
|
||||
assert.match(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.doesNotMatch(stylesheet, /#8aa9d6|#46494f|#23252a/);
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ fn quick_pane_height(height: f64) -> Result<f64, String> {
|
||||
if !height.is_finite() {
|
||||
return Err("Quick Pane height must be finite".to_owned());
|
||||
}
|
||||
Ok(height.clamp(240.0, 520.0))
|
||||
Ok(height.clamp(360.0, 520.0))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -891,9 +891,9 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(quick_pane.width, 640.0);
|
||||
assert_eq!(quick_pane.height, 240.0);
|
||||
assert_eq!(quick_pane.height, 360.0);
|
||||
assert_eq!(quick_pane.min_width, Some(520.0));
|
||||
assert_eq!(quick_pane.min_height, Some(240.0));
|
||||
assert_eq!(quick_pane.min_height, Some(360.0));
|
||||
assert_eq!(quick_pane.url.to_string(), "index.html");
|
||||
assert!(!quick_pane.visible);
|
||||
assert!(quick_pane.transparent);
|
||||
@@ -903,7 +903,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn quick_pane_height_stays_within_content_limit() {
|
||||
assert_eq!(quick_pane_height(200.0), Ok(240.0));
|
||||
assert_eq!(quick_pane_height(200.0), Ok(360.0));
|
||||
assert_eq!(quick_pane_height(420.0), Ok(420.0));
|
||||
assert_eq!(quick_pane_height(900.0), Ok(520.0));
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
"label": "quick-pane",
|
||||
"title": "Pi Quick Pane",
|
||||
"width": 640,
|
||||
"height": 240,
|
||||
"height": 360,
|
||||
"minWidth": 520,
|
||||
"minHeight": 240,
|
||||
"minHeight": 360,
|
||||
"center": true,
|
||||
"visible": false,
|
||||
"decorations": false,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
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)}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user