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.
|
`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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
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;
|
||||||
|
|||||||
@@ -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",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user