feat(quick-pane): promote active runtime

This commit is contained in:
2026-08-28 12:07:50 +02:00
parent 0a318f5ab6
commit b44b1be4b7
19 changed files with 1298 additions and 126 deletions
+1 -1
View File
@@ -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)}
+12 -2
View File
@@ -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");
+29
View File
@@ -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;
+46 -15
View File
@@ -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
View File
@@ -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);
}
+29 -35
View File
@@ -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 () => {
+23 -32
View File
@@ -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"
+140
View File
@@ -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);
},
);
});
+50
View File
@@ -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, {
+13 -8
View File
@@ -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;
+48 -1
View File
@@ -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;