feat(quick-pane): polish compact chat controls
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×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
|
||||
|
||||
|
||||
@@ -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×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.
|
||||
- **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×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.
|
||||
|
||||
## Exit criteria
|
||||
|
||||
@@ -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, /<ReactMarkdown remarkPlugins=\{\[remarkGfm\]\}>/);
|
||||
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, /Quick Pane transcript:/);
|
||||
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,
|
||||
height: 360,
|
||||
height: 240,
|
||||
minWidth: 520,
|
||||
minHeight: 360,
|
||||
minHeight: 240,
|
||||
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, /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, /invoke\("resize_quick_pane", \{ height \}\)/);
|
||||
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-exchange"/);
|
||||
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, /ref=\{inputRef\}/);
|
||||
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(
|
||||
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(
|
||||
stylesheet,
|
||||
/\.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, /@media \(max-width: 400px\)/);
|
||||
assert.match(stylesheet, /html,[\s\S]*?#root\s*\{[\s\S]*?overflow: hidden/);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user