feat(quick-pane): add compact runtime controls
This commit is contained in:
+37
-10
@@ -51,26 +51,30 @@ test("main Settings uses shared catalog selectors with provider derived from mod
|
|||||||
assert.doesNotMatch(pane, /showSettings/);
|
assert.doesNotMatch(pane, /showSettings/);
|
||||||
assert.doesNotMatch(pane, /Save Quick Pane settings/);
|
assert.doesNotMatch(pane, /Save Quick Pane settings/);
|
||||||
assert.match(pane, /!snapshotIsActive\(snapshot\)/);
|
assert.match(pane, /!snapshotIsActive\(snapshot\)/);
|
||||||
assert.match(pane, /current === submitted\.request \? "" : current/);
|
assert.match(pane, /if \(current !== submitted\.request\) return current;/);
|
||||||
|
assert.match(pane, /inputSelectionRef\.current = undefined;/);
|
||||||
|
assert.match(pane, /input\.setSelectionRange\(/);
|
||||||
|
assert.match(pane, /onSelect=\{\(event\) => recordInputSelection/);
|
||||||
|
assert.match(pane, /onInput=\{\(event\) => recordInputChangeSelection/);
|
||||||
|
assert.match(pane, /<ThinkingSelector/);
|
||||||
|
assert.match(pane, /quick-pane-catalog-overlay/);
|
||||||
assert.match(pane, /readOnly=\{runActive\}/);
|
assert.match(pane, /readOnly=\{runActive\}/);
|
||||||
assert.match(pane, /window\.requestAnimationFrame\(focusInput\)/);
|
assert.match(pane, /window\.requestAnimationFrame\(focusInput\)/);
|
||||||
assert.doesNotMatch(pane, /\.slice\(0, 5\)/);
|
assert.doesNotMatch(pane, /\.slice\(0, 5\)/);
|
||||||
assert.doesNotMatch(pane, /aria-label="Answer history"/);
|
assert.doesNotMatch(pane, /aria-label="Answer history"/);
|
||||||
assert.match(
|
assert.match(
|
||||||
pane,
|
pane,
|
||||||
/transcriptMessages\(snapshot, submittedPromptRequestsRef\.current\)/,
|
/transcriptMessages\(snapshot, submittedPromptTurnsRef\.current\)/,
|
||||||
);
|
);
|
||||||
assert.match(pane, /const QUICK_PANE_ADVISORY_PREFIX =/);
|
assert.match(pane, /const QUICK_PANE_ADVISORY_PREFIX =/);
|
||||||
assert.match(pane, /submittedPromptRequests\.get\(text\)/);
|
assert.match(pane, /submittedPromptTurns\[nextSubmittedTurn\]/);
|
||||||
assert.match(
|
assert.match(
|
||||||
pane,
|
pane,
|
||||||
/text\.startsWith\(QUICK_PANE_ADVISORY_PREFIX\) \? "" : text/,
|
/text\.startsWith\(QUICK_PANE_ADVISORY_PREFIX\) \? "" : text/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(pane, /submittedPromptTurnsRef\.current\.push\(submittedTurn\)/);
|
||||||
pane,
|
assert.match(pane, /nextSubmittedTurnIdRef\.current/);
|
||||||
/submittedPromptRequestsRef\.current\.set\(guardrails, submittedRequest\)/,
|
assert.match(pane, /submittedPromptTurnsRef\.current = \[\]/);
|
||||||
);
|
|
||||||
assert.match(pane, /submittedPromptRequestsRef\.current\.clear\(\)/);
|
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
pane,
|
pane,
|
||||||
/delimiterIndex|text\.indexOf\(USER_REQUEST_DELIMITER\)/,
|
/delimiterIndex|text\.indexOf\(USER_REQUEST_DELIMITER\)/,
|
||||||
@@ -234,7 +238,19 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
|||||||
assert.match(source, /className="quick-pane-drag-strip"/);
|
assert.match(source, /className="quick-pane-drag-strip"/);
|
||||||
assert.match(source, /startDragging\(\)/);
|
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, /onOpen=\{handleModelMenuOpen\}/);
|
||||||
|
assert.match(source, /onClose=\{handleModelMenuClose\}/);
|
||||||
|
assert.match(source, /requestPaneResize\(520\)/);
|
||||||
|
assert.match(source, /if \(modelMenuOpenRef\.current\) return;/);
|
||||||
|
assert.match(source, /submittedPromptTurnsRef/);
|
||||||
|
assert.match(source, /function elapsedLabel/);
|
||||||
|
assert.match(source, /quick-pane-assistant-activity/);
|
||||||
|
assert.match(source, /transcriptFollowRef/);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/transcript\.scrollHeight\s*-\s*transcript\.clientHeight/,
|
||||||
|
);
|
||||||
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, /className="quick-pane-composer-actions"/);
|
||||||
assert.match(source, /replaceChat\(false, true\)/);
|
assert.match(source, /replaceChat\(false, true\)/);
|
||||||
@@ -261,8 +277,16 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
|||||||
/\.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-drag-strip[\s\S]*?height: 8px/);
|
||||||
assert.match(stylesheet, /\.quick-pane-composer-actions[\s\S]*?position: absolute/);
|
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,
|
||||||
|
/\.quick-pane-assistant-elapsed[\s\S]*?font-variant-numeric/,
|
||||||
|
);
|
||||||
|
assert.match(stylesheet, /\.quick-pane-assistant-activity[\s\S]*?color:/);
|
||||||
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/);
|
||||||
assert.match(stylesheet, /\.quick-pane\s*\{[\s\S]*?overflow: hidden/);
|
assert.match(stylesheet, /\.quick-pane\s*\{[\s\S]*?overflow: hidden/);
|
||||||
@@ -288,6 +312,9 @@ test("Quick Pane uses native dimensions and request-first compact layout", async
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
assert.match(selectorSource, /import "\.\/ModelSelector\.css";/);
|
assert.match(selectorSource, /import "\.\/ModelSelector\.css";/);
|
||||||
|
assert.match(selectorSource, /onOpen\?: \(\) => void;/);
|
||||||
|
assert.match(selectorSource, /onClose\?: \(\) => void;/);
|
||||||
|
assert.match(selectorSource, /onCloseRef\.current\?\.\(\);/);
|
||||||
assert.match(
|
assert.match(
|
||||||
selectorStylesheet,
|
selectorStylesheet,
|
||||||
/\.model-trigger\s*>\s*span\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis/,
|
/\.model-trigger\s*>\s*span\s*\{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis/,
|
||||||
|
|||||||
+22
-9
@@ -283,15 +283,16 @@ async fn get_model_catalog() -> Result<Value, String> {
|
|||||||
bridge::get_model_catalog(&socket_path()?).await
|
bridge::get_model_catalog(&socket_path()?).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn quick_pane_height(height: f64) -> f64 {
|
fn quick_pane_height(height: f64) -> Result<f64, String> {
|
||||||
height.clamp(240.0, 520.0)
|
if !height.is_finite() {
|
||||||
|
return Err("Quick Pane height must be finite".to_owned());
|
||||||
|
}
|
||||||
|
Ok(height.clamp(240.0, 520.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
|
fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
|
||||||
if !height.is_finite() {
|
let height = quick_pane_height(height)?;
|
||||||
return Err("Quick Pane height must be finite".to_owned());
|
|
||||||
}
|
|
||||||
let quick_pane = app
|
let quick_pane = app
|
||||||
.get_webview_window("quick-pane")
|
.get_webview_window("quick-pane")
|
||||||
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?;
|
.ok_or_else(|| "Quick Pane window is unavailable".to_owned())?;
|
||||||
@@ -300,7 +301,7 @@ fn resize_quick_pane(app: AppHandle, height: f64) -> Result<(), String> {
|
|||||||
quick_pane
|
quick_pane
|
||||||
.set_size(LogicalSize::new(
|
.set_size(LogicalSize::new(
|
||||||
f64::from(current_size.width) / scale_factor,
|
f64::from(current_size.width) / scale_factor,
|
||||||
quick_pane_height(height),
|
height,
|
||||||
))
|
))
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
@@ -802,9 +803,21 @@ 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), 240.0);
|
assert_eq!(quick_pane_height(200.0), Ok(240.0));
|
||||||
assert_eq!(quick_pane_height(420.0), 420.0);
|
assert_eq!(quick_pane_height(420.0), Ok(420.0));
|
||||||
assert_eq!(quick_pane_height(900.0), 520.0);
|
assert_eq!(quick_pane_height(900.0), Ok(520.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_pane_resize_rejects_non_finite_height() {
|
||||||
|
assert_eq!(
|
||||||
|
quick_pane_height(f64::NAN),
|
||||||
|
Err("Quick Pane height must be finite".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
quick_pane_height(f64::INFINITY),
|
||||||
|
Err("Quick Pane height must be finite".to_owned())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -717,41 +717,6 @@ select:focus-visible {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: #303030;
|
background: #303030;
|
||||||
}
|
}
|
||||||
.pi-controls label {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.pi-controls select {
|
|
||||||
min-width: 0;
|
|
||||||
appearance: none;
|
|
||||||
color-scheme: dark;
|
|
||||||
padding: 5px 25px 5px 6px;
|
|
||||||
border: 1px solid #70522f;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #2c2a27
|
|
||||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
|
|
||||||
no-repeat right 7px center;
|
|
||||||
color: #f2ece4;
|
|
||||||
}
|
|
||||||
.pi-controls select:hover {
|
|
||||||
border-color: #d88735;
|
|
||||||
background-color: #383129;
|
|
||||||
}
|
|
||||||
.pi-controls select:focus {
|
|
||||||
border-color: #f0a347;
|
|
||||||
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
|
|
||||||
}
|
|
||||||
.pi-controls select option {
|
|
||||||
background: #2c2a27;
|
|
||||||
color: #f2ece4;
|
|
||||||
}
|
|
||||||
.pi-controls select option:checked {
|
|
||||||
background: #4b3420;
|
|
||||||
color: #fff2df;
|
|
||||||
}
|
|
||||||
.session-status-strip {
|
.session-status-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Model, RuntimeView, TodoTask } from "../workspace/types";
|
|||||||
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
|
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
|
||||||
import { CommandPalette } from "./CommandPalette";
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { ModelSelector } from "./ModelSelector";
|
import { ModelSelector } from "./ModelSelector";
|
||||||
|
import { ThinkingSelector } from "./ThinkingSelector";
|
||||||
|
|
||||||
type SessionStatus = {
|
type SessionStatus = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -185,6 +186,11 @@ export function ConversationWorkspace({
|
|||||||
const activeTool = runtime.progress.tool
|
const activeTool = runtime.progress.tool
|
||||||
? plainStatusText(runtime.progress.tool)
|
? plainStatusText(runtime.progress.tool)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const selectedThinkingModel = runtime.models.find(
|
||||||
|
(model) =>
|
||||||
|
model.provider === runtime.state.model?.provider &&
|
||||||
|
model.id === runtime.state.model?.id,
|
||||||
|
);
|
||||||
const inputTokens = runtime.stats.tokens?.input;
|
const inputTokens = runtime.stats.tokens?.input;
|
||||||
const outputTokens = runtime.stats.tokens?.output;
|
const outputTokens = runtime.stats.tokens?.output;
|
||||||
const cacheReadTokens = runtime.stats.tokens?.cacheRead;
|
const cacheReadTokens = runtime.stats.tokens?.cacheRead;
|
||||||
@@ -330,20 +336,12 @@ export function ConversationWorkspace({
|
|||||||
onSelect={onModel}
|
onSelect={onModel}
|
||||||
pending={isModelUpdating || locked}
|
pending={isModelUpdating || locked}
|
||||||
/>
|
/>
|
||||||
<label>
|
<ThinkingSelector
|
||||||
Thinking
|
model={selectedThinkingModel}
|
||||||
<select
|
value={runtime.state.thinkingLevel ?? "medium"}
|
||||||
value={runtime.state.thinkingLevel ?? "medium"}
|
disabled={isModelUpdating || locked}
|
||||||
disabled={isModelUpdating || locked}
|
onChange={(level) => void onThinking(level)}
|
||||||
onChange={(event) => void onThinking(event.currentTarget.value)}
|
/>
|
||||||
>
|
|
||||||
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
|
|
||||||
(level) => (
|
|
||||||
<option key={level}>{level}</option>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</section>
|
</section>
|
||||||
<div
|
<div
|
||||||
className={`session-status-strip ${presentedStatus.phase}`}
|
className={`session-status-strip ${presentedStatus.phase}`}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
.thinking-selector {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #bbb7b0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-selector.thinking-selector select {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
appearance: none;
|
||||||
|
color: #f2ece4;
|
||||||
|
color-scheme: dark;
|
||||||
|
padding: 5px 25px 5px 6px;
|
||||||
|
border: 1px solid #70522f;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #2c2a27
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23f0a347' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E")
|
||||||
|
no-repeat right 7px center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-selector.thinking-selector select:hover {
|
||||||
|
border-color: #d88735;
|
||||||
|
background-color: #383129;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-selector.thinking-selector select:focus {
|
||||||
|
border-color: #f0a347;
|
||||||
|
box-shadow: 0 0 0 2px rgba(240, 163, 71, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-selector.thinking-selector select option {
|
||||||
|
background: #2c2a27;
|
||||||
|
color: #f2ece4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-selector.thinking-selector select option:checked {
|
||||||
|
background: #4b3420;
|
||||||
|
color: #fff2df;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
import { ThinkingSelector } from "./ThinkingSelector";
|
||||||
|
|
||||||
|
it("uses selected model thinking capabilities and reconciles unavailable values", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ThinkingSelector
|
||||||
|
model={{ thinkingLevelMap: { off: "off", high: "high" } }}
|
||||||
|
value="medium"
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selector = screen.getByRole("combobox", { name: "Thinking" });
|
||||||
|
expect(selector).toHaveValue("off");
|
||||||
|
expect(
|
||||||
|
within(selector).getAllByRole("option").map((option) => option.textContent),
|
||||||
|
).toEqual(["Off", "High"]);
|
||||||
|
fireEvent.change(selector, { target: { value: "high" } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith("high");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses disabled native select semantics", () => {
|
||||||
|
render(<ThinkingSelector value="off" disabled onChange={vi.fn()} />);
|
||||||
|
expect(screen.getByRole("combobox", { name: "Thinking" })).toBeDisabled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { reconcileThinkingLevel, thinkingLevelOptions } from "../model-catalog";
|
||||||
|
import type { Model } from "../workspace/types";
|
||||||
|
import "./ThinkingSelector.css";
|
||||||
|
|
||||||
|
type ThinkingSelectorProps = {
|
||||||
|
model?: Model;
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onChange: (level: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThinkingSelector({
|
||||||
|
model,
|
||||||
|
value,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
onChange,
|
||||||
|
}: ThinkingSelectorProps) {
|
||||||
|
const selected = reconcileThinkingLevel(model, value);
|
||||||
|
const classes = ["thinking-selector", className].filter(Boolean).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={classes}>
|
||||||
|
<span>Thinking</span>
|
||||||
|
<select
|
||||||
|
aria-label="Thinking"
|
||||||
|
value={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange(event.currentTarget.value)}
|
||||||
|
>
|
||||||
|
{thinkingLevelOptions(model).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -230,7 +230,7 @@ describe("QuickPane", () => {
|
|||||||
await preparedTrigger();
|
await preparedTrigger();
|
||||||
const pane = container.querySelector(".quick-pane");
|
const pane = container.querySelector(".quick-pane");
|
||||||
expect(
|
expect(
|
||||||
Array.from(pane?.children ?? []).map((element) => element.className),
|
Array.from(pane?.children ?? []).map((element) => element.classList[0]),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
"quick-pane-drag-strip",
|
"quick-pane-drag-strip",
|
||||||
"quick-pane-toolbar",
|
"quick-pane-toolbar",
|
||||||
@@ -245,10 +245,144 @@ describe("QuickPane", () => {
|
|||||||
await preparedTrigger();
|
await preparedTrigger();
|
||||||
fireEvent.pointerDown(container.querySelector(".quick-pane-drag-strip")!);
|
fireEvent.pointerDown(container.querySelector(".quick-pane-drag-strip")!);
|
||||||
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
|
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
|
||||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Close Quick Pane" }));
|
fireEvent.pointerDown(
|
||||||
|
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||||
|
);
|
||||||
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
|
expect(mocks.startDragging).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks pane interaction behind one catalog loading overlay", async () => {
|
||||||
|
const tracker = deferredCatalogBridge();
|
||||||
|
render(<QuickPane />);
|
||||||
|
|
||||||
|
const overlay = screen.getByRole("dialog", { name: "Model catalog" });
|
||||||
|
expect(overlay).toHaveTextContent("Loading models…");
|
||||||
|
expect(screen.getAllByText("Loading models…")).toHaveLength(1);
|
||||||
|
expect(screen.getByRole("textbox", { name: "Request" })).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Close Quick Pane" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
await act(async () =>
|
||||||
|
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
|
||||||
|
);
|
||||||
|
expect(await preparedTrigger()).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Model catalog" })).toBeNull();
|
||||||
|
expect(screen.getByRole("textbox", { name: "Request" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores textarea caret after Tab to Send and Shift+Tab", async () => {
|
||||||
|
bridgeMock([defaultModels]);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<QuickPane />);
|
||||||
|
await preparedTrigger();
|
||||||
|
const input = screen.getByRole("textbox", {
|
||||||
|
name: "Request",
|
||||||
|
}) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(input, { target: { value: "Inspect selection" } });
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(7, 7, "none");
|
||||||
|
fireEvent.select(input);
|
||||||
|
|
||||||
|
await user.tab();
|
||||||
|
expect(screen.getByRole("button", { name: "Send request" })).toHaveFocus();
|
||||||
|
await user.tab({ shift: true });
|
||||||
|
|
||||||
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(7);
|
||||||
|
expect(input.selectionEnd).toBe(7);
|
||||||
|
expect(input.selectionDirection).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps browser click selection instead of restoring stale range", async () => {
|
||||||
|
bridgeMock([defaultModels]);
|
||||||
|
render(<QuickPane />);
|
||||||
|
await preparedTrigger();
|
||||||
|
const input = screen.getByRole("textbox", {
|
||||||
|
name: "Request",
|
||||||
|
}) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(input, { target: { value: "Inspect selection" } });
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(2, 2, "none");
|
||||||
|
fireEvent.select(input);
|
||||||
|
input.blur();
|
||||||
|
|
||||||
|
fireEvent.pointerDown(input);
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(11, 11, "none");
|
||||||
|
fireEvent.select(input);
|
||||||
|
fireEvent.click(input);
|
||||||
|
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||||
|
expect(input.selectionStart).toBe(11);
|
||||||
|
expect(input.selectionEnd).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores selection direction on programmatic focus without rewriting value", async () => {
|
||||||
|
bridgeMock([defaultModels]);
|
||||||
|
render(<QuickPane />);
|
||||||
|
await preparedTrigger();
|
||||||
|
const input = screen.getByRole("textbox", {
|
||||||
|
name: "Request",
|
||||||
|
}) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(input, { target: { value: "Inspect selection" } });
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(2, 9, "backward");
|
||||||
|
fireEvent.select(input);
|
||||||
|
input.blur();
|
||||||
|
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLTextAreaElement.prototype,
|
||||||
|
"value",
|
||||||
|
)!;
|
||||||
|
const setValue = vi.fn(function (this: HTMLTextAreaElement, value: string) {
|
||||||
|
descriptor.set!.call(this, value);
|
||||||
|
});
|
||||||
|
Object.defineProperty(input, "value", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => descriptor.get!.call(input),
|
||||||
|
set: setValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(2);
|
||||||
|
expect(input.selectionEnd).toBe(9);
|
||||||
|
expect(input.selectionDirection).toBe("backward");
|
||||||
|
});
|
||||||
|
expect(setValue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore stale selection after completion clears and focuses draft", async () => {
|
||||||
|
let snapshot = runtimeSnapshot(defaultModels, "Partial", true);
|
||||||
|
bridgeMock([defaultModels], () => snapshot);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<QuickPane />);
|
||||||
|
await preparedTrigger();
|
||||||
|
const input = screen.getByRole("textbox", {
|
||||||
|
name: "Request",
|
||||||
|
}) as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(input, { target: { value: "Inspect selection" } });
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(2, 9, "backward");
|
||||||
|
fireEvent.select(input);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Send request" }));
|
||||||
|
await waitFor(() => expect(input).toHaveAttribute("readonly"));
|
||||||
|
|
||||||
|
snapshot = runtimeSnapshot(defaultModels, "Completed", false);
|
||||||
|
expect(await screen.findByText("Completed")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(input).toHaveValue(""));
|
||||||
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
expect(input.selectionStart).toBe(0);
|
||||||
|
expect(input.selectionEnd).toBe(0);
|
||||||
|
expect(input.selectionDirection).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
it("derives thinking choices from selected model metadata and falls back to Off", async () => {
|
it("derives thinking choices from selected model metadata and falls back to Off", async () => {
|
||||||
const thinkingModels: Model[] = [
|
const thinkingModels: Model[] = [
|
||||||
{
|
{
|
||||||
@@ -258,14 +392,17 @@ describe("QuickPane", () => {
|
|||||||
thinkingLevelMap: { off: "off", high: "high" },
|
thinkingLevelMap: { off: "off", high: "high" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
bridgeMock([thinkingModels], () => runtimeSnapshot(thinkingModels, "", true));
|
bridgeMock([thinkingModels], () =>
|
||||||
|
runtimeSnapshot(thinkingModels, "", true),
|
||||||
|
);
|
||||||
render(<QuickPane />);
|
render(<QuickPane />);
|
||||||
await preparedTrigger();
|
await preparedTrigger();
|
||||||
const thinking = screen.getByRole("combobox", { name: "Thinking" });
|
const thinking = screen.getByRole("combobox", { name: "Thinking" });
|
||||||
expect(within(thinking).getAllByRole("option").map((option) => option.textContent)).toEqual([
|
expect(
|
||||||
"Off",
|
within(thinking)
|
||||||
"High",
|
.getAllByRole("option")
|
||||||
]);
|
.map((option) => option.textContent),
|
||||||
|
).toEqual(["Off", "High"]);
|
||||||
await userEvent.selectOptions(thinking, "high");
|
await userEvent.selectOptions(thinking, "high");
|
||||||
await ask("Inspect");
|
await ask("Inspect");
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -291,7 +428,9 @@ describe("QuickPane", () => {
|
|||||||
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
|
await waitFor(() => expect(tracker.runtimeCount()).toBe(2));
|
||||||
expect(mocks.invoke).toHaveBeenCalledWith("abort", { agentId: "agent-1" });
|
expect(mocks.invoke).toHaveBeenCalledWith("abort", { agentId: "agent-1" });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByRole("button", { name: "Send request" })).toBeEnabled(),
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Send request" }),
|
||||||
|
).toBeEnabled(),
|
||||||
);
|
);
|
||||||
|
|
||||||
streaming = false;
|
streaming = false;
|
||||||
@@ -304,10 +443,13 @@ describe("QuickPane", () => {
|
|||||||
runtimeSnapshot(defaultModels, "Partial answer", true),
|
runtimeSnapshot(defaultModels, "Partial answer", true),
|
||||||
);
|
);
|
||||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||||
mocks.invoke.mockImplementation((command: string, payload?: Record<string, unknown>) => {
|
mocks.invoke.mockImplementation(
|
||||||
if (command === "abort") return Promise.reject(new Error("abort unavailable"));
|
(command: string, payload?: Record<string, unknown>) => {
|
||||||
return baseImplementation?.(command, payload);
|
if (command === "abort")
|
||||||
});
|
return Promise.reject(new Error("abort unavailable"));
|
||||||
|
return baseImplementation?.(command, payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
render(<QuickPane />);
|
render(<QuickPane />);
|
||||||
await preparedTrigger();
|
await preparedTrigger();
|
||||||
await ask("Inspect");
|
await ask("Inspect");
|
||||||
@@ -340,18 +482,36 @@ describe("QuickPane", () => {
|
|||||||
).toHaveClass("model-results");
|
).toHaveClass("model-results");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes an open selector on first Escape and dismisses pane on second", async () => {
|
it("grows for an open selector, restores content height on Escape, then dismisses", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
bridgeMock([defaultModels]);
|
bridgeMock([defaultModels]);
|
||||||
render(<QuickPane />);
|
render(<QuickPane />);
|
||||||
const trigger = await preparedTrigger();
|
const trigger = await preparedTrigger();
|
||||||
await user.click(trigger);
|
await user.click(trigger);
|
||||||
await waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus());
|
await waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mocks.invoke).toHaveBeenCalledWith("resize_quick_pane", {
|
||||||
|
height: 520,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const resizeCallCountBeforeClose = mocks.invoke.mock.calls.filter(
|
||||||
|
([command]) => command === "resize_quick_pane",
|
||||||
|
).length;
|
||||||
|
|
||||||
fireEvent.keyDown(window, { key: "Escape" });
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
|
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
|
||||||
expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane");
|
expect(mocks.invoke).not.toHaveBeenCalledWith("dismiss_quick_pane");
|
||||||
await waitFor(() => expect(trigger).toHaveFocus());
|
await waitFor(() => expect(trigger).toHaveFocus());
|
||||||
|
await waitFor(() => {
|
||||||
|
const resizeCalls = mocks.invoke.mock.calls.filter(
|
||||||
|
([command]) => command === "resize_quick_pane",
|
||||||
|
);
|
||||||
|
expect(resizeCalls.length).toBeGreaterThan(resizeCallCountBeforeClose);
|
||||||
|
expect(resizeCalls[resizeCalls.length - 1]).toEqual([
|
||||||
|
"resize_quick_pane",
|
||||||
|
{ height: 240 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
fireEvent.keyDown(window, { key: "Escape" });
|
fireEvent.keyDown(window, { key: "Escape" });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -359,6 +519,41 @@ describe("QuickPane", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries a failed native menu expansion while selector remains open", async () => {
|
||||||
|
bridgeMock([defaultModels]);
|
||||||
|
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||||
|
let failed = false;
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: { height?: number }) => {
|
||||||
|
if (
|
||||||
|
command === "resize_quick_pane" &&
|
||||||
|
payload?.height === 520 &&
|
||||||
|
!failed
|
||||||
|
) {
|
||||||
|
failed = true;
|
||||||
|
return Promise.reject(new Error("resize unavailable"));
|
||||||
|
}
|
||||||
|
return baseImplementation?.(command, payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<QuickPane />);
|
||||||
|
await user.click(await preparedTrigger());
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
mocks.invoke.mock.calls.filter(
|
||||||
|
([command, payload]) =>
|
||||||
|
command === "resize_quick_pane" &&
|
||||||
|
(payload as { height?: number })?.height === 520,
|
||||||
|
),
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: "Choose model" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("allows only one submission start while commands are pending", async () => {
|
it("allows only one submission start while commands are pending", async () => {
|
||||||
const pendingModel = deferred<unknown>();
|
const pendingModel = deferred<unknown>();
|
||||||
const tracker = bridgeMock([defaultModels]);
|
const tracker = bridgeMock([defaultModels]);
|
||||||
@@ -412,14 +607,21 @@ describe("QuickPane", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears only submitted draft and restores focus after completion", async () => {
|
it("shows current humanized activity inside streaming assistant card", async () => {
|
||||||
let current = runtimeSnapshot(defaultModels, "Partial answer", true);
|
let current = runtimeSnapshot(defaultModels, "Partial answer", true, {
|
||||||
|
...runtimeSnapshot(defaultModels).runtime,
|
||||||
|
activeTool: "functions.read",
|
||||||
|
});
|
||||||
bridgeMock([defaultModels], () => current);
|
bridgeMock([defaultModels], () => current);
|
||||||
render(<QuickPane />);
|
render(<QuickPane />);
|
||||||
await preparedTrigger();
|
await preparedTrigger();
|
||||||
const input = screen.getByRole("textbox", { name: "Request" });
|
const input = screen.getByRole("textbox", { name: "Request" });
|
||||||
await ask("Inspect this");
|
await ask("Inspect this");
|
||||||
expect(await screen.findByText("Partial answer")).toBeInTheDocument();
|
const partialAnswer = await screen.findByText("Partial answer");
|
||||||
|
expect(partialAnswer).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(partialAnswer.closest("article")!).getByText("Reading file"),
|
||||||
|
).toHaveClass("quick-pane-assistant-activity");
|
||||||
expect(input).toHaveValue("Inspect this");
|
expect(input).toHaveValue("Inspect this");
|
||||||
expect(input).toHaveAttribute("readonly");
|
expect(input).toHaveAttribute("readonly");
|
||||||
|
|
||||||
@@ -435,6 +637,32 @@ describe("QuickPane", () => {
|
|||||||
expect(input).toHaveValue("Next draft");
|
expect(input).toHaveValue("Next draft");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("follows transcript near bottom without overriding manual scroll-up", async () => {
|
||||||
|
let current = runtimeSnapshot(defaultModels, "Partial answer", true);
|
||||||
|
bridgeMock([defaultModels], () => current);
|
||||||
|
render(<QuickPane />);
|
||||||
|
await preparedTrigger();
|
||||||
|
await ask("Inspect this");
|
||||||
|
const response = screen.getByRole("region", { name: "Assistant response" });
|
||||||
|
Object.defineProperties(response, {
|
||||||
|
clientHeight: { configurable: true, value: 100 },
|
||||||
|
scrollHeight: { configurable: true, value: 1_000 },
|
||||||
|
scrollTop: { configurable: true, value: 880, writable: true },
|
||||||
|
});
|
||||||
|
fireEvent.scroll(response);
|
||||||
|
|
||||||
|
current = runtimeSnapshot(defaultModels, "Updated answer", true);
|
||||||
|
expect(await screen.findByText("Updated answer")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(response.scrollTop).toBe(1_000));
|
||||||
|
|
||||||
|
response.scrollTop = 100;
|
||||||
|
fireEvent.scroll(response);
|
||||||
|
current = runtimeSnapshot(defaultModels, "Latest answer", true);
|
||||||
|
expect(await screen.findByText("Latest answer")).toBeInTheDocument();
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 50));
|
||||||
|
expect(response.scrollTop).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows manual continuation after a completed normal answer and hands off transcript", async () => {
|
it("shows manual continuation after a completed normal answer and hands off transcript", async () => {
|
||||||
bridgeMock([defaultModels], () =>
|
bridgeMock([defaultModels], () =>
|
||||||
snapshotWithTranscript([
|
snapshotWithTranscript([
|
||||||
@@ -459,7 +687,9 @@ 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(
|
||||||
|
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(),
|
||||||
@@ -533,7 +763,11 @@ describe("QuickPane", () => {
|
|||||||
const cards = screen
|
const cards = screen
|
||||||
.getByRole("region", { name: "Assistant response" })
|
.getByRole("region", { name: "Assistant response" })
|
||||||
.querySelectorAll("article");
|
.querySelectorAll("article");
|
||||||
expect(Array.from(cards).map((card) => card.textContent)).toEqual([
|
expect(
|
||||||
|
Array.from(cards).map((card) =>
|
||||||
|
card.textContent?.replace(/\d+(?:m \d+)?s/, ""),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
"YouFirst request",
|
"YouFirst request",
|
||||||
"AssistantFirst answer",
|
"AssistantFirst answer",
|
||||||
"YouSecond request",
|
"YouSecond request",
|
||||||
@@ -541,6 +775,196 @@ describe("QuickPane", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps distinct elapsed times for duplicate follow-up requests", async () => {
|
||||||
|
let now = 100_000;
|
||||||
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||||
|
const submittedPrompts: string[] = [];
|
||||||
|
let firstComplete = false;
|
||||||
|
let secondComplete = false;
|
||||||
|
bridgeMock([defaultModels], () => {
|
||||||
|
const messages: Message[] = [];
|
||||||
|
if (submittedPrompts[0])
|
||||||
|
messages.push(
|
||||||
|
{ role: "user", content: submittedPrompts[0] },
|
||||||
|
{ role: "assistant", content: "First answer" },
|
||||||
|
);
|
||||||
|
if (submittedPrompts[1])
|
||||||
|
messages.push(
|
||||||
|
{ role: "user", content: submittedPrompts[1] },
|
||||||
|
{ role: "assistant", content: "Second answer" },
|
||||||
|
);
|
||||||
|
return snapshotWithTranscript(
|
||||||
|
messages,
|
||||||
|
submittedPrompts.length === 1 ? !firstComplete : !secondComplete,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: Record<string, unknown>) => {
|
||||||
|
if (command === "submit_prompt" && payload?.message)
|
||||||
|
submittedPrompts.push(String(payload.message));
|
||||||
|
return baseImplementation?.(command, payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<QuickPane />);
|
||||||
|
await preparedTrigger();
|
||||||
|
await ask("Same request");
|
||||||
|
expect(await screen.findByText("First answer")).toBeInTheDocument();
|
||||||
|
now = 165_000;
|
||||||
|
firstComplete = true;
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("1m 5s")).toHaveClass(
|
||||||
|
"quick-pane-assistant-elapsed",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", { name: "Request" }),
|
||||||
|
).not.toHaveAttribute("readonly"),
|
||||||
|
);
|
||||||
|
|
||||||
|
now = 166_000;
|
||||||
|
await ask("Same request");
|
||||||
|
expect(submittedPrompts[1]).toBe(submittedPrompts[0]);
|
||||||
|
now = 168_000;
|
||||||
|
secondComplete = true;
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("2s")).toHaveClass(
|
||||||
|
"quick-pane-assistant-elapsed",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const assistantCards = screen
|
||||||
|
.getByRole("region", { name: "Assistant response" })
|
||||||
|
.querySelectorAll(".quick-pane-assistant-message");
|
||||||
|
expect(
|
||||||
|
within(assistantCards[0]! as HTMLElement).getByText("1m 5s"),
|
||||||
|
).toHaveClass("quick-pane-assistant-elapsed");
|
||||||
|
expect(
|
||||||
|
within(assistantCards[1]! as HTMLElement).getByText("2s"),
|
||||||
|
).toHaveClass("quick-pane-assistant-elapsed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["set_model", "set_thinking_level", "submit_prompt"])(
|
||||||
|
"keeps only retry timing when %s rejects before first submission",
|
||||||
|
async (failingCommand) => {
|
||||||
|
let now = 100_000;
|
||||||
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||||
|
let failed = false;
|
||||||
|
let acceptedPrompt = "";
|
||||||
|
let complete = false;
|
||||||
|
bridgeMock([defaultModels], () =>
|
||||||
|
acceptedPrompt
|
||||||
|
? snapshotWithTranscript(
|
||||||
|
[
|
||||||
|
{ role: "user", content: acceptedPrompt },
|
||||||
|
{ role: "assistant", content: "Retry answer" },
|
||||||
|
],
|
||||||
|
!complete,
|
||||||
|
)
|
||||||
|
: runtimeSnapshot(defaultModels),
|
||||||
|
);
|
||||||
|
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: Record<string, unknown>) => {
|
||||||
|
if (command === failingCommand && !failed) {
|
||||||
|
failed = true;
|
||||||
|
return Promise.reject(new Error(`${failingCommand} unavailable`));
|
||||||
|
}
|
||||||
|
if (command === "submit_prompt" && payload?.message)
|
||||||
|
acceptedPrompt = String(payload.message);
|
||||||
|
return baseImplementation?.(command, payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<QuickPane />);
|
||||||
|
await preparedTrigger();
|
||||||
|
await ask("Same request");
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
`Could not start quick request: Error: ${failingCommand} unavailable`,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", { name: "Request" }),
|
||||||
|
).not.toHaveAttribute("readonly"),
|
||||||
|
);
|
||||||
|
|
||||||
|
now = 200_000;
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole("button", { name: "Send request" }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(acceptedPrompt).not.toBe(""));
|
||||||
|
now = 202_000;
|
||||||
|
complete = true;
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
expect(screen.getByText("2s")).toHaveClass(
|
||||||
|
"quick-pane-assistant-elapsed",
|
||||||
|
),
|
||||||
|
{ timeout: 2_000 },
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("1m 42s")).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("does not let a failed different request claim later timing", async () => {
|
||||||
|
let now = 100_000;
|
||||||
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||||
|
let failed = false;
|
||||||
|
let acceptedPrompt = "";
|
||||||
|
let complete = false;
|
||||||
|
bridgeMock([defaultModels], () =>
|
||||||
|
acceptedPrompt
|
||||||
|
? snapshotWithTranscript(
|
||||||
|
[
|
||||||
|
{ role: "user", content: acceptedPrompt },
|
||||||
|
{ role: "assistant", content: "Second answer" },
|
||||||
|
],
|
||||||
|
!complete,
|
||||||
|
)
|
||||||
|
: runtimeSnapshot(defaultModels),
|
||||||
|
);
|
||||||
|
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||||
|
mocks.invoke.mockImplementation(
|
||||||
|
(command: string, payload?: Record<string, unknown>) => {
|
||||||
|
if (command === "submit_prompt" && !failed) {
|
||||||
|
failed = true;
|
||||||
|
return Promise.reject(new Error("submit unavailable"));
|
||||||
|
}
|
||||||
|
if (command === "submit_prompt" && payload?.message)
|
||||||
|
acceptedPrompt = String(payload.message);
|
||||||
|
return baseImplementation?.(command, payload);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<QuickPane />);
|
||||||
|
await preparedTrigger();
|
||||||
|
await ask("Discard this");
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"Could not start quick request: Error: submit unavailable",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const input = screen.getByRole("textbox", { name: "Request" });
|
||||||
|
fireEvent.change(input, { target: { value: "Keep this" } });
|
||||||
|
|
||||||
|
now = 200_000;
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Send request" }));
|
||||||
|
await waitFor(() => expect(acceptedPrompt).toContain("Keep this"));
|
||||||
|
now = 202_000;
|
||||||
|
complete = true;
|
||||||
|
await waitFor(
|
||||||
|
() =>
|
||||||
|
expect(screen.getByText("2s")).toHaveClass(
|
||||||
|
"quick-pane-assistant-elapsed",
|
||||||
|
),
|
||||||
|
{ timeout: 2_000 },
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Discard this")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not use prior-turn answer after follow-up runtime failure", async () => {
|
it("does not use prior-turn answer after follow-up runtime failure", async () => {
|
||||||
const submittedPrompts: string[] = [];
|
const submittedPrompts: string[] = [];
|
||||||
bridgeMock([defaultModels], () => {
|
bridgeMock([defaultModels], () => {
|
||||||
@@ -856,7 +1280,7 @@ describe("QuickPane", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
render(<QuickPane />);
|
render(<QuickPane />);
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alertdialog");
|
||||||
expect(alert).toHaveTextContent(
|
expect(alert).toHaveTextContent(
|
||||||
"Could not load settings: Error: state unavailable",
|
"Could not load settings: Error: state unavailable",
|
||||||
);
|
);
|
||||||
@@ -890,7 +1314,7 @@ describe("QuickPane", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
render(<QuickPane />);
|
render(<QuickPane />);
|
||||||
const alert = await screen.findByRole("alert");
|
const alert = await screen.findByRole("alertdialog");
|
||||||
expect(alert).toHaveTextContent(
|
expect(alert).toHaveTextContent(
|
||||||
"Could not prepare model catalog: Error: offline",
|
"Could not prepare model catalog: Error: offline",
|
||||||
);
|
);
|
||||||
@@ -997,53 +1421,6 @@ describe("QuickPane fresh runtime catalog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
action: "Close",
|
|
||||||
dismiss: async () => {
|
|
||||||
await userEvent.click(
|
|
||||||
screen.getByRole("button", { name: "Close Quick Pane" }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: "Escape",
|
|
||||||
dismiss: async () => {
|
|
||||||
fireEvent.keyDown(window, { key: "Escape" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
"retains deferred catalog preparation across $action dismissal",
|
|
||||||
async ({ dismiss }) => {
|
|
||||||
const tracker = deferredCatalogBridge();
|
|
||||||
render(<QuickPane />);
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mocks.invoke).toHaveBeenCalledWith(
|
|
||||||
"get_session_runtime_snapshot",
|
|
||||||
{ runtimeId: "runtime-1" },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await dismiss();
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane"),
|
|
||||||
);
|
|
||||||
await act(async () => mocks.focusHandler?.({ payload: false }));
|
|
||||||
await act(async () =>
|
|
||||||
tracker.firstSnapshot.resolve(runtimeSnapshot(tracker.staleModels)),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("button", { name: /provider · Stale/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
await act(async () => mocks.focusHandler?.({ payload: true }));
|
|
||||||
expect(tracker.runtimeCount()).toBe(1);
|
|
||||||
expect(mocks.invoke).not.toHaveBeenCalledWith("close_quick_runtime", {
|
|
||||||
runtimeId: "runtime-1",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("retains deferred catalog preparation across idle focus loss", async () => {
|
it("retains deferred catalog preparation across idle focus loss", async () => {
|
||||||
const tracker = deferredCatalogBridge();
|
const tracker = deferredCatalogBridge();
|
||||||
render(<QuickPane />);
|
render(<QuickPane />);
|
||||||
@@ -1292,7 +1669,7 @@ describe("QuickPane fresh runtime catalog", () => {
|
|||||||
return Promise.resolve(undefined);
|
return Promise.resolve(undefined);
|
||||||
});
|
});
|
||||||
render(<QuickPane />);
|
render(<QuickPane />);
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
expect(await screen.findByRole("alertdialog")).toHaveTextContent(
|
||||||
"Could not prepare model catalog: Error: offline",
|
"Could not prepare model catalog: Error: offline",
|
||||||
);
|
);
|
||||||
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
expect(mocks.invoke).toHaveBeenCalledWith("close_quick_runtime", {
|
||||||
|
|||||||
+405
-87
@@ -6,12 +6,12 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { ExtensionDialog } from "../components/ExtensionDialog";
|
import { ExtensionDialog } from "../components/ExtensionDialog";
|
||||||
import { ModelSelector } from "../components/ModelSelector";
|
import { ModelSelector } from "../components/ModelSelector";
|
||||||
|
import { ThinkingSelector } from "../components/ThinkingSelector";
|
||||||
import {
|
import {
|
||||||
modelIdentity,
|
modelIdentity,
|
||||||
modelIdentityValue,
|
modelIdentityValue,
|
||||||
reconcileModelIdentity,
|
reconcileModelIdentity,
|
||||||
reconcileThinkingLevel,
|
reconcileThinkingLevel,
|
||||||
thinkingLevelOptions,
|
|
||||||
type ModelIdentity,
|
type ModelIdentity,
|
||||||
} from "../model-catalog";
|
} from "../model-catalog";
|
||||||
import "./quick-pane.css";
|
import "./quick-pane.css";
|
||||||
@@ -43,15 +43,32 @@ const defaults: QuickPaneSettings = {
|
|||||||
|
|
||||||
type Runtime = { agentId: string; runtimeId: string };
|
type Runtime = { agentId: string; runtimeId: string };
|
||||||
type PreparedRuntime = { runtime: Runtime; models: Model[] };
|
type PreparedRuntime = { runtime: Runtime; models: Model[] };
|
||||||
|
type TurnTiming = {
|
||||||
|
startedAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
};
|
||||||
|
type SubmittedPromptTurn = {
|
||||||
|
id: number;
|
||||||
|
guardrails: string;
|
||||||
|
request: string;
|
||||||
|
timing: TurnTiming;
|
||||||
|
};
|
||||||
type TranscriptMessage = {
|
type TranscriptMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
markdown: string;
|
markdown: string;
|
||||||
|
timing?: TurnTiming;
|
||||||
};
|
};
|
||||||
type ActivitySnapshot = {
|
type ActivitySnapshot = {
|
||||||
activeTool?: string;
|
activeTool?: string;
|
||||||
hasAssistantText: boolean;
|
hasAssistantText: boolean;
|
||||||
};
|
};
|
||||||
|
type TextSelection = {
|
||||||
|
value: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
direction: HTMLTextAreaElement["selectionDirection"];
|
||||||
|
};
|
||||||
|
|
||||||
const CHAT_RENEWAL_MS = 180_000;
|
const CHAT_RENEWAL_MS = 180_000;
|
||||||
|
|
||||||
@@ -71,39 +88,56 @@ function visibleAssistantText(text: string) {
|
|||||||
: text;
|
: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleUserText(
|
function visibleUserText(text: string, submittedTurn?: SubmittedPromptTurn) {
|
||||||
text: string,
|
if (submittedTurn) return submittedTurn.request;
|
||||||
submittedPromptRequests: ReadonlyMap<string, string>,
|
|
||||||
) {
|
|
||||||
const submittedRequest = submittedPromptRequests.get(text);
|
|
||||||
if (submittedRequest !== undefined) return submittedRequest;
|
|
||||||
return text.startsWith(QUICK_PANE_ADVISORY_PREFIX) ? "" : text;
|
return text.startsWith(QUICK_PANE_ADVISORY_PREFIX) ? "" : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function transcriptMessages(
|
function transcriptMessages(
|
||||||
snapshot: RuntimeSnapshot,
|
snapshot: RuntimeSnapshot,
|
||||||
submittedPromptRequests: ReadonlyMap<string, string>,
|
submittedPromptTurns: readonly SubmittedPromptTurn[],
|
||||||
): TranscriptMessage[] {
|
): TranscriptMessage[] {
|
||||||
|
let nextSubmittedTurn = 0;
|
||||||
|
let activeTurn: SubmittedPromptTurn | undefined;
|
||||||
return (snapshot.transcript?.data?.messages ?? []).flatMap(
|
return (snapshot.transcript?.data?.messages ?? []).flatMap(
|
||||||
(message, index) => {
|
(message, index) => {
|
||||||
if (message.role !== "user" && message.role !== "assistant") return [];
|
if (message.role !== "user" && message.role !== "assistant") return [];
|
||||||
const text = messageText(message);
|
const text = messageText(message);
|
||||||
|
let submittedTurn: SubmittedPromptTurn | undefined;
|
||||||
|
if (message.role === "user") {
|
||||||
|
const candidate = submittedPromptTurns[nextSubmittedTurn];
|
||||||
|
if (candidate?.guardrails === text) {
|
||||||
|
submittedTurn = candidate;
|
||||||
|
activeTurn = candidate;
|
||||||
|
nextSubmittedTurn += 1;
|
||||||
|
} else activeTurn = undefined;
|
||||||
|
}
|
||||||
const markdown =
|
const markdown =
|
||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
? visibleAssistantText(text)
|
? visibleAssistantText(text)
|
||||||
: visibleUserText(text, submittedPromptRequests);
|
: visibleUserText(text, submittedTurn);
|
||||||
if (!markdown) return [];
|
if (!markdown) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `${index}-${message.role}`,
|
id: `${index}-${message.role}`,
|
||||||
role: message.role,
|
role: message.role,
|
||||||
markdown,
|
markdown,
|
||||||
|
timing: message.role === "assistant" ? activeTurn?.timing : undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function elapsedLabel(startedAt: number, completedAt?: number) {
|
||||||
|
const seconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(((completedAt ?? Date.now()) - startedAt) / 1_000),
|
||||||
|
);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
function snapshotIsActive(snapshot: RuntimeSnapshot) {
|
function snapshotIsActive(snapshot: RuntimeSnapshot) {
|
||||||
return (
|
return (
|
||||||
snapshot.state?.data?.isStreaming === true ||
|
snapshot.state?.data?.isStreaming === true ||
|
||||||
@@ -131,19 +165,17 @@ function settingsError(settings: QuickPaneSettings) {
|
|||||||
|
|
||||||
function currentTurnAssistantText(
|
function currentTurnAssistantText(
|
||||||
snapshot: RuntimeSnapshot,
|
snapshot: RuntimeSnapshot,
|
||||||
submittedPromptRequests: ReadonlyMap<string, string>,
|
submittedPromptTurns: readonly SubmittedPromptTurn[],
|
||||||
) {
|
) {
|
||||||
const messages = snapshot.transcript?.data?.messages ?? [];
|
const messages = snapshot.transcript?.data?.messages ?? [];
|
||||||
|
let nextSubmittedTurn = 0;
|
||||||
let submittedUserIndex = -1;
|
let submittedUserIndex = -1;
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (const [index, message] of messages.entries()) {
|
||||||
const message = messages[index];
|
if (message.role !== "user") continue;
|
||||||
if (
|
const candidate = submittedPromptTurns[nextSubmittedTurn];
|
||||||
message.role === "user" &&
|
if (candidate?.guardrails !== messageText(message)) continue;
|
||||||
submittedPromptRequests.has(messageText(message))
|
submittedUserIndex = index;
|
||||||
) {
|
nextSubmittedTurn += 1;
|
||||||
submittedUserIndex = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (submittedUserIndex === -1) return "";
|
if (submittedUserIndex === -1) return "";
|
||||||
|
|
||||||
@@ -176,6 +208,8 @@ export default function QuickPane() {
|
|||||||
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
|
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||||
const [request, setRequest] = useState("");
|
const [request, setRequest] = useState("");
|
||||||
|
const requestRef = useRef(request);
|
||||||
|
requestRef.current = request;
|
||||||
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 [chatReplacing, setChatReplacing] = useState(false);
|
||||||
@@ -184,6 +218,7 @@ export default function QuickPane() {
|
|||||||
const [catalogStatus, setCatalogStatus] = useState<
|
const [catalogStatus, setCatalogStatus] = useState<
|
||||||
"loading" | "ready" | "error"
|
"loading" | "ready" | "error"
|
||||||
>("loading");
|
>("loading");
|
||||||
|
const [initialCatalogResolved, setInitialCatalogResolved] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
|
const [selectedModel, setSelectedModel] = useState<ModelIdentity>({
|
||||||
provider: "",
|
provider: "",
|
||||||
modelId: "",
|
modelId: "",
|
||||||
@@ -202,6 +237,7 @@ export default function QuickPane() {
|
|||||||
hasAssistantText: false,
|
hasAssistantText: false,
|
||||||
});
|
});
|
||||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||||
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
||||||
const preparedRuntimeRef = useRef<PreparedRuntime | undefined>(undefined);
|
const preparedRuntimeRef = useRef<PreparedRuntime | undefined>(undefined);
|
||||||
const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds);
|
const cleanupQueueRef = useRef(pendingQuickRuntimeCleanupIds);
|
||||||
@@ -212,7 +248,11 @@ export default function QuickPane() {
|
|||||||
const submittedRequestRef = useRef<
|
const submittedRequestRef = useRef<
|
||||||
{ runtimeId: string; request: string } | undefined
|
{ runtimeId: string; request: string } | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const submittedPromptRequestsRef = useRef(new Map<string, string>());
|
const submittedPromptTurnsRef = useRef<SubmittedPromptTurn[]>([]);
|
||||||
|
const nextSubmittedTurnIdRef = useRef(0);
|
||||||
|
const activeSubmittedTurnRef = useRef<SubmittedPromptTurn | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
|
const promptSubmittedRuntimeRef = useRef<string | undefined>(undefined);
|
||||||
const completionHandledRef = useRef(false);
|
const completionHandledRef = useRef(false);
|
||||||
const submissionStartingRef = useRef(false);
|
const submissionStartingRef = useRef(false);
|
||||||
@@ -227,11 +267,27 @@ export default function QuickPane() {
|
|||||||
const catalogReadyRef = useRef(false);
|
const catalogReadyRef = useRef(false);
|
||||||
const selectedModelRef = useRef(selectedModel);
|
const selectedModelRef = useRef(selectedModel);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const inputSelectionRef = useRef<TextSelection | undefined>(undefined);
|
||||||
|
const inputFocusIntentRef = useRef<
|
||||||
|
"keyboard" | "pointer" | "programmatic" | undefined
|
||||||
|
>(undefined);
|
||||||
|
const selectionRestoreFrameRef = useRef<number | undefined>(undefined);
|
||||||
|
const selectionRestorePendingRef = useRef(false);
|
||||||
const paneRef = useRef<HTMLElement>(null);
|
const paneRef = useRef<HTMLElement>(null);
|
||||||
const exchangeRef = useRef<HTMLElement>(null);
|
const exchangeRef = useRef<HTMLElement>(null);
|
||||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
|
const transcriptFollowRef = useRef(true);
|
||||||
|
const transcriptScrollFrameRef = useRef<number | undefined>(undefined);
|
||||||
const resizeFrameRef = useRef<number | undefined>(undefined);
|
const resizeFrameRef = useRef<number | undefined>(undefined);
|
||||||
|
const resizeRetryTimeoutRef = useRef<number | undefined>(undefined);
|
||||||
|
const resizeRetryCountRef = useRef(0);
|
||||||
const requestedPaneHeightRef = useRef<number | undefined>(undefined);
|
const requestedPaneHeightRef = useRef<number | undefined>(undefined);
|
||||||
|
const resizedPaneHeightRef = useRef<number | undefined>(undefined);
|
||||||
|
const resizeInFlightRef = useRef(false);
|
||||||
|
const modelMenuOpenRef = useRef(false);
|
||||||
|
const requestPaneResizeRef = useRef<((height: number) => void) | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const invalid = useMemo(() => settingsError(settings), [settings]);
|
const invalid = useMemo(() => settingsError(settings), [settings]);
|
||||||
const selectedCatalogModel = useMemo(
|
const selectedCatalogModel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -242,10 +298,6 @@ export default function QuickPane() {
|
|||||||
),
|
),
|
||||||
[models, selectedModel],
|
[models, selectedModel],
|
||||||
);
|
);
|
||||||
const thinkingOptions = useMemo(
|
|
||||||
() => thinkingLevelOptions(selectedCatalogModel),
|
|
||||||
[selectedCatalogModel],
|
|
||||||
);
|
|
||||||
const runActive = submissionStarting || requestActive;
|
const runActive = submissionStarting || requestActive;
|
||||||
const activityPhase = submissionStarting
|
const activityPhase = submissionStarting
|
||||||
? "Starting"
|
? "Starting"
|
||||||
@@ -260,6 +312,8 @@ export default function QuickPane() {
|
|||||||
activityPhase === "Thinking"
|
activityPhase === "Thinking"
|
||||||
? `${activityPhase} · ${elapsedSeconds}s`
|
? `${activityPhase} · ${elapsedSeconds}s`
|
||||||
: activityPhase;
|
: activityPhase;
|
||||||
|
const catalogOverlayActive = !initialCatalogResolved;
|
||||||
|
const catalogOverlayError = catalogError ?? error;
|
||||||
const hasExchangeContent =
|
const hasExchangeContent =
|
||||||
messages.length > 0 ||
|
messages.length > 0 ||
|
||||||
runActive ||
|
runActive ||
|
||||||
@@ -278,7 +332,121 @@ export default function QuickPane() {
|
|||||||
);
|
);
|
||||||
}, [selectedCatalogModel]);
|
}, [selectedCatalogModel]);
|
||||||
|
|
||||||
const focusInput = () => inputRef.current?.focus();
|
const recordInputSelection = (input: HTMLTextAreaElement) => {
|
||||||
|
if (
|
||||||
|
selectionRestorePendingRef.current &&
|
||||||
|
inputFocusIntentRef.current !== "pointer"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
inputSelectionRef.current = {
|
||||||
|
value: input.value,
|
||||||
|
start: input.selectionStart,
|
||||||
|
end: input.selectionEnd,
|
||||||
|
direction: input.selectionDirection,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const recordInputChangeSelection = (input: HTMLTextAreaElement) => {
|
||||||
|
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
|
||||||
|
selectionRestoreFrameRef.current = undefined;
|
||||||
|
selectionRestorePendingRef.current = false;
|
||||||
|
recordInputSelection(input);
|
||||||
|
};
|
||||||
|
const restoreInputSelection = useCallback(() => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
const selection = inputSelectionRef.current;
|
||||||
|
if (
|
||||||
|
!input ||
|
||||||
|
!selection ||
|
||||||
|
selection.value !== input.value ||
|
||||||
|
selection.value !== requestRef.current
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const length = input.value.length;
|
||||||
|
const start = Math.min(Math.max(selection.start, 0), length);
|
||||||
|
const end = Math.min(Math.max(selection.end, 0), length);
|
||||||
|
input.setSelectionRange(
|
||||||
|
Math.min(start, end),
|
||||||
|
Math.max(start, end),
|
||||||
|
selection.direction,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
const scheduleInputSelectionRestore = useCallback(() => {
|
||||||
|
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
|
||||||
|
selectionRestorePendingRef.current = true;
|
||||||
|
selectionRestoreFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
selectionRestoreFrameRef.current = undefined;
|
||||||
|
selectionRestorePendingRef.current = false;
|
||||||
|
restoreInputSelection();
|
||||||
|
});
|
||||||
|
}, [restoreInputSelection]);
|
||||||
|
const focusInput = useCallback(() => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input || document.activeElement === input) return;
|
||||||
|
inputFocusIntentRef.current = "programmatic";
|
||||||
|
input.focus();
|
||||||
|
}, []);
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
const intent = inputFocusIntentRef.current ?? "programmatic";
|
||||||
|
inputFocusIntentRef.current = undefined;
|
||||||
|
if (intent !== "pointer") scheduleInputSelectionRestore();
|
||||||
|
};
|
||||||
|
const requestPaneResize = useCallback((height: number) => {
|
||||||
|
if (requestedPaneHeightRef.current !== height)
|
||||||
|
resizeRetryCountRef.current = 0;
|
||||||
|
requestedPaneHeightRef.current = height;
|
||||||
|
if (resizeRetryTimeoutRef.current !== undefined) {
|
||||||
|
window.clearTimeout(resizeRetryTimeoutRef.current);
|
||||||
|
resizeRetryTimeoutRef.current = undefined;
|
||||||
|
}
|
||||||
|
if (resizeInFlightRef.current || resizedPaneHeightRef.current === height)
|
||||||
|
return;
|
||||||
|
|
||||||
|
resizeInFlightRef.current = true;
|
||||||
|
void invoke("resize_quick_pane", { height }).then(
|
||||||
|
() => {
|
||||||
|
resizeInFlightRef.current = false;
|
||||||
|
resizedPaneHeightRef.current = height;
|
||||||
|
const desiredHeight = requestedPaneHeightRef.current;
|
||||||
|
if (desiredHeight !== undefined && desiredHeight !== height)
|
||||||
|
requestPaneResizeRef.current?.(desiredHeight);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
resizeInFlightRef.current = false;
|
||||||
|
const desiredHeight = requestedPaneHeightRef.current;
|
||||||
|
if (desiredHeight === undefined) return;
|
||||||
|
if (desiredHeight !== height) {
|
||||||
|
requestPaneResizeRef.current?.(desiredHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!mountedRef.current ||
|
||||||
|
(modelMenuOpenRef.current && height !== 520) ||
|
||||||
|
resizeRetryCountRef.current >= 1
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
resizeRetryCountRef.current += 1;
|
||||||
|
resizeRetryTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
resizeRetryTimeoutRef.current = undefined;
|
||||||
|
if (
|
||||||
|
mountedRef.current &&
|
||||||
|
requestedPaneHeightRef.current === height &&
|
||||||
|
(!modelMenuOpenRef.current || height === 520)
|
||||||
|
)
|
||||||
|
requestPaneResizeRef.current?.(height);
|
||||||
|
}, 250);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
requestPaneResizeRef.current = requestPaneResize;
|
||||||
|
const handleModelMenuOpen = useCallback(() => {
|
||||||
|
modelMenuOpenRef.current = true;
|
||||||
|
setModelMenuOpen(true);
|
||||||
|
requestPaneResize(520);
|
||||||
|
}, [requestPaneResize]);
|
||||||
|
const handleModelMenuClose = useCallback(() => {
|
||||||
|
modelMenuOpenRef.current = false;
|
||||||
|
setModelMenuOpen(false);
|
||||||
|
}, []);
|
||||||
const closeRuntime = useCallback(async (runtimeId: string) => {
|
const closeRuntime = useCallback(async (runtimeId: string) => {
|
||||||
const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
|
const pending = pendingQuickRuntimeCleanupPromises.get(runtimeId);
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
@@ -365,6 +533,7 @@ export default function QuickPane() {
|
|||||||
const emptyModel = { provider: "", modelId: "" };
|
const emptyModel = { provider: "", modelId: "" };
|
||||||
selectedModelRef.current = emptyModel;
|
selectedModelRef.current = emptyModel;
|
||||||
setSelectedModel(emptyModel);
|
setSelectedModel(emptyModel);
|
||||||
|
setInitialCatalogResolved(true);
|
||||||
setCatalogStatus("ready");
|
setCatalogStatus("ready");
|
||||||
setCatalogNotice("No models are currently available.");
|
setCatalogNotice("No models are currently available.");
|
||||||
return false;
|
return false;
|
||||||
@@ -372,6 +541,7 @@ export default function QuickPane() {
|
|||||||
selectedModelRef.current = reconciled;
|
selectedModelRef.current = reconciled;
|
||||||
setSelectedModel(reconciled);
|
setSelectedModel(reconciled);
|
||||||
catalogReadyRef.current = true;
|
catalogReadyRef.current = true;
|
||||||
|
setInitialCatalogResolved(true);
|
||||||
setCatalogStatus("ready");
|
setCatalogStatus("ready");
|
||||||
setCatalogNotice(
|
setCatalogNotice(
|
||||||
modelIdentityValue(reconciled) === modelIdentityValue(preferred)
|
modelIdentityValue(reconciled) === modelIdentityValue(preferred)
|
||||||
@@ -453,7 +623,9 @@ export default function QuickPane() {
|
|||||||
lastUserQuestionAtRef.current = undefined;
|
lastUserQuestionAtRef.current = undefined;
|
||||||
lastCompletedAnswerAtRef.current = undefined;
|
lastCompletedAnswerAtRef.current = undefined;
|
||||||
submittedRequestRef.current = undefined;
|
submittedRequestRef.current = undefined;
|
||||||
submittedPromptRequestsRef.current.clear();
|
submittedPromptTurnsRef.current = [];
|
||||||
|
nextSubmittedTurnIdRef.current = 0;
|
||||||
|
activeSubmittedTurnRef.current = undefined;
|
||||||
promptSubmittedRuntimeRef.current = undefined;
|
promptSubmittedRuntimeRef.current = undefined;
|
||||||
completionHandledRef.current = false;
|
completionHandledRef.current = false;
|
||||||
runStartedAtRef.current = undefined;
|
runStartedAtRef.current = undefined;
|
||||||
@@ -474,7 +646,10 @@ export default function QuickPane() {
|
|||||||
setCatalogError(undefined);
|
setCatalogError(undefined);
|
||||||
setCatalogNotice(undefined);
|
setCatalogNotice(undefined);
|
||||||
setModels([]);
|
setModels([]);
|
||||||
if (clearDraft) setRequest("");
|
if (clearDraft) {
|
||||||
|
inputSelectionRef.current = undefined;
|
||||||
|
setRequest("");
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const replaceChat = useCallback(
|
const replaceChat = useCallback(
|
||||||
@@ -537,6 +712,10 @@ export default function QuickPane() {
|
|||||||
void loadSettingsAndPrepare();
|
void loadSettingsAndPrepare();
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
|
if (resizeRetryTimeoutRef.current !== undefined)
|
||||||
|
window.clearTimeout(resizeRetryTimeoutRef.current);
|
||||||
|
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
|
||||||
|
selectionRestorePendingRef.current = false;
|
||||||
preparationGenerationRef.current += 1;
|
preparationGenerationRef.current += 1;
|
||||||
const current = runtimeRef.current;
|
const current = runtimeRef.current;
|
||||||
if (current) void closeRuntime(current.runtimeId).catch(() => undefined);
|
if (current) void closeRuntime(current.runtimeId).catch(() => undefined);
|
||||||
@@ -553,13 +732,16 @@ export default function QuickPane() {
|
|||||||
if (!pane || !exchange) return;
|
if (!pane || !exchange) return;
|
||||||
const resizeToContent = () => {
|
const resizeToContent = () => {
|
||||||
window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
|
window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
|
||||||
|
if (modelMenuOpenRef.current) return;
|
||||||
resizeFrameRef.current = window.requestAnimationFrame(() => {
|
resizeFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
if (modelMenuOpenRef.current) return;
|
||||||
const visibleChildren = [...exchange.children].filter(
|
const visibleChildren = [...exchange.children].filter(
|
||||||
(child) => !child.classList.contains("sr-only"),
|
(child) => !child.classList.contains("sr-only"),
|
||||||
);
|
);
|
||||||
const childHeight = visibleChildren.reduce(
|
const childHeight = visibleChildren.reduce(
|
||||||
(total, child) =>
|
(total, child) =>
|
||||||
total + Math.max(child.scrollHeight, child.getBoundingClientRect().height),
|
total +
|
||||||
|
Math.max(child.scrollHeight, child.getBoundingClientRect().height),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const styles = window.getComputedStyle(exchange);
|
const styles = window.getComputedStyle(exchange);
|
||||||
@@ -573,12 +755,12 @@ export default function QuickPane() {
|
|||||||
520,
|
520,
|
||||||
Math.max(
|
Math.max(
|
||||||
240,
|
240,
|
||||||
Math.ceil(pane.clientHeight - exchange.clientHeight + contentHeight),
|
Math.ceil(
|
||||||
|
pane.clientHeight - exchange.clientHeight + contentHeight,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (height === requestedPaneHeightRef.current) return;
|
requestPaneResize(height);
|
||||||
requestedPaneHeightRef.current = height;
|
|
||||||
void invoke("resize_quick_pane", { height }).catch(() => undefined);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (typeof ResizeObserver === "undefined") {
|
if (typeof ResizeObserver === "undefined") {
|
||||||
@@ -594,7 +776,18 @@ export default function QuickPane() {
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
|
window.cancelAnimationFrame(resizeFrameRef.current ?? 0);
|
||||||
};
|
};
|
||||||
}, [activity, catalogError, catalogNotice, error, escalation, messages, notice, responseComplete]);
|
}, [
|
||||||
|
activity,
|
||||||
|
catalogError,
|
||||||
|
catalogNotice,
|
||||||
|
error,
|
||||||
|
escalation,
|
||||||
|
messages,
|
||||||
|
modelMenuOpen,
|
||||||
|
notice,
|
||||||
|
requestPaneResize,
|
||||||
|
responseComplete,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runActive) return;
|
if (!runActive) return;
|
||||||
@@ -631,6 +824,11 @@ export default function QuickPane() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Tab") inputFocusIntentRef.current = "keyboard";
|
||||||
|
if (catalogOverlayActive) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.ctrlKey && event.key.toLowerCase() === "n") {
|
if (event.ctrlKey && event.key.toLowerCase() === "n") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void replaceChat(true, true);
|
void replaceChat(true, true);
|
||||||
@@ -658,7 +856,13 @@ export default function QuickPane() {
|
|||||||
void settingsListener.then((unlisten) => unlisten());
|
void settingsListener.then((unlisten) => unlisten());
|
||||||
void focusListener.then((unlisten) => unlisten());
|
void focusListener.then((unlisten) => unlisten());
|
||||||
};
|
};
|
||||||
}, [dismissPane, prepareRuntime, renewChatOnFocus, replaceChat]);
|
}, [
|
||||||
|
catalogOverlayActive,
|
||||||
|
dismissPane,
|
||||||
|
prepareRuntime,
|
||||||
|
renewChatOnFocus,
|
||||||
|
replaceChat,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!runtime || !requestActive) return;
|
if (!runtime || !requestActive) return;
|
||||||
@@ -673,14 +877,18 @@ export default function QuickPane() {
|
|||||||
if (!active || chatGeneration !== chatGenerationRef.current) return;
|
if (!active || chatGeneration !== chatGenerationRef.current) return;
|
||||||
const text = currentTurnAssistantText(
|
const text = currentTurnAssistantText(
|
||||||
snapshot,
|
snapshot,
|
||||||
submittedPromptRequestsRef.current,
|
submittedPromptTurnsRef.current,
|
||||||
);
|
);
|
||||||
const result = visibleAssistantText(text);
|
const result = visibleAssistantText(text);
|
||||||
const complete =
|
const complete =
|
||||||
!snapshotIsActive(snapshot) &&
|
!snapshotIsActive(snapshot) &&
|
||||||
promptSubmittedRuntimeRef.current === runtime.runtimeId;
|
promptSubmittedRuntimeRef.current === runtime.runtimeId;
|
||||||
|
if (complete) {
|
||||||
|
const timing = activeSubmittedTurnRef.current?.timing;
|
||||||
|
if (timing && !timing.completedAt) timing.completedAt = Date.now();
|
||||||
|
}
|
||||||
setMessages(
|
setMessages(
|
||||||
transcriptMessages(snapshot, submittedPromptRequestsRef.current),
|
transcriptMessages(snapshot, submittedPromptTurnsRef.current),
|
||||||
);
|
);
|
||||||
setActivitySnapshot({
|
setActivitySnapshot({
|
||||||
activeTool: snapshot.runtime.activeTool,
|
activeTool: snapshot.runtime.activeTool,
|
||||||
@@ -702,9 +910,11 @@ export default function QuickPane() {
|
|||||||
setNotice("");
|
setNotice("");
|
||||||
const submitted = submittedRequestRef.current;
|
const submitted = submittedRequestRef.current;
|
||||||
if (submitted?.runtimeId === runtime.runtimeId)
|
if (submitted?.runtimeId === runtime.runtimeId)
|
||||||
setRequest((current) =>
|
setRequest((current) => {
|
||||||
current === submitted.request ? "" : current,
|
if (current !== submitted.request) return current;
|
||||||
);
|
inputSelectionRef.current = undefined;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
if (text.startsWith(ESCALATION_MARKER))
|
if (text.startsWith(ESCALATION_MARKER))
|
||||||
setEscalation(result || "Quick agent requested a full session.");
|
setEscalation(result || "Quick agent requested a full session.");
|
||||||
window.requestAnimationFrame(focusInput);
|
window.requestAnimationFrame(focusInput);
|
||||||
@@ -730,7 +940,6 @@ export default function QuickPane() {
|
|||||||
};
|
};
|
||||||
}, [requestActive, runtime]);
|
}, [requestActive, runtime]);
|
||||||
|
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (submissionStartingRef.current || requestActiveRef.current) return;
|
if (submissionStartingRef.current || requestActiveRef.current) return;
|
||||||
if (invalid || !request.trim())
|
if (invalid || !request.trim())
|
||||||
@@ -764,9 +973,13 @@ export default function QuickPane() {
|
|||||||
|
|
||||||
const submittedRuntime = prepared.runtime;
|
const submittedRuntime = prepared.runtime;
|
||||||
if (chatGeneration !== chatGenerationRef.current) return;
|
if (chatGeneration !== chatGenerationRef.current) return;
|
||||||
submittedRequestRef.current = {
|
const submittedAt = Date.now();
|
||||||
runtimeId: submittedRuntime.runtimeId,
|
const guardrails = `[Quick Pane advisory mode: use at most ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more tool calls, start final response with [ESCALATE] and explain why.]\n${settings.instructions}${USER_REQUEST_DELIMITER}${submittedRequest}`;
|
||||||
|
const submittedTurn = {
|
||||||
|
id: ++nextSubmittedTurnIdRef.current,
|
||||||
|
guardrails,
|
||||||
request: submittedRequest,
|
request: submittedRequest,
|
||||||
|
timing: { startedAt: submittedAt },
|
||||||
};
|
};
|
||||||
promptSubmittedRuntimeRef.current = undefined;
|
promptSubmittedRuntimeRef.current = undefined;
|
||||||
completionHandledRef.current = false;
|
completionHandledRef.current = false;
|
||||||
@@ -777,7 +990,6 @@ export default function QuickPane() {
|
|||||||
setExtension(undefined);
|
setExtension(undefined);
|
||||||
setActivitySnapshot({ hasAssistantText: false });
|
setActivitySnapshot({ hasAssistantText: false });
|
||||||
setNotice("");
|
setNotice("");
|
||||||
const submittedAt = Date.now();
|
|
||||||
lastUserQuestionAtRef.current = submittedAt;
|
lastUserQuestionAtRef.current = submittedAt;
|
||||||
runStartedAtRef.current = submittedAt;
|
runStartedAtRef.current = submittedAt;
|
||||||
setElapsedSeconds(0);
|
setElapsedSeconds(0);
|
||||||
@@ -795,14 +1007,18 @@ export default function QuickPane() {
|
|||||||
level: thinkingLevel,
|
level: thinkingLevel,
|
||||||
});
|
});
|
||||||
if (chatGeneration !== chatGenerationRef.current) return;
|
if (chatGeneration !== chatGenerationRef.current) return;
|
||||||
const guardrails = `[Quick Pane advisory mode: use at most ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more tool calls, start final response with [ESCALATE] and explain why.]\n${settings.instructions}${USER_REQUEST_DELIMITER}${submittedRequest}`;
|
|
||||||
submittedPromptRequestsRef.current.set(guardrails, submittedRequest);
|
|
||||||
await invoke("submit_prompt", {
|
await invoke("submit_prompt", {
|
||||||
agentId: submittedRuntime.agentId,
|
agentId: submittedRuntime.agentId,
|
||||||
message: guardrails,
|
message: guardrails,
|
||||||
});
|
});
|
||||||
if (chatGeneration === chatGenerationRef.current)
|
if (chatGeneration !== chatGenerationRef.current) return;
|
||||||
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
|
submittedRequestRef.current = {
|
||||||
|
runtimeId: submittedRuntime.runtimeId,
|
||||||
|
request: submittedRequest,
|
||||||
|
};
|
||||||
|
submittedPromptTurnsRef.current.push(submittedTurn);
|
||||||
|
activeSubmittedTurnRef.current = submittedTurn;
|
||||||
|
promptSubmittedRuntimeRef.current = submittedRuntime.runtimeId;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (chatGeneration === chatGenerationRef.current) {
|
if (chatGeneration === chatGenerationRef.current) {
|
||||||
requestActiveRef.current = false;
|
requestActiveRef.current = false;
|
||||||
@@ -838,17 +1054,25 @@ export default function QuickPane() {
|
|||||||
request.trim() || submittedRequestRef.current?.request || "";
|
request.trim() || submittedRequestRef.current?.request || "";
|
||||||
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
|
const completedAssistantMessageId = completedAssistantAnswer
|
||||||
? [...messages].reverse().find((message) => message.role === "assistant")?.id
|
? [...messages].reverse().find((message) => message.role === "assistant")
|
||||||
|
?.id
|
||||||
|
: undefined;
|
||||||
|
const streamingAssistantMessageId = runActive
|
||||||
|
? [...messages].reverse().find((message) => message.role === "assistant")
|
||||||
|
?.id
|
||||||
: undefined;
|
: undefined;
|
||||||
const hasExchange =
|
const hasExchange =
|
||||||
messages.length > 0 ||
|
messages.length > 0 ||
|
||||||
responseComplete ||
|
responseComplete ||
|
||||||
submittedRequestRef.current !== undefined;
|
submittedRequestRef.current !== undefined;
|
||||||
|
const hasTranscript = messages.length > 0 || runActive || responseComplete;
|
||||||
const transcriptHandoff = messages
|
const transcriptHandoff = messages
|
||||||
.map((message) =>
|
.map(
|
||||||
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
|
(message) =>
|
||||||
|
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
|
||||||
)
|
)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
|
|
||||||
@@ -877,22 +1101,59 @@ export default function QuickPane() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasTranscript) return;
|
||||||
|
const transcript = transcriptRef.current;
|
||||||
|
if (!transcript) return;
|
||||||
|
transcriptFollowRef.current = true;
|
||||||
|
const trackTranscriptScroll = () => {
|
||||||
|
transcriptFollowRef.current =
|
||||||
|
transcript.scrollHeight -
|
||||||
|
transcript.clientHeight -
|
||||||
|
transcript.scrollTop <=
|
||||||
|
32;
|
||||||
|
};
|
||||||
|
transcript.addEventListener("scroll", trackTranscriptScroll);
|
||||||
|
return () =>
|
||||||
|
transcript.removeEventListener("scroll", trackTranscriptScroll);
|
||||||
|
}, [hasTranscript]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasTranscript || !transcriptFollowRef.current) return;
|
||||||
|
window.cancelAnimationFrame(transcriptScrollFrameRef.current ?? 0);
|
||||||
|
transcriptScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
if (!transcriptFollowRef.current) return;
|
||||||
|
const transcript = transcriptRef.current;
|
||||||
|
if (transcript) transcript.scrollTop = transcript.scrollHeight;
|
||||||
|
});
|
||||||
|
return () =>
|
||||||
|
window.cancelAnimationFrame(transcriptScrollFrameRef.current ?? 0);
|
||||||
|
}, [
|
||||||
|
activity,
|
||||||
|
catalogError,
|
||||||
|
catalogNotice,
|
||||||
|
error,
|
||||||
|
escalation,
|
||||||
|
extension,
|
||||||
|
hasTranscript,
|
||||||
|
messages,
|
||||||
|
notice,
|
||||||
|
responseComplete,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="quick-pane" ref={paneRef}>
|
<main className="quick-pane" ref={paneRef}>
|
||||||
<div
|
<div
|
||||||
className="quick-pane-drag-strip"
|
className="quick-pane-drag-strip"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onPointerDown={() =>
|
onPointerDown={() =>
|
||||||
void getCurrentWindow().startDragging().catch(() => undefined)
|
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" && (
|
|
||||||
<span className="quick-pane-model-loading" role="status">
|
|
||||||
Loading models…
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
className="quick-pane-model-selector"
|
className="quick-pane-model-selector"
|
||||||
placement="down"
|
placement="down"
|
||||||
@@ -901,7 +1162,11 @@ export default function QuickPane() {
|
|||||||
provider: selectedModel.provider,
|
provider: selectedModel.provider,
|
||||||
id: selectedModel.modelId,
|
id: selectedModel.modelId,
|
||||||
}}
|
}}
|
||||||
disabled={runActive || catalogStatus !== "ready"}
|
disabled={
|
||||||
|
catalogOverlayActive || runActive || catalogStatus !== "ready"
|
||||||
|
}
|
||||||
|
onOpen={handleModelMenuOpen}
|
||||||
|
onClose={handleModelMenuClose}
|
||||||
onSelect={async (model) => {
|
onSelect={async (model) => {
|
||||||
const identity = modelIdentity(model);
|
const identity = modelIdentity(model);
|
||||||
if (!identity) return false;
|
if (!identity) return false;
|
||||||
@@ -913,24 +1178,13 @@ export default function QuickPane() {
|
|||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label className="quick-pane-field quick-pane-thinking">
|
<ThinkingSelector
|
||||||
<span className="full-label">Thinking</span>
|
className="quick-pane-thinking"
|
||||||
<span className="compact-label" aria-hidden="true">
|
model={selectedCatalogModel}
|
||||||
T
|
value={thinkingLevel}
|
||||||
</span>
|
disabled={catalogOverlayActive || runActive || chatReplacing}
|
||||||
<select
|
onChange={setThinkingLevel}
|
||||||
aria-label="Thinking"
|
/>
|
||||||
value={thinkingLevel}
|
|
||||||
disabled={runActive || chatReplacing}
|
|
||||||
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
|
||||||
>
|
|
||||||
{thinkingOptions.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="quick-pane-header-actions">
|
<div className="quick-pane-header-actions">
|
||||||
<button
|
<button
|
||||||
@@ -938,6 +1192,7 @@ export default function QuickPane() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Close Quick Pane"
|
aria-label="Close Quick Pane"
|
||||||
title="Close Quick Pane (Esc)"
|
title="Close Quick Pane (Esc)"
|
||||||
|
disabled={catalogOverlayActive}
|
||||||
onClick={dismissPane}
|
onClick={dismissPane}
|
||||||
>
|
>
|
||||||
<span className="full-label">Close</span>
|
<span className="full-label">Close</span>
|
||||||
@@ -955,9 +1210,23 @@ export default function QuickPane() {
|
|||||||
autoFocus
|
autoFocus
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
readOnly={runActive}
|
readOnly={runActive}
|
||||||
|
disabled={catalogOverlayActive}
|
||||||
aria-busy={runActive}
|
aria-busy={runActive}
|
||||||
value={request}
|
value={request}
|
||||||
onChange={(event) => setRequest(event.currentTarget.value)}
|
onPointerDown={() => {
|
||||||
|
inputFocusIntentRef.current = "pointer";
|
||||||
|
window.cancelAnimationFrame(selectionRestoreFrameRef.current ?? 0);
|
||||||
|
selectionRestoreFrameRef.current = undefined;
|
||||||
|
selectionRestorePendingRef.current = false;
|
||||||
|
}}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={(event) => recordInputSelection(event.currentTarget)}
|
||||||
|
onSelect={(event) => recordInputSelection(event.currentTarget)}
|
||||||
|
onInput={(event) => recordInputChangeSelection(event.currentTarget)}
|
||||||
|
onChange={(event) => {
|
||||||
|
recordInputChangeSelection(event.currentTarget);
|
||||||
|
setRequest(event.currentTarget.value);
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (
|
if (
|
||||||
event.key === "Enter" &&
|
event.key === "Enter" &&
|
||||||
@@ -970,7 +1239,10 @@ 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">
|
<div
|
||||||
|
className="quick-pane-composer-actions"
|
||||||
|
aria-label="Quick chat actions"
|
||||||
|
>
|
||||||
{requestActive && (
|
{requestActive && (
|
||||||
<button
|
<button
|
||||||
className="quick-pane-action"
|
className="quick-pane-action"
|
||||||
@@ -1019,7 +1291,7 @@ export default function QuickPane() {
|
|||||||
aria-label="Quick exchange"
|
aria-label="Quick exchange"
|
||||||
ref={exchangeRef}
|
ref={exchangeRef}
|
||||||
>
|
>
|
||||||
{(messages.length > 0 || runActive || responseComplete) && (
|
{hasTranscript && (
|
||||||
<div
|
<div
|
||||||
className="quick-pane-transcript"
|
className="quick-pane-transcript"
|
||||||
ref={transcriptRef}
|
ref={transcriptRef}
|
||||||
@@ -1028,8 +1300,15 @@ export default function QuickPane() {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-busy={runActive}
|
aria-busy={runActive}
|
||||||
>
|
>
|
||||||
{messages.map((message) =>
|
{messages.map((message) => {
|
||||||
message.role === "user" ? (
|
const elapsed =
|
||||||
|
message.timing && (message.timing.completedAt || runActive)
|
||||||
|
? elapsedLabel(
|
||||||
|
message.timing.startedAt,
|
||||||
|
message.timing.completedAt,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return message.role === "user" ? (
|
||||||
<div className="quick-pane-user-row" key={message.id}>
|
<div className="quick-pane-user-row" key={message.id}>
|
||||||
<article className="quick-pane-user-message">
|
<article className="quick-pane-user-message">
|
||||||
<span className="message-author">You</span>
|
<span className="message-author">You</span>
|
||||||
@@ -1047,6 +1326,16 @@ export default function QuickPane() {
|
|||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
{message.markdown}
|
{message.markdown}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
{elapsed && (
|
||||||
|
<span className="quick-pane-assistant-elapsed">
|
||||||
|
{elapsed}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{message.id === streamingAssistantMessageId && activity && (
|
||||||
|
<span className="quick-pane-assistant-activity">
|
||||||
|
{activity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{message.id === completedAssistantMessageId &&
|
{message.id === completedAssistantMessageId &&
|
||||||
!escalation &&
|
!escalation &&
|
||||||
canContinue && (
|
canContinue && (
|
||||||
@@ -1057,9 +1346,9 @@ export default function QuickPane() {
|
|||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
),
|
);
|
||||||
)}
|
})}
|
||||||
{activity && (
|
{activity && !streamingAssistantMessageId && (
|
||||||
<div className="quick-pane-activity" aria-hidden="true">
|
<div className="quick-pane-activity" aria-hidden="true">
|
||||||
<span className="typing-dots">
|
<span className="typing-dots">
|
||||||
<i />
|
<i />
|
||||||
@@ -1094,7 +1383,7 @@ export default function QuickPane() {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
{error && (
|
{error && initialCatalogResolved && (
|
||||||
<aside className="quick-pane-notice error" role="alert">
|
<aside className="quick-pane-notice error" role="alert">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
{settingsLoaded ? (
|
{settingsLoaded ? (
|
||||||
@@ -1115,7 +1404,7 @@ export default function QuickPane() {
|
|||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
{catalogError && (
|
{catalogError && initialCatalogResolved && (
|
||||||
<aside className="quick-pane-notice error" role="alert">
|
<aside className="quick-pane-notice error" role="alert">
|
||||||
<p>{catalogError}</p>
|
<p>{catalogError}</p>
|
||||||
<button
|
<button
|
||||||
@@ -1126,7 +1415,7 @@ export default function QuickPane() {
|
|||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
{notice && (
|
{notice && initialCatalogResolved && (
|
||||||
<p className="quick-pane-system-notice" role="status">
|
<p className="quick-pane-system-notice" role="status">
|
||||||
{notice}
|
{notice}
|
||||||
</p>
|
</p>
|
||||||
@@ -1138,6 +1427,35 @@ export default function QuickPane() {
|
|||||||
)}
|
)}
|
||||||
<ExtensionDialog extension={extension} onRespond={respond} />
|
<ExtensionDialog extension={extension} onRespond={respond} />
|
||||||
</section>
|
</section>
|
||||||
|
{catalogOverlayActive && (
|
||||||
|
<aside
|
||||||
|
className="quick-pane-catalog-overlay"
|
||||||
|
role={catalogOverlayError ? "alertdialog" : "dialog"}
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Model catalog"
|
||||||
|
>
|
||||||
|
{catalogOverlayError ? (
|
||||||
|
<>
|
||||||
|
<p>{catalogOverlayError}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (catalogError)
|
||||||
|
void prepareRuntime(selectedModelRef.current);
|
||||||
|
else void loadSettingsAndPrepare();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="quick-pane-catalog-spinner" aria-hidden="true" />
|
||||||
|
<span role="status">Loading models…</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane {
|
.quick-pane {
|
||||||
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -53,7 +54,12 @@ body {
|
|||||||
cursor: move;
|
cursor: move;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
border-bottom: 1px solid #70522f;
|
border-bottom: 1px solid #70522f;
|
||||||
background: linear-gradient(90deg, transparent, rgba(240, 163, 71, 0.14), transparent);
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(240, 163, 71, 0.14),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-drag-strip::after {
|
.quick-pane-drag-strip::after {
|
||||||
@@ -107,13 +113,43 @@ body {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-pane-model-loading {
|
.quick-pane-catalog-overlay {
|
||||||
flex: 0 0 auto;
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 24px;
|
||||||
color: #f1e4d1;
|
color: #f1e4d1;
|
||||||
font-size: 11px;
|
background: rgba(34, 34, 34, 0.98);
|
||||||
|
border-radius: inherit;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-pane-catalog-overlay p {
|
||||||
|
max-width: 440px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-catalog-spinner {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 3px solid rgba(240, 163, 71, 0.28);
|
||||||
|
border-top-color: #f0a347;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: quick-pane-catalog-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes quick-pane-catalog-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.quick-pane .model-trigger {
|
.quick-pane .model-trigger {
|
||||||
height: 27px;
|
height: 27px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -299,6 +335,22 @@ body {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-pane-assistant-elapsed,
|
||||||
|
.quick-pane-assistant-activity {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #96918b;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-assistant-elapsed {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-pane-assistant-activity {
|
||||||
|
color: #c5beb5;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-pane-assistant-footer {
|
.quick-pane-assistant-footer {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
border-top: 1px solid #484848;
|
border-top: 1px solid #484848;
|
||||||
|
|||||||
Reference in New Issue
Block a user