feat(status-ui): improve concurrent session workflows

Add in-place session renewal, searchable model selection, clearer progress feedback, tool-result previews, and per-session presentation state.

Keep commands, errors, loading, and lifecycle indicators attached to their owning runtime so parallel tabs cannot interfere.
This commit is contained in:
2026-08-25 14:56:42 +02:00
parent 9f2c840ae7
commit 166b065ad4
20 changed files with 2211 additions and 430 deletions
+160 -5
View File
@@ -74,6 +74,126 @@ describe("workspace reducer", () => {
expect(state.runtimesById.b.messages[0].content).toBe("b");
expect(state.runtimesById.b.unread).toBe(true);
});
it("uses the runtime summary as the authoritative snapshot working state", () => {
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: "streaming" },
state: { data: { isStreaming: false } },
extensions: [],
},
});
expect(state.runtimesById.a.progress.phase).toBe("working");
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 2,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 2,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 2,
runtime: runtime("a"),
state: { data: { isStreaming: true } },
extensions: [],
},
});
expect(state.runtimesById.a.progress.phase).toBe("idle");
});
it("requests another snapshot when a live event overtakes the current load", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
state = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_event",
data: {
runtimeId: "a",
eventType: "queue",
eventData: { event: { pendingMessageCount: 1 } },
},
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 0,
runtime: runtime("a"),
extensions: [],
},
});
expect(state.runtimesById.a.loading).toBe(false);
expect(state.runtimesById.a.snapshotRetry).toBe(true);
expect(state.runtimesById.a.summary.queueCount).toBe(1);
});
it("preserves known models and exposes model discovery failures", () => {
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"),
models: {
data: { models: [{ provider: "openai", id: "gpt-5" }] },
},
extensions: [],
},
});
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 2,
});
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 2,
snapshot: {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: runtime("a"),
models: {
data: { models: [] },
error: "RPC timed out",
},
extensions: [],
},
});
expect(state.runtimesById.a.models).toEqual([
{ provider: "openai", id: "gpt-5" },
]);
expect(state.runtimesById.a.modelError).toContain("RPC timed out");
});
it("removes the optimistic submission when Pi returns its structured user message", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
@@ -259,8 +379,8 @@ describe("workspace reducer", () => {
],
};
expect(
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed
.summary.agentId,
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed.summary
.agentId,
).toBeUndefined();
});
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
@@ -318,9 +438,7 @@ describe("workspace reducer", () => {
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
state.runtimesById.b.extensions = [
{ id: "dead-request", method: "confirm" },
];
state.runtimesById.b.extensions = [{ id: "dead-request", method: "confirm" }];
state.runtimesById.b.unread = true;
const reset = workspaceReducer(state, {
type: "hostEvent",
@@ -340,6 +458,43 @@ describe("workspace reducer", () => {
expect(restored.runtimesById.b.extensions).toEqual([]);
expect(restored.runtimesById.b.unread).toBe(false);
});
it("clears active history and input when a runtime is renewed", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.a.messages = [
{ role: "user", content: "old conversation" },
];
state.runtimesById.a.pendingSubmissions = [
{
id: 1,
text: "old pending input",
userMessageCount: 1,
phase: "sent",
},
];
state.runtimesById.a.extensions = [
{ id: "old-extension", method: "confirm" },
];
state.runtimesById.a.models = [{ provider: "openai", id: "old-model" }];
state.runtimesById.a.draft = "old draft";
state.runtimesById.a.scroll = { atBottom: false, scrollTop: 88 };
state.runtimesById.a.unread = true;
const renewed = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_renewed",
data: { runtimeId: "a", runtime: runtime("a") },
});
expect(renewed.runtimesById.a.messages).toEqual([]);
expect(renewed.runtimesById.a.pendingSubmissions).toEqual([]);
expect(renewed.runtimesById.a.extensions).toEqual([]);
expect(renewed.runtimesById.a.models).toEqual([]);
expect(renewed.runtimesById.a.draft).toBe("");
expect(renewed.runtimesById.a.scroll).toEqual({ atBottom: true });
expect(renewed.runtimesById.a.unread).toBe(false);
expect(renewed.bridge.status).toBe("resyncing");
});
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
const next = applyWorkspaceEvent(state, {
+68 -19
View File
@@ -16,6 +16,28 @@ const idleProgress = {
toolCount: 0,
};
function snapshotProgress(runtime: RuntimeSummary) {
if (runtime.state === "streaming")
return {
phase: "working" as const,
detail: "Thinking and preparing a response",
toolCount: 0,
};
if (runtime.state === "recovering")
return {
phase: "recovering" as const,
detail: "Restoring this Pi session",
toolCount: 0,
};
if (runtime.state === "failed")
return {
phase: "error" as const,
detail: runtime.error?.message ?? "This Pi session failed",
toolCount: 0,
};
return idleProgress;
}
function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
@@ -57,6 +79,7 @@ export function runtimeView(
messages: previous?.messages ?? [],
commands: previous?.commands ?? [],
models: previous?.models ?? [],
modelError: previous?.modelError,
extensions: previous?.extensions ?? [],
extensionStatuses: previous?.extensionStatuses ?? {},
extensionWidgets: previous?.extensionWidgets ?? {},
@@ -67,6 +90,7 @@ export function runtimeView(
scroll: previous?.scroll ?? { atBottom: true },
unread: previous?.unread ?? false,
loading: previous?.loading ?? false,
snapshotRetry: previous?.snapshotRetry ?? false,
error: previous?.error,
requestGeneration: previous?.requestGeneration ?? 0,
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
@@ -84,6 +108,7 @@ function resetRuntimeView(
messages: [],
commands: [],
models: [],
modelError: undefined,
extensions: [],
extensionStatuses: {},
extensionWidgets: {},
@@ -92,6 +117,7 @@ function resetRuntimeView(
unread: false,
progress: idleProgress,
loading: false,
snapshotRetry: false,
error: undefined,
requestGeneration: 0,
loadedThroughSeq: 0,
@@ -147,9 +173,7 @@ export function reconcileWorkspace(
ids,
);
}
const paths = workspace.directories.map(
(directory) => directory.worktreePath,
);
const paths = workspace.directories.map((directory) => directory.worktreePath);
const directoryOrder = ordered(
persisted?.directoryOrder ?? state.directoryOrder,
paths,
@@ -158,9 +182,7 @@ export function reconcileWorkspace(
const selectedRuntimeId =
preferred && runtimesById[preferred]
? preferred
: directoryOrder.flatMap(
(path) => runtimeOrderByDirectory[path] ?? [],
)[0];
: directoryOrder.flatMap((path) => runtimeOrderByDirectory[path] ?? [])[0];
const selectedDirectoryPath = selectedRuntimeId
? runtimesById[selectedRuntimeId]?.summary.worktreePath
: directoryOrder[0];
@@ -261,6 +283,26 @@ export function applyWorkspaceEvent(
},
};
const id = event.data?.runtimeId;
if (
event.type === "runtime_renewed" &&
id &&
state.runtimesById[id] &&
event.data?.runtime
) {
const renewed = resetRuntimeView(event.data.runtime, state.runtimesById[id]);
return {
...state,
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
runtimesById: {
...state.runtimesById,
[id]: {
...renewed,
draft: "",
scroll: { atBottom: true },
},
},
};
}
if (
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
event.type ?? "",
@@ -456,8 +498,7 @@ export function workspaceReducer(
return {
...state,
selectedDirectoryPath: action.worktreePath,
selectedRuntimeId:
state.runtimeOrderByDirectory[action.worktreePath]?.[0],
selectedRuntimeId: state.runtimeOrderByDirectory[action.worktreePath]?.[0],
};
if (action.type === "selectRuntime")
return {
@@ -483,20 +524,29 @@ export function workspaceReducer(
const view = { ...state.runtimesById[runtimeId] };
if (action.type === "snapshotRequested") {
view.loading = true;
view.snapshotRetry = false;
view.error = undefined;
view.requestGeneration = action.generation;
} else if (action.type === "snapshotFailed") {
if (view.requestGeneration !== action.generation) return state;
view.loading = false;
view.snapshotRetry = false;
view.error = action.error;
} else if (action.type === "snapshotLoaded") {
if (view.requestGeneration !== action.generation) return state;
if (
view.requestGeneration !== action.generation ||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
action.snapshot.latestSeq < view.loadedThroughSeq
)
return state;
) {
view.loading = false;
view.snapshotRetry = true;
return {
...state,
runtimesById: { ...state.runtimesById, [runtimeId]: view },
};
}
view.loading = false;
view.snapshotRetry = false;
view.summary = action.snapshot.runtime;
view.state = action.snapshot.state?.data ?? {};
view.stats = action.snapshot.stats?.data ?? {};
@@ -506,16 +556,15 @@ export function workspaceReducer(
view.messages,
);
view.commands = action.snapshot.commands?.data?.commands ?? [];
view.models = action.snapshot.models?.data?.models ?? [];
if (action.snapshot.models?.error) {
view.modelError = `Could not load available Pi models: ${action.snapshot.models.error}`;
} else {
view.models = action.snapshot.models?.data?.models ?? [];
view.modelError = undefined;
}
view.extensions = action.snapshot.extensions ?? [];
view.loadedThroughSeq = action.snapshot.latestSeq;
view.progress = view.state.isStreaming
? {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
}
: idleProgress;
view.progress = snapshotProgress(action.snapshot.runtime);
} else if (action.type === "draftChanged") view.draft = action.draft;
else if (action.type === "scrollChanged")
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
+3 -1
View File
@@ -129,7 +129,7 @@ export type RuntimeSnapshot = {
stats?: { data?: SessionStats };
transcript?: { data?: { messages?: Message[] } };
commands?: { data?: { commands?: Command[] } };
models?: { data?: { models?: Model[] } };
models?: { data?: { models?: Model[] }; error?: string };
extensions: Extension[];
};
export type DirectorySession = {
@@ -180,6 +180,7 @@ export type RuntimeView = {
messages: Message[];
commands: Command[];
models: Model[];
modelError?: string;
extensions: Extension[];
extensionStatuses: Record<string, string>;
extensionWidgets: Record<
@@ -193,6 +194,7 @@ export type RuntimeView = {
scroll: { atBottom: boolean; scrollTop?: number };
unread: boolean;
loading: boolean;
snapshotRetry: boolean;
error?: string;
requestGeneration: number;
loadedThroughSeq: number;
+41 -22
View File
@@ -29,28 +29,46 @@ export function useWorkspace() {
const disposed = useRef(true);
const refreshGeneration = useRef(0);
const loadSnapshot = useCallback(async (runtimeId: string) => {
if (disposed.current) return;
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
loadGeneration.current[runtimeId] = generation;
dispatch({ type: "snapshotRequested", runtimeId, generation });
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{ runtimeId },
);
if (!disposed.current)
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
} catch (error) {
if (!disposed.current)
dispatch({
type: "snapshotFailed",
runtimeId,
generation,
error: String(error),
});
}
}, []);
const loadSnapshot = useCallback(
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
if (disposed.current) return false;
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
loadGeneration.current[runtimeId] = generation;
dispatch({ type: "snapshotRequested", runtimeId, generation });
try {
const snapshot = await invoke<RuntimeSnapshot>(
"get_session_runtime_snapshot",
{ runtimeId },
);
if (!disposed.current)
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
return true;
} catch (error) {
if (!disposed.current)
dispatch({
type: "snapshotFailed",
runtimeId,
generation,
error: String(error),
});
if (options.throwOnError) throw error;
return false;
}
},
[],
);
const snapshotRetryKey = Object.values(state.runtimesById)
.flatMap((runtime) =>
runtime.snapshotRetry ? [runtime.summary.runtimeId] : [],
)
.sort((left, right) => left.localeCompare(right))
.join("\n");
useEffect(() => {
if (!snapshotRetryKey) return;
for (const runtimeId of snapshotRetryKey.split("\n"))
void loadSnapshot(runtimeId);
}, [snapshotRetryKey, loadSnapshot]);
const scheduleSnapshot = useCallback(
(runtimeId: string) => {
@@ -177,6 +195,7 @@ export function useWorkspace() {
"runtime_opened",
"runtime_closed",
"runtime_recovered",
"runtime_renewed",
"runtime_restore_failed",
].includes(eventType ?? "")
) {