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
+1 -1
View File
@@ -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. `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×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. 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.
## Escalation ## Escalation
+2 -2
View File
@@ -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. - `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. - `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. - Stale runtimes created by superseded preparation are queued after cleanup failure and retried before later stale-chat renewal.
- 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. - 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.
- 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. - 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. - **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. 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. 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. 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×360, grows only to 520px tall as transcript grows, then keeps transcript/model results scrolling internally. 10. Verify pane opens at 640×240, 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. 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 ## Exit criteria
+16 -5
View File
@@ -11,7 +11,7 @@ test("Quick Pane polls snapshots, renders answers, and gates manual transcript h
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/); assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
assert.match(source, /<ReactMarkdown remarkPlugins=\{\[remarkGfm\]\}>/); assert.match(source, /<ReactMarkdown remarkPlugins=\{\[remarkGfm\]\}>/);
assert.match(source, /className="quick-pane-notice escalation"/); assert.match(source, /className="quick-pane-notice escalation"/);
assert.match(source, /className="quick-pane-notice continue"/); assert.match(source, /className="quick-pane-assistant-footer"/);
assert.match(source, /Continue in full session/); assert.match(source, /Continue in full session/);
assert.match(source, /Quick Pane transcript:/); assert.match(source, /Quick Pane transcript:/);
assert.doesNotMatch(source, /maximumSeconds|Quick time budget/); assert.doesNotMatch(source, /maximumSeconds|Quick time budget/);
@@ -212,9 +212,9 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
}, },
{ {
width: 640, width: 640,
height: 360, height: 240,
minWidth: 520, minWidth: 520,
minHeight: 360, minHeight: 240,
transparent: true, transparent: true,
}, },
); );
@@ -227,13 +227,18 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
); );
assert.match(nativeSource, /get_or_create_quick_pane/); assert.match(nativeSource, /get_or_create_quick_pane/);
assert.match(nativeSource, /fn resize_quick_pane/); assert.match(nativeSource, /fn resize_quick_pane/);
assert.match(nativeSource, /height\.clamp\(360\.0, 520\.0\)/); assert.match(nativeSource, /height\.clamp\(240\.0, 520\.0\)/);
assert.match(source, /new ResizeObserver\(resizeToContent\)/); assert.match(source, /new ResizeObserver\(resizeToContent\)/);
assert.match(source, /invoke\("resize_quick_pane", \{ height \}\)/); assert.match(source, /invoke\("resize_quick_pane", \{ height \}\)/);
assert.doesNotMatch(source, /onCloseRequested/); assert.doesNotMatch(source, /onCloseRequested/);
assert.match(source, /className="quick-pane-drag-strip"/);
assert.match(source, /startDragging\(\)/);
assert.match(source, /className="quick-pane-request"/); assert.match(source, /className="quick-pane-request"/);
assert.match(source, /className="quick-pane-exchange"/); assert.match(source, /className="quick-pane-exchange"/);
assert.match(source, /className="quick-pane-composer"/); assert.match(source, /className="quick-pane-composer"/);
assert.match(source, /className="quick-pane-composer-actions"/);
assert.match(source, /replaceChat\(false, true\)/);
assert.match(source, /replaceChat\(false, false\)/);
assert.match(source, /className="quick-pane-send"/); assert.match(source, /className="quick-pane-send"/);
assert.match(source, /ref=\{inputRef\}/); assert.match(source, /ref=\{inputRef\}/);
assert.match(source, /focusInput\(\)/); assert.match(source, /focusInput\(\)/);
@@ -245,12 +250,18 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/); assert.match(source, /event\.preventDefault\(\);\s*void run\(\);/);
assert.match( assert.match(
stylesheet, stylesheet,
/\.quick-pane-exchange[\s\S]*?flex: 1 1 auto[\s\S]*?overflow-y: auto/, /\.quick-pane-exchange[\s\S]*?flex: 1 1 auto[\s\S]*?overflow: hidden/,
);
assert.match(
stylesheet,
/\.quick-pane-transcript[\s\S]*?flex: 1 1 auto[\s\S]*?overflow-y: auto/,
); );
assert.match( assert.match(
stylesheet, stylesheet,
/\.quick-pane-composer[\s\S]*?height: 120px[\s\S]*?min-height: 110px[\s\S]*?max-height: 130px/, /\.quick-pane-composer[\s\S]*?height: 120px[\s\S]*?min-height: 110px[\s\S]*?max-height: 130px/,
); );
assert.match(stylesheet, /\.quick-pane-drag-strip[\s\S]*?height: 8px/);
assert.match(stylesheet, /\.quick-pane-composer-actions[\s\S]*?position: absolute/);
assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/); assert.match(stylesheet, /\.quick-pane-send[\s\S]*?position: absolute/);
assert.match(stylesheet, /@media \(max-width: 400px\)/); assert.match(stylesheet, /@media \(max-width: 400px\)/);
assert.match(stylesheet, /html,[\s\S]*?#root\s*\{[\s\S]*?overflow: hidden/); assert.match(stylesheet, /html,[\s\S]*?#root\s*\{[\s\S]*?overflow: hidden/);
+4 -4
View File
@@ -284,7 +284,7 @@ async fn get_model_catalog() -> Result<Value, String> {
} }
fn quick_pane_height(height: f64) -> f64 { fn quick_pane_height(height: f64) -> f64 {
height.clamp(360.0, 520.0) height.clamp(240.0, 520.0)
} }
#[tauri::command] #[tauri::command]
@@ -790,9 +790,9 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(quick_pane.width, 640.0); 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_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_eq!(quick_pane.url.to_string(), "index.html");
assert!(!quick_pane.visible); assert!(!quick_pane.visible);
assert!(quick_pane.transparent); assert!(quick_pane.transparent);
@@ -802,7 +802,7 @@ mod tests {
#[test] #[test]
fn quick_pane_height_stays_within_content_limit() { 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(420.0), 420.0);
assert_eq!(quick_pane_height(900.0), 520.0); assert_eq!(quick_pane_height(900.0), 520.0);
} }
+2 -2
View File
@@ -29,9 +29,9 @@
"label": "quick-pane", "label": "quick-pane",
"title": "Pi Quick Pane", "title": "Pi Quick Pane",
"width": 640, "width": 640,
"height": 360, "height": 240,
"minWidth": 520, "minWidth": 520,
"minHeight": 360, "minHeight": 240,
"center": true, "center": true,
"visible": false, "visible": false,
"decorations": 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"; import type { Model } from "./workspace/types";
export type ModelIdentity = { provider: string; modelId: string }; 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 { export function modelIdentity(model: Model): ModelIdentity | undefined {
if (!model.provider || !model.id) return 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( export function catalogModels(
models: Model[], models: Model[],
configured: ModelIdentity[], configured: ModelIdentity[],
@@ -60,7 +82,12 @@ export function catalogModels(
if (!identity.provider || !identity.modelId) continue; if (!identity.provider || !identity.modelId) continue;
const key = modelIdentityValue(identity); const key = modelIdentityValue(identity);
if (keys.has(key)) continue; 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); keys.add(key);
} }
return available; return available;
+91 -2
View File
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({
invoke: vi.fn(), invoke: vi.fn(),
listen: vi.fn(), listen: vi.fn(),
onFocusChanged: vi.fn(), onFocusChanged: vi.fn(),
startDragging: vi.fn(),
focusHandler: undefined as focusHandler: undefined as
| ((event: { payload: boolean }) => void) | ((event: { payload: boolean }) => void)
| undefined, | undefined,
@@ -28,7 +29,10 @@ const mocks = vi.hoisted(() => ({
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
vi.mock("@tauri-apps/api/window", () => ({ vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({ onFocusChanged: mocks.onFocusChanged }), getCurrentWindow: () => ({
onFocusChanged: mocks.onFocusChanged,
startDragging: mocks.startDragging,
}),
})); }));
import QuickPane from "./QuickPane"; import QuickPane from "./QuickPane";
@@ -212,6 +216,7 @@ describe("QuickPane", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.clearAllMocks(); vi.clearAllMocks();
mocks.focusHandler = undefined; mocks.focusHandler = undefined;
mocks.startDragging.mockResolvedValue(undefined);
mocks.listen.mockResolvedValue(vi.fn()); mocks.listen.mockResolvedValue(vi.fn());
mocks.onFocusChanged.mockImplementation((handler) => { mocks.onFocusChanged.mockImplementation((handler) => {
mocks.focusHandler = handler; mocks.focusHandler = handler;
@@ -227,12 +232,95 @@ describe("QuickPane", () => {
expect( expect(
Array.from(pane?.children ?? []).map((element) => element.className), Array.from(pane?.children ?? []).map((element) => element.className),
).toEqual([ ).toEqual([
"quick-pane-drag-strip",
"quick-pane-toolbar", "quick-pane-toolbar",
"quick-pane-composer", "quick-pane-composer",
"quick-pane-exchange", "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 () => { it("keeps downward model menu inside the scroll-contained pane", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
bridgeMock([defaultModels]); bridgeMock([defaultModels]);
@@ -371,6 +459,7 @@ describe("QuickPane", () => {
const continueButton = await screen.findByRole("button", { const continueButton = await screen.findByRole("button", {
name: "Continue in full session", name: "Continue in full session",
}); });
expect(continueButton.closest(".quick-pane-assistant-footer")).not.toBeNull();
expect(mocks.invoke).not.toHaveBeenCalledWith( expect(mocks.invoke).not.toHaveBeenCalledWith(
"create_session_runtime", "create_session_runtime",
expect.anything(), expect.anything(),
@@ -448,7 +537,7 @@ describe("QuickPane", () => {
"YouFirst request", "YouFirst request",
"AssistantFirst answer", "AssistantFirst answer",
"YouSecond request", "YouSecond request",
"AssistantSecond answer", "AssistantSecond answerContinue in full session",
]); ]);
}); });
+85 -19
View File
@@ -10,6 +10,8 @@ import {
modelIdentity, modelIdentity,
modelIdentityValue, modelIdentityValue,
reconcileModelIdentity, reconcileModelIdentity,
reconcileThinkingLevel,
thinkingLevelOptions,
type ModelIdentity, type ModelIdentity,
} from "../model-catalog"; } from "../model-catalog";
import "./quick-pane.css"; import "./quick-pane.css";
@@ -176,6 +178,7 @@ export default function QuickPane() {
const [request, setRequest] = useState(""); const [request, setRequest] = useState("");
const [submissionStarting, setSubmissionStarting] = useState(false); const [submissionStarting, setSubmissionStarting] = useState(false);
const [requestActive, setRequestActive] = useState(false); const [requestActive, setRequestActive] = useState(false);
const [chatReplacing, setChatReplacing] = useState(false);
const [thinkingLevel, setThinkingLevel] = useState("medium"); const [thinkingLevel, setThinkingLevel] = useState("medium");
const [models, setModels] = useState<Model[]>([]); const [models, setModels] = useState<Model[]>([]);
const [catalogStatus, setCatalogStatus] = useState< const [catalogStatus, setCatalogStatus] = useState<
@@ -230,6 +233,19 @@ export default function QuickPane() {
const resizeFrameRef = useRef<number | undefined>(undefined); const resizeFrameRef = useRef<number | undefined>(undefined);
const requestedPaneHeightRef = useRef<number | undefined>(undefined); const requestedPaneHeightRef = useRef<number | undefined>(undefined);
const invalid = useMemo(() => settingsError(settings), [settings]); 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 runActive = submissionStarting || requestActive;
const activityPhase = submissionStarting const activityPhase = submissionStarting
? "Starting" ? "Starting"
@@ -245,6 +261,12 @@ export default function QuickPane() {
? `${activityPhase} · ${elapsedSeconds}s` ? `${activityPhase} · ${elapsedSeconds}s`
: activityPhase; : activityPhase;
useEffect(() => {
setThinkingLevel((current) =>
reconcileThinkingLevel(selectedCatalogModel, current),
);
}, [selectedCatalogModel]);
const focusInput = () => inputRef.current?.focus(); const focusInput = () => inputRef.current?.focus();
const closeRuntime = useCallback(async (runtimeId: string) => { const closeRuntime = useCallback(async (runtimeId: string) => {
const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId); const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
@@ -448,6 +470,7 @@ export default function QuickPane() {
async (clearDraft: boolean, abortActive: boolean) => { async (clearDraft: boolean, abortActive: boolean) => {
if (resetStartingRef.current) return false; if (resetStartingRef.current) return false;
resetStartingRef.current = true; resetStartingRef.current = true;
setChatReplacing(true);
preparationGenerationRef.current += 1; preparationGenerationRef.current += 1;
preparationLockRef.current = false; preparationLockRef.current = false;
catalogReadyRef.current = false; catalogReadyRef.current = false;
@@ -456,9 +479,12 @@ export default function QuickPane() {
requestActiveRef.current || submissionStartingRef.current; requestActiveRef.current || submissionStartingRef.current;
try { try {
if (abortActive && wasActive && current) { if (abortActive && wasActive && current) {
await invoke("abort", { agentId: current.agentId }).catch( try {
() => undefined, await invoke("abort", { agentId: current.agentId });
); } catch (reason) {
setError(`Could not abort quick request: ${String(reason)}`);
return false;
}
} }
clearChatState(clearDraft); clearChatState(clearDraft);
if (current) { if (current) {
@@ -472,6 +498,7 @@ export default function QuickPane() {
return await prepareRuntime(selectedModelRef.current, true); return await prepareRuntime(selectedModelRef.current, true);
} finally { } finally {
resetStartingRef.current = false; resetStartingRef.current = false;
setChatReplacing(false);
window.requestAnimationFrame(focusInput); window.requestAnimationFrame(focusInput);
} }
}, },
@@ -534,7 +561,7 @@ export default function QuickPane() {
const height = Math.min( const height = Math.min(
520, 520,
Math.max( Math.max(
360, 240,
Math.ceil(pane.clientHeight - exchange.clientHeight + contentHeight), Math.ceil(pane.clientHeight - exchange.clientHeight + contentHeight),
), ),
); );
@@ -801,6 +828,13 @@ export default function QuickPane() {
const canContinue = !invalid && !!handoffRequest; const canContinue = !invalid && !!handoffRequest;
const completedAssistantAnswer = const completedAssistantAnswer =
responseComplete && messages.some((message) => message.role === "assistant"); 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 const transcriptHandoff = messages
.map((message) => .map((message) =>
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`, `${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
@@ -834,6 +868,13 @@ export default function QuickPane() {
return ( return (
<main className="quick-pane" ref={paneRef}> <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"> <header className="quick-pane-toolbar">
<div className="quick-pane-primary-controls"> <div className="quick-pane-primary-controls">
{catalogStatus === "loading" && ( {catalogStatus === "loading" && (
@@ -855,6 +896,9 @@ export default function QuickPane() {
if (!identity) return false; if (!identity) return false;
selectedModelRef.current = identity; selectedModelRef.current = identity;
setSelectedModel(identity); setSelectedModel(identity);
setThinkingLevel((current) =>
reconcileThinkingLevel(model, current),
);
return true; return true;
}} }}
/> />
@@ -866,12 +910,14 @@ export default function QuickPane() {
<select <select
aria-label="Thinking" aria-label="Thinking"
value={thinkingLevel} value={thinkingLevel}
disabled={runActive} disabled={runActive || chatReplacing}
onChange={(event) => setThinkingLevel(event.currentTarget.value)} onChange={(event) => setThinkingLevel(event.currentTarget.value)}
> >
<option value="low">Low</option> {thinkingOptions.map((option) => (
<option value="medium">Medium</option> <option key={option.value} value={option.value}>
<option value="high">High</option> {option.label}
</option>
))}
</select> </select>
</label> </label>
</div> </div>
@@ -913,6 +959,28 @@ export default function QuickPane() {
}} }}
placeholder="Ask a quick question or give a quick instruction" 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 <button
className="quick-pane-send" className="quick-pane-send"
type="button" type="button"
@@ -968,6 +1036,15 @@ export default function QuickPane() {
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.markdown} {message.markdown}
</ReactMarkdown> </ReactMarkdown>
{message.id === completedAssistantMessageId &&
!escalation &&
canContinue && (
<footer className="quick-pane-assistant-footer">
<button type="button" onClick={continueFull}>
Continue in full session
</button>
</footer>
)}
</article> </article>
), ),
)} )}
@@ -993,17 +1070,6 @@ export default function QuickPane() {
> >
{activityPhase} {activityPhase}
</span> </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 && ( {escalation && (
<aside className="quick-pane-notice escalation" role="status"> <aside className="quick-pane-notice escalation" role="status">
<div> <div>
+48 -3
View File
@@ -46,6 +46,13 @@ body {
align-items: center; align-items: center;
} }
.quick-pane-drag-strip {
height: 8px;
flex: 0 0 8px;
cursor: move;
touch-action: none;
}
.quick-pane-toolbar { .quick-pane-toolbar {
flex: 0 0 auto; flex: 0 0 auto;
justify-content: space-between; justify-content: space-between;
@@ -195,16 +202,19 @@ body {
flex: 1 1 auto; flex: 1 1 auto;
flex-direction: column; flex-direction: column;
gap: 9px; gap: 9px;
overflow-x: hidden; overflow: hidden;
overflow-y: auto;
padding: 2px 3px 8px; padding: 2px 3px 8px;
scroll-behavior: smooth; scroll-behavior: smooth;
} }
.quick-pane-transcript { .quick-pane-transcript {
display: flex; display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column; flex-direction: column;
gap: 9px; gap: 9px;
overflow-x: hidden;
overflow-y: auto;
} }
.quick-pane-user-row { .quick-pane-user-row {
@@ -266,6 +276,25 @@ body {
margin-bottom: 0; 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 p,
.quick-pane-assistant-message ul, .quick-pane-assistant-message ul,
.quick-pane-assistant-message ol, .quick-pane-assistant-message ol,
@@ -450,7 +479,7 @@ body {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 11px 50px 29px 11px; padding: 11px 150px 29px 11px;
resize: none; resize: none;
border: 1px solid #55514c; border: 1px solid #55514c;
border-radius: 8px; border-radius: 8px;
@@ -465,6 +494,22 @@ body {
background: #272727; 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 { .quick-pane-send {
position: absolute; position: absolute;
right: 8px; right: 8px;
+6 -1
View File
@@ -89,7 +89,12 @@ export type SessionStats = {
}; };
}; };
export type Command = { name?: string; description?: string }; 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 = { export type Extension = {
id?: string; id?: string;
method?: string; method?: string;