feat(quick-pane): promote active runtime
This commit is contained in:
@@ -228,6 +228,17 @@ pub async fn create_quick_runtime(
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn promote_quick_runtime(
|
||||
socket_path: &str,
|
||||
runtime_id: &str,
|
||||
) -> Result<RuntimeResult, String> {
|
||||
typed_request(
|
||||
socket_path,
|
||||
"promote_quick_runtime",
|
||||
Some(json!({ "runtimeId": runtime_id })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn open_session_runtime(
|
||||
socket_path: &str,
|
||||
worktree_path: &str,
|
||||
|
||||
+101
-1
@@ -41,6 +41,7 @@ impl ManagedWindowVisibility {
|
||||
}
|
||||
// One mutex serializes cross-window transitions, avoiding nested visibility-lock ordering.
|
||||
struct WindowVisibility(Mutex<ManagedWindowVisibility>);
|
||||
struct PendingPromotedRuntime(Mutex<Option<String>>);
|
||||
struct WorkspaceSubscription {
|
||||
generation: Arc<bridge::SubscriptionGeneration>,
|
||||
task: Mutex<Option<JoinHandle<()>>>,
|
||||
@@ -96,7 +97,10 @@ fn should_hide_close_request(label: &str) -> bool {
|
||||
label == "main" || label == "quick-pane"
|
||||
}
|
||||
|
||||
fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
|
||||
fn get_or_create_main_with_visibility(
|
||||
app: &AppHandle,
|
||||
visible_when_created: bool,
|
||||
) -> Result<(WebviewWindow, bool), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
return Ok((window, false));
|
||||
}
|
||||
@@ -109,11 +113,16 @@ fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String>
|
||||
.ok_or_else(|| "Main window configuration is missing".to_owned())?;
|
||||
let window = WebviewWindowBuilder::from_config(app, config)
|
||||
.map_err(|error| error.to_string())?
|
||||
.visible(visible_when_created)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((window, true))
|
||||
}
|
||||
|
||||
fn get_or_create_main(app: &AppHandle) -> Result<(WebviewWindow, bool), String> {
|
||||
get_or_create_main_with_visibility(app, true)
|
||||
}
|
||||
|
||||
fn apply_main_window_action(app: &AppHandle, args: &[String]) {
|
||||
let (window, created) = match get_or_create_main(app) {
|
||||
Ok(result) => result,
|
||||
@@ -174,6 +183,44 @@ fn get_or_create_quick_pane(app: &AppHandle) -> Result<(WebviewWindow, bool), St
|
||||
Ok((window, true))
|
||||
}
|
||||
|
||||
fn show_main_for_promoted_runtime(app: &AppHandle, runtime_id: String) -> Result<(), String> {
|
||||
// A recreated Main starts hidden, so Quick is never visible with it.
|
||||
let (window, created) = get_or_create_main_with_visibility(app, false)?;
|
||||
let state = app
|
||||
.try_state::<WindowVisibility>()
|
||||
.ok_or_else(|| "Window visibility state is unavailable".to_owned())?;
|
||||
let pending = app
|
||||
.try_state::<PendingPromotedRuntime>()
|
||||
.ok_or_else(|| "Promoted runtime state is unavailable".to_owned())?;
|
||||
let mut visibility = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Could not update window visibility".to_owned())?;
|
||||
let mut pending = pending
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Could not queue promoted runtime".to_owned())?;
|
||||
if created {
|
||||
visibility.main = false;
|
||||
}
|
||||
if let Some(quick_pane) = app.get_webview_window("quick-pane") {
|
||||
quick_pane
|
||||
.hide()
|
||||
.map_err(|error| format!("Could not hide Quick Pane before promotion: {error}"))?;
|
||||
}
|
||||
visibility.hidden("quick-pane");
|
||||
// Retain this on show failure; a later Main startup can consume it.
|
||||
*pending = Some(runtime_id);
|
||||
visibility.main = false;
|
||||
window
|
||||
.show()
|
||||
.map_err(|error| format!("Could not show Main after hiding Quick Pane: {error}"))?;
|
||||
visibility.main_shown();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle_quick_pane(app: &AppHandle) {
|
||||
let (window, created) = match get_or_create_quick_pane(app) {
|
||||
Ok(result) => result,
|
||||
@@ -333,6 +380,45 @@ async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeRe
|
||||
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn promote_quick_runtime(
|
||||
app: AppHandle,
|
||||
runtime_id: String,
|
||||
) -> Result<bridge::RuntimeResult, String> {
|
||||
let result = bridge::promote_quick_runtime(&socket_path()?, &runtime_id).await?;
|
||||
show_main_for_promoted_runtime(&app, result.runtime.runtime_id.clone())?;
|
||||
let _ = app.emit("workspace-promoted-runtime", json!({ "runtimeId": result.runtime.runtime_id }));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_pending_promoted_runtime(
|
||||
pending: State<'_, PendingPromotedRuntime>,
|
||||
) -> Result<Option<String>, String> {
|
||||
pending
|
||||
.0
|
||||
.lock()
|
||||
.map(|pending| pending.clone())
|
||||
.map_err(|_| "Could not read promoted runtime".to_owned())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn consume_pending_promoted_runtime(
|
||||
runtime_id: String,
|
||||
pending: State<'_, PendingPromotedRuntime>,
|
||||
) -> Result<bool, String> {
|
||||
let mut pending = pending
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "Could not consume promoted runtime".to_owned())?;
|
||||
if pending.as_deref() == Some(runtime_id.as_str()) {
|
||||
*pending = None;
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_session_runtime(
|
||||
worktree_path: String,
|
||||
@@ -582,6 +668,7 @@ pub fn run() {
|
||||
main: true,
|
||||
quick_pane: false,
|
||||
})))
|
||||
.manage(PendingPromotedRuntime(Mutex::new(None)))
|
||||
.manage(WorkspaceSubscription {
|
||||
generation: Arc::new(bridge::SubscriptionGeneration::default()),
|
||||
task: Mutex::new(None),
|
||||
@@ -628,6 +715,9 @@ pub fn run() {
|
||||
dismiss_quick_pane,
|
||||
create_session_runtime,
|
||||
create_quick_runtime,
|
||||
promote_quick_runtime,
|
||||
get_pending_promoted_runtime,
|
||||
consume_pending_promoted_runtime,
|
||||
open_session_runtime,
|
||||
close_session_runtime,
|
||||
close_quick_runtime,
|
||||
@@ -742,6 +832,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promoted_runtime_pending_id_clears_only_after_matching_acknowledgement() {
|
||||
let pending = PendingPromotedRuntime(Mutex::new(Some("runtime-a".to_owned())));
|
||||
let mut value = pending.0.lock().unwrap();
|
||||
assert_ne!(value.as_deref(), Some("runtime-b"));
|
||||
assert_eq!(value.as_deref(), Some("runtime-a"));
|
||||
*value = None;
|
||||
assert_eq!(value.as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissed_quick_pane_reopens_with_one_toggle() {
|
||||
let mut visibility = ManagedWindowVisibility {
|
||||
|
||||
@@ -337,7 +337,7 @@ export function ConversationWorkspace({
|
||||
pending={isModelUpdating || locked}
|
||||
/>
|
||||
<ThinkingSelector
|
||||
model={selectedThinkingModel}
|
||||
model={selectedThinkingModel ?? runtime.state.model}
|
||||
value={runtime.state.thinkingLevel ?? "medium"}
|
||||
disabled={isModelUpdating || locked}
|
||||
onChange={(level) => void onThinking(level)}
|
||||
|
||||
@@ -6,7 +6,15 @@ it("uses selected model thinking capabilities and reconciles unavailable values"
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ThinkingSelector
|
||||
model={{ thinkingLevelMap: { off: "off", high: "high" } }}
|
||||
model={{
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: null,
|
||||
high: "high",
|
||||
},
|
||||
}}
|
||||
value="medium"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
@@ -15,7 +23,9 @@ it("uses selected model thinking capabilities and reconciles unavailable values"
|
||||
const selector = screen.getByRole("combobox", { name: "Thinking" });
|
||||
expect(selector).toHaveValue("off");
|
||||
expect(
|
||||
within(selector).getAllByRole("option").map((option) => option.textContent),
|
||||
within(selector)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.textContent),
|
||||
).toEqual(["Off", "High"]);
|
||||
fireEvent.change(selector, { target: { value: "high" } });
|
||||
expect(onChange).toHaveBeenCalledWith("high");
|
||||
|
||||
@@ -178,6 +178,35 @@ describe("multi-session navigation", () => {
|
||||
await user.click(screen.getByText("Tool result · read"));
|
||||
expect(details).toHaveAttribute("open");
|
||||
});
|
||||
it("uses omitted-reasoning state model metadata when catalog lacks current model", () => {
|
||||
const runtime = runtimeView(summary("thinking"));
|
||||
runtime.state = {
|
||||
model: {
|
||||
provider: "provider",
|
||||
id: "current",
|
||||
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||
},
|
||||
};
|
||||
render(
|
||||
<ConversationWorkspace
|
||||
runtime={runtime}
|
||||
onDraft={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
onScroll={vi.fn()}
|
||||
onModel={vi.fn()}
|
||||
onThinking={vi.fn()}
|
||||
onCommand={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
Array.from(
|
||||
screen
|
||||
.getByRole("combobox", { name: "Thinking" })
|
||||
.querySelectorAll("option"),
|
||||
).map((option) => option.value),
|
||||
).toEqual(["off", "minimal", "low", "medium", "xhigh"]);
|
||||
});
|
||||
it("packs session status between model controls and the composer", () => {
|
||||
const runtime = runtimeView(summary("status", "streaming"));
|
||||
runtime.summary.queueCount = 2;
|
||||
|
||||
@@ -6,29 +6,60 @@ import {
|
||||
} from "./model-catalog";
|
||||
|
||||
describe("thinking model catalog metadata", () => {
|
||||
it("uses catalog thinking levels and reconciles removed selections", () => {
|
||||
const model = {
|
||||
provider: "provider",
|
||||
id: "reasoning",
|
||||
thinkingLevelMap: { off: "off", low: "low", high: "high" },
|
||||
};
|
||||
expect(thinkingLevelOptions(model)).toEqual([
|
||||
it("matches Pi reasoning defaults, null exclusions, and explicit max", () => {
|
||||
expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]);
|
||||
expect(thinkingLevelOptions({ reasoning: false })).toEqual([
|
||||
{ value: "off", label: "Off" },
|
||||
]);
|
||||
expect(thinkingLevelOptions({})).toEqual([
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "minimal", label: "Minimal" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
]);
|
||||
expect(reconcileThinkingLevel(model, "high")).toBe("high");
|
||||
expect(reconcileThinkingLevel(model, "medium")).toBe("off");
|
||||
const mapped = {
|
||||
thinkingLevelMap: {
|
||||
off: null,
|
||||
low: null,
|
||||
xhigh: "xhigh",
|
||||
max: "max",
|
||||
},
|
||||
};
|
||||
expect(thinkingLevelOptions(mapped)).toEqual([
|
||||
{ value: "minimal", label: "Minimal" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "XHigh" },
|
||||
{ value: "max", label: "Max" },
|
||||
]);
|
||||
expect(reconcileThinkingLevel(mapped, "off")).toBe("minimal");
|
||||
expect(reconcileThinkingLevel(mapped, "xhigh")).toBe("xhigh");
|
||||
const unavailable = {
|
||||
thinkingLevelMap: {
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: null,
|
||||
medium: null,
|
||||
high: null,
|
||||
xhigh: null,
|
||||
},
|
||||
};
|
||||
expect(reconcileThinkingLevel(unavailable, "high")).toBe("off");
|
||||
});
|
||||
|
||||
it("keeps unknown and dynamically configured local models at Off", () => {
|
||||
expect(thinkingLevelOptions()).toEqual([{ value: "off", label: "Off" }]);
|
||||
const [dynamic] = catalogModels([], [
|
||||
{ provider: "localai", modelId: "local/unknown" },
|
||||
]);
|
||||
expect(dynamic.thinkingLevelMap).toEqual({ off: "off" });
|
||||
it("treats dynamically configured models with omitted reasoning as capable", () => {
|
||||
const [dynamic] = catalogModels(
|
||||
[],
|
||||
[{ provider: "localai", modelId: "local/unknown" }],
|
||||
);
|
||||
expect(dynamic.thinkingLevelMap).toBeUndefined();
|
||||
expect(thinkingLevelOptions(dynamic)).toEqual([
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "minimal", label: "Minimal" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+31
-13
@@ -3,8 +3,21 @@ import type { Model } from "./workspace/types";
|
||||
export type ModelIdentity = { provider: string; modelId: string };
|
||||
export type ThinkingLevelOption = { value: string; label: string };
|
||||
|
||||
const DEFAULT_THINKING_LEVELS = [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
] as const;
|
||||
const OFF_THINKING_LEVEL: ThinkingLevelOption = { value: "off", label: "Off" };
|
||||
|
||||
function thinkingLevelLabel(level: string) {
|
||||
if (level === "xhigh") return "XHigh";
|
||||
return level[0].toUpperCase() + level.slice(1);
|
||||
}
|
||||
|
||||
export function modelIdentity(model: Model): ModelIdentity | undefined {
|
||||
if (!model.provider || !model.id) return undefined;
|
||||
return { provider: model.provider, modelId: model.id };
|
||||
@@ -49,22 +62,29 @@ export function reconcileModelIdentity(
|
||||
}
|
||||
|
||||
export function thinkingLevelOptions(model?: Model): ThinkingLevelOption[] {
|
||||
const map = model?.thinkingLevelMap;
|
||||
if (!map || typeof map !== "object") return [OFF_THINKING_LEVEL];
|
||||
const options = Object.keys(map)
|
||||
.filter((level) => level.trim() && typeof map[level] === "string")
|
||||
.map((level) => ({
|
||||
value: level,
|
||||
label: level === "off" ? "Off" : level[0].toUpperCase() + level.slice(1),
|
||||
}));
|
||||
return options.length ? options : [OFF_THINKING_LEVEL];
|
||||
if (!model || model.reasoning === false) return [OFF_THINKING_LEVEL];
|
||||
const map = model.thinkingLevelMap;
|
||||
const levels = [
|
||||
...DEFAULT_THINKING_LEVELS.filter((level) => {
|
||||
if (level === "xhigh") return map?.xhigh !== undefined && map.xhigh !== null;
|
||||
return map?.[level] !== null;
|
||||
}),
|
||||
...(map?.max == null ? [] : ["max"]),
|
||||
];
|
||||
return levels.length
|
||||
? levels.map((value) => ({ value, label: thinkingLevelLabel(value) }))
|
||||
: [OFF_THINKING_LEVEL];
|
||||
}
|
||||
|
||||
export function reconcileThinkingLevel(model: Model | undefined, level: string) {
|
||||
export function reconcileThinkingLevel(
|
||||
model: Model | undefined,
|
||||
level: string,
|
||||
) {
|
||||
const options = thinkingLevelOptions(model);
|
||||
return options.some((option) => option.value === level)
|
||||
? level
|
||||
: options.find((option) => option.value === "off")?.value ?? options[0].value;
|
||||
: (options.find((option) => option.value === "off")?.value ??
|
||||
options[0].value);
|
||||
}
|
||||
|
||||
export function catalogModels(
|
||||
@@ -85,8 +105,6 @@ export function catalogModels(
|
||||
available.unshift({
|
||||
provider: identity.provider,
|
||||
id: identity.modelId,
|
||||
// Configured/local models are not catalog-confirmed. Do not imply thinking support.
|
||||
thinkingLevelMap: { off: "off" },
|
||||
});
|
||||
keys.add(key);
|
||||
}
|
||||
|
||||
@@ -389,7 +389,8 @@ describe("QuickPane", () => {
|
||||
provider: "provider",
|
||||
id: "quick/model",
|
||||
name: "Quick Model",
|
||||
thinkingLevelMap: { off: "off", high: "high" },
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high" },
|
||||
},
|
||||
];
|
||||
bridgeMock([thinkingModels], () =>
|
||||
@@ -663,23 +664,13 @@ describe("QuickPane", () => {
|
||||
expect(response.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("shows manual continuation after a completed normal answer and hands off transcript", async () => {
|
||||
it("promotes completed quick runtime without copying a handoff", async () => {
|
||||
bridgeMock([defaultModels], () =>
|
||||
snapshotWithTranscript([
|
||||
{ role: "user", content: "Inspect" },
|
||||
{ role: "assistant", content: "Completed answer" },
|
||||
]),
|
||||
);
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "create_session_runtime")
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-full", runtimeId: "runtime-full" },
|
||||
});
|
||||
return baseImplementation?.(command, payload);
|
||||
},
|
||||
);
|
||||
|
||||
render(<QuickPane />);
|
||||
await preparedTrigger();
|
||||
@@ -690,19 +681,26 @@ describe("QuickPane", () => {
|
||||
expect(
|
||||
continueButton.closest(".quick-pane-assistant-footer"),
|
||||
).not.toBeNull();
|
||||
|
||||
const submittedPromptCount = mocks.invoke.mock.calls.filter(
|
||||
([command]) => command === "submit_prompt",
|
||||
).length;
|
||||
await userEvent.click(continueButton);
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("promote_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
}),
|
||||
);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane");
|
||||
expect(mocks.invoke).not.toHaveBeenCalledWith(
|
||||
"create_session_runtime",
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
await userEvent.click(continueButton);
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
|
||||
agentId: "agent-full",
|
||||
message:
|
||||
"Inspect\n\nQuick Pane handoff: Continue this chat in a full session.\n\nQuick Pane transcript:\nUser: Inspect\n\nAssistant: Completed answer",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
mocks.invoke.mock.calls.filter(
|
||||
([command]) => command === "submit_prompt",
|
||||
),
|
||||
).toHaveLength(submittedPromptCount);
|
||||
});
|
||||
|
||||
it("keeps unlimited follow-ups on one runtime and renders snapshot messages in order", async () => {
|
||||
@@ -988,10 +986,6 @@ describe("QuickPane", () => {
|
||||
const baseImplementation = mocks.invoke.getMockImplementation();
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, payload?: Record<string, unknown>) => {
|
||||
if (command === "create_session_runtime")
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent-full", runtimeId: "runtime-full" },
|
||||
});
|
||||
if (command === "submit_prompt" && payload?.agentId === "agent-1")
|
||||
submittedPrompts.push(String(payload.message));
|
||||
return baseImplementation?.(command, payload);
|
||||
@@ -1011,18 +1005,18 @@ describe("QuickPane", () => {
|
||||
await screen.findByText("Second runtime failed."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("First answer")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("create_session_runtime", {
|
||||
worktreePath: "/repo",
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("status")).getByRole("button", {
|
||||
name: "Continue",
|
||||
}),
|
||||
);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("submit_prompt", {
|
||||
agentId: "agent-full",
|
||||
message:
|
||||
"Second\n\nQuick Pane handoff: Quick runtime failed. Continue in a full session if needed.\n\nQuick Pane transcript:\nUser: First\n\nAssistant: First answer\n\nUser: Second",
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("promote_quick_runtime", {
|
||||
runtimeId: "runtime-1",
|
||||
}),
|
||||
);
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("dismiss_quick_pane");
|
||||
});
|
||||
|
||||
it("maps the exact submitted payload to the entered multiline request despite delimiter collisions", async () => {
|
||||
|
||||
@@ -213,6 +213,7 @@ export default function QuickPane() {
|
||||
const [submissionStarting, setSubmissionStarting] = useState(false);
|
||||
const [requestActive, setRequestActive] = useState(false);
|
||||
const [chatReplacing, setChatReplacing] = useState(false);
|
||||
const [promoting, setPromoting] = useState(false);
|
||||
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [catalogStatus, setCatalogStatus] = useState<
|
||||
@@ -229,7 +230,6 @@ export default function QuickPane() {
|
||||
const [catalogNotice, setCatalogNotice] = useState<string>();
|
||||
const [runtime, setRuntime] = useState<Runtime>();
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [responseComplete, setResponseComplete] = useState(false);
|
||||
const [escalation, setEscalation] = useState<string>();
|
||||
const [extension, setExtension] = useState<Extension>();
|
||||
@@ -634,7 +634,6 @@ export default function QuickPane() {
|
||||
setRequestActive(false);
|
||||
setSubmissionStarting(false);
|
||||
setMessages([]);
|
||||
setAnswer("");
|
||||
setResponseComplete(false);
|
||||
setEscalation(undefined);
|
||||
setExtension(undefined);
|
||||
@@ -894,8 +893,6 @@ export default function QuickPane() {
|
||||
activeTool: snapshot.runtime.activeTool,
|
||||
hasAssistantText: !!text,
|
||||
});
|
||||
if (promptSubmittedRuntimeRef.current === runtime.runtimeId)
|
||||
setAnswer(result);
|
||||
setExtension(snapshot.extensions[0]);
|
||||
if (
|
||||
complete &&
|
||||
@@ -983,7 +980,6 @@ export default function QuickPane() {
|
||||
};
|
||||
promptSubmittedRuntimeRef.current = undefined;
|
||||
completionHandledRef.current = false;
|
||||
setAnswer("");
|
||||
setResponseComplete(false);
|
||||
setError(undefined);
|
||||
setEscalation(undefined);
|
||||
@@ -1050,9 +1046,7 @@ export default function QuickPane() {
|
||||
}
|
||||
}
|
||||
|
||||
const handoffRequest =
|
||||
request.trim() || submittedRequestRef.current?.request || "";
|
||||
const canContinue = !invalid && !!handoffRequest;
|
||||
const canContinue = !invalid && !!runtime && !promoting;
|
||||
const completedAssistantAnswer =
|
||||
responseComplete &&
|
||||
messages.some((message) => message.role === "assistant");
|
||||
@@ -1069,35 +1063,22 @@ export default function QuickPane() {
|
||||
responseComplete ||
|
||||
submittedRequestRef.current !== undefined;
|
||||
const hasTranscript = messages.length > 0 || runActive || responseComplete;
|
||||
const transcriptHandoff = messages
|
||||
.map(
|
||||
(message) =>
|
||||
`${message.role === "user" ? "User" : "Assistant"}: ${message.markdown}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
|
||||
async function continueFull() {
|
||||
if (invalid || !handoffRequest)
|
||||
return setError(invalid ?? "Enter a request.");
|
||||
const current = runtimeRef.current;
|
||||
if (invalid || !current) return setError(invalid ?? "Quick runtime is unavailable.");
|
||||
setPromoting(true);
|
||||
try {
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
"create_session_runtime",
|
||||
{
|
||||
worktreePath: settings.workspace,
|
||||
},
|
||||
);
|
||||
await invoke("set_model", {
|
||||
agentId: result.runtime.agentId,
|
||||
provider: settings.followUpProvider,
|
||||
modelId: settings.followUpModel,
|
||||
await invoke<{ runtime: Runtime }>("promote_quick_runtime", {
|
||||
runtimeId: current.runtimeId,
|
||||
});
|
||||
await invoke("submit_prompt", {
|
||||
agentId: result.runtime.agentId,
|
||||
message: `${handoffRequest}\n\nQuick Pane handoff: ${escalation ?? "Continue this chat in a full session."}\n\nQuick Pane transcript:\n${transcriptHandoff || answer}`,
|
||||
});
|
||||
setNotice("Full session started with follow-up model.");
|
||||
cleanupQueueRef.current.delete(current.runtimeId);
|
||||
clearRuntimeIfCurrent(current.runtimeId);
|
||||
await dismissPane();
|
||||
} catch (reason) {
|
||||
setError(`Could not start full session: ${String(reason)}`);
|
||||
setError(`Could not continue in full session: ${String(reason)}`);
|
||||
} finally {
|
||||
setPromoting(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1187,6 +1168,16 @@ export default function QuickPane() {
|
||||
/>
|
||||
</div>
|
||||
<div className="quick-pane-header-actions">
|
||||
{runtime && (
|
||||
<button
|
||||
className="quick-pane-action"
|
||||
type="button"
|
||||
disabled={catalogOverlayActive || promoting}
|
||||
onClick={continueFull}
|
||||
>
|
||||
{promoting ? "Continuing…" : "Continue"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="quick-pane-close"
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationWorkspace } from "./components/ConversationWorkspace";
|
||||
import QuickPane from "./quick-pane/QuickPane";
|
||||
import { runtimeView } from "./workspace/reducer";
|
||||
import type { Model, RuntimeSnapshot, UiState } from "./workspace/types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
listen: vi.fn(),
|
||||
onFocusChanged: vi.fn(),
|
||||
startDragging: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen }));
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => ({
|
||||
onFocusChanged: mocks.onFocusChanged,
|
||||
startDragging: mocks.startDragging,
|
||||
}),
|
||||
}));
|
||||
|
||||
const settings: UiState = {
|
||||
version: 1,
|
||||
directoryOrder: [],
|
||||
runtimeOrderByDirectory: {},
|
||||
drafts: {},
|
||||
scroll: {},
|
||||
lastSeen: {},
|
||||
quickPane: {
|
||||
workspace: "/repo",
|
||||
provider: "provider",
|
||||
model: "model",
|
||||
followUpProvider: "provider",
|
||||
followUpModel: "model",
|
||||
instructions: "Stay read-only.",
|
||||
maximumToolCalls: 6,
|
||||
},
|
||||
};
|
||||
|
||||
function options(selector: HTMLSelectElement) {
|
||||
return within(selector)
|
||||
.getAllByRole("option")
|
||||
.map((option) => option.getAttribute("value"));
|
||||
}
|
||||
|
||||
function renderSurfaces(model: Model) {
|
||||
const runtime = runtimeView({
|
||||
runtimeId: "runtime",
|
||||
worktreePath: "/repo",
|
||||
state: "idle",
|
||||
label: "Session",
|
||||
attention: false,
|
||||
queueCount: 0,
|
||||
lastActivity: "now",
|
||||
openedAt: "now",
|
||||
});
|
||||
runtime.models = [model];
|
||||
runtime.state = { model, thinkingLevel: "high" };
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "bridge",
|
||||
latestSeq: 1,
|
||||
runtime: runtime.summary,
|
||||
state: { data: runtime.state },
|
||||
models: { data: { models: runtime.models } },
|
||||
extensions: [],
|
||||
};
|
||||
mocks.invoke.mockImplementation((command: string) => {
|
||||
if (command === "load_ui_state") return Promise.resolve(settings);
|
||||
if (command === "create_quick_runtime")
|
||||
return Promise.resolve({
|
||||
runtime: { agentId: "agent", runtimeId: "quick" },
|
||||
});
|
||||
if (command === "get_session_runtime_snapshot")
|
||||
return Promise.resolve(snapshot);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
render(
|
||||
<>
|
||||
<ConversationWorkspace
|
||||
runtime={runtime}
|
||||
onDraft={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
onScroll={vi.fn()}
|
||||
onModel={vi.fn().mockResolvedValue(true)}
|
||||
onThinking={vi.fn().mockResolvedValue(true)}
|
||||
onCommand={vi.fn()}
|
||||
/>
|
||||
<QuickPane />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("thinking selector parity", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listen.mockResolvedValue(vi.fn());
|
||||
mocks.onFocusChanged.mockResolvedValue(vi.fn());
|
||||
mocks.startDragging.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"omitted reasoning and map",
|
||||
{},
|
||||
["off", "minimal", "low", "medium", "high"],
|
||||
],
|
||||
[
|
||||
"null exclusions",
|
||||
{ thinkingLevelMap: { minimal: null, high: null } },
|
||||
["off", "low", "medium"],
|
||||
],
|
||||
[
|
||||
"explicit xhigh and max",
|
||||
{ thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
|
||||
["off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
],
|
||||
[
|
||||
"non-reasoning",
|
||||
{ reasoning: false, thinkingLevelMap: { xhigh: "xhigh", max: "max" } },
|
||||
["off"],
|
||||
],
|
||||
] as const)(
|
||||
"matches Pi %s semantics in Conversation Workspace and Quick Pane",
|
||||
async (_name, metadata, expected) => {
|
||||
renderSurfaces({ provider: "provider", id: "model", ...metadata });
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getAllByRole("combobox", { name: "Thinking" }),
|
||||
).toHaveLength(2),
|
||||
);
|
||||
for (const selector of screen.getAllByRole("combobox", {
|
||||
name: "Thinking",
|
||||
}) as HTMLSelectElement[])
|
||||
expect(options(selector)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -194,6 +194,56 @@ describe("workspace reducer", () => {
|
||||
]);
|
||||
expect(state.runtimesById.a.modelError).toContain("RPC timed out");
|
||||
});
|
||||
it("preserves reasoning model metadata from runtime snapshots", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
state: {
|
||||
data: {
|
||||
model: {
|
||||
provider: "provider",
|
||||
id: "reasoning",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
provider: "provider",
|
||||
id: "reasoning",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.state.model).toEqual({
|
||||
provider: "provider",
|
||||
id: "reasoning",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: { high: null, xhigh: "xhigh" },
|
||||
});
|
||||
expect(state.runtimesById.a.models[0]).toEqual(
|
||||
state.runtimesById.a.state.model,
|
||||
);
|
||||
});
|
||||
it("removes the optimistic submission when Pi returns its structured user message", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
|
||||
@@ -60,19 +60,23 @@ export type Message = {
|
||||
| string
|
||||
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
||||
};
|
||||
type ThinkingLevel =
|
||||
| "off"
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
| "xhigh"
|
||||
| "max";
|
||||
|
||||
export type AgentState = {
|
||||
isStreaming?: boolean;
|
||||
thinkingLevel?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
sessionName?: string;
|
||||
sessionId?: string;
|
||||
messageCount?: number;
|
||||
pendingMessageCount?: number;
|
||||
model?: {
|
||||
provider?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
contextWindow?: number;
|
||||
};
|
||||
model?: Model & { contextWindow?: number };
|
||||
};
|
||||
export type SessionStats = {
|
||||
tokens?: {
|
||||
@@ -93,7 +97,8 @@ export type Model = {
|
||||
provider?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
thinkingLevelMap?: Record<string, string>;
|
||||
reasoning?: boolean;
|
||||
thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
|
||||
};
|
||||
export type Extension = {
|
||||
id?: string;
|
||||
|
||||
@@ -28,6 +28,9 @@ export function useWorkspace() {
|
||||
const legacyMigrationPending = useRef(false);
|
||||
const disposed = useRef(true);
|
||||
const refreshGeneration = useRef(0);
|
||||
const workspaceReady = useRef(false);
|
||||
const pendingPromotedRuntime = useRef<string | undefined>(undefined);
|
||||
const consumingPromotion = useRef(false);
|
||||
|
||||
const loadSnapshot = useCallback(
|
||||
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
|
||||
@@ -150,10 +153,44 @@ export function useWorkspace() {
|
||||
[loadSnapshot],
|
||||
);
|
||||
|
||||
const consumePromotedRuntime = useCallback(() => {
|
||||
if (!workspaceReady.current || consumingPromotion.current) return;
|
||||
consumingPromotion.current = true;
|
||||
void (async () => {
|
||||
while (pendingPromotedRuntime.current && !disposed.current) {
|
||||
const runtimeId = pendingPromotedRuntime.current;
|
||||
pendingPromotedRuntime.current = undefined;
|
||||
await refresh();
|
||||
if (disposed.current) return;
|
||||
dispatch({ type: "selectRuntime", runtimeId });
|
||||
await loadSnapshot(runtimeId);
|
||||
if (disposed.current) return;
|
||||
await invoke("consume_pending_promoted_runtime", { runtimeId });
|
||||
}
|
||||
})()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
consumingPromotion.current = false;
|
||||
if (pendingPromotedRuntime.current) consumePromotedRuntime();
|
||||
});
|
||||
}, [loadSnapshot, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
disposed.current = false;
|
||||
workspaceReady.current = false;
|
||||
let stop: (() => void) | undefined;
|
||||
let stopNewRuntime: (() => void) | undefined;
|
||||
let stopPromotedRuntime: (() => void) | undefined;
|
||||
const promotedListenerReady = listen<{ runtimeId: string }>(
|
||||
"workspace-promoted-runtime",
|
||||
(message) => {
|
||||
pendingPromotedRuntime.current = message.payload.runtimeId;
|
||||
consumePromotedRuntime();
|
||||
},
|
||||
).then((unlisten) => {
|
||||
if (disposed.current) unlisten();
|
||||
else stopPromotedRuntime = unlisten;
|
||||
});
|
||||
void listen<{
|
||||
phase: "starting" | "ready" | "error";
|
||||
runtimeId?: string;
|
||||
@@ -197,6 +234,7 @@ export function useWorkspace() {
|
||||
"runtime_recovered",
|
||||
"runtime_renewed",
|
||||
"runtime_restore_failed",
|
||||
"runtime_promoted",
|
||||
].includes(eventType ?? "")
|
||||
) {
|
||||
void refresh();
|
||||
@@ -215,10 +253,17 @@ export function useWorkspace() {
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
await promotedListenerReady;
|
||||
const loaded = await loadPresentation();
|
||||
if (disposed.current) return;
|
||||
await refresh(loaded);
|
||||
if (disposed.current) return;
|
||||
workspaceReady.current = true;
|
||||
const runtimeId = await invoke<string | null>(
|
||||
"get_pending_promoted_runtime",
|
||||
).catch(() => null);
|
||||
if (runtimeId) pendingPromotedRuntime.current = runtimeId;
|
||||
consumePromotedRuntime();
|
||||
initialized.current = true;
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
@@ -243,9 +288,11 @@ export function useWorkspace() {
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
return () => {
|
||||
disposed.current = true;
|
||||
workspaceReady.current = false;
|
||||
refreshGeneration.current += 1;
|
||||
stop?.();
|
||||
stopNewRuntime?.();
|
||||
stopPromotedRuntime?.();
|
||||
void invoke("unsubscribe_workspace").catch(() => {});
|
||||
window.removeEventListener("focus", visibility);
|
||||
window.removeEventListener("blur", visibility);
|
||||
@@ -253,7 +300,7 @@ export function useWorkspace() {
|
||||
Object.values(timers.current).forEach(clearTimeout);
|
||||
void appWindow;
|
||||
};
|
||||
}, [refresh, scheduleSnapshot]);
|
||||
}, [consumePromotedRuntime, refresh, scheduleSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) return;
|
||||
|
||||
Reference in New Issue
Block a user