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:
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? "")
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user