Prevent Android chats from freezing or going blank (#1989)
* fix(app): stabilize streamed chat rendering Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths. * fix(app): stabilize retained native panels Keep retained workspace roots in a stable native order and make active panels present in the selection commit. Hidden panels now stop expensive subscriptions and animations without losing mounted state. * fix(app): harden retained panel activity
This commit is contained in:
@@ -92,8 +92,22 @@ describe("workspace agent activity index", () => {
|
||||
|
||||
expect(index).toEqual(
|
||||
new Map([
|
||||
["workspace-a", { status: "needs_input", enteredAt: new Date("2026-06-01T10:01:00.000Z") }],
|
||||
["workspace-b", { status: "attention", enteredAt: new Date("2026-06-01T10:02:00.000Z") }],
|
||||
[
|
||||
"workspace-a",
|
||||
{
|
||||
agentId: "permission",
|
||||
status: "needs_input",
|
||||
enteredAt: new Date("2026-06-01T10:01:00.000Z"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"workspace-b",
|
||||
{
|
||||
agentId: "attention",
|
||||
status: "attention",
|
||||
enteredAt: new Date("2026-06-01T10:02:00.000Z"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -135,8 +149,82 @@ describe("workspace agent activity index", () => {
|
||||
);
|
||||
|
||||
expect(index.get("workspace-a")).toEqual({
|
||||
agentId: "root",
|
||||
status: "running",
|
||||
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the activity index while the same agent remains in the same status", () => {
|
||||
const previous = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const next = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:05:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
previous,
|
||||
);
|
||||
|
||||
expect(next).toBe(previous);
|
||||
expect(next.get("workspace-a")?.enteredAt).toEqual(new Date("2026-06-01T10:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("records a new entry time when an agent changes status", () => {
|
||||
const previous = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const next = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({
|
||||
id: "root",
|
||||
workspaceId: "workspace-a",
|
||||
status: "idle",
|
||||
updatedAt: "2026-06-01T10:05:00.000Z",
|
||||
pendingPermissionCount: 1,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
previous,
|
||||
);
|
||||
|
||||
expect(next).not.toBe(previous);
|
||||
expect(next.get("workspace-a")).toEqual({
|
||||
agentId: "root",
|
||||
status: "needs_input",
|
||||
enteredAt: new Date("2026-06-01T10:05:00.000Z"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,17 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
|
||||
|
||||
export interface WorkspaceAgentActivity {
|
||||
agentId: string;
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
export function buildWorkspaceAgentActivityIndex(
|
||||
agents: ReadonlyMap<string, Agent>,
|
||||
previous?: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
): Map<string, WorkspaceAgentActivity> {
|
||||
const activityByWorkspaceId = new Map<string, WorkspaceAgentActivity>();
|
||||
const latestActivityAtByWorkspaceId = new Map<string, Date>();
|
||||
|
||||
for (const agent of agents.values()) {
|
||||
if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) {
|
||||
@@ -17,21 +20,52 @@ export function buildWorkspaceAgentActivityIndex(
|
||||
}
|
||||
|
||||
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
|
||||
const current = activityByWorkspaceId.get(agent.workspaceId);
|
||||
if (current && enteredAt <= (current.enteredAt ?? new Date(0))) {
|
||||
const latestActivityAt = latestActivityAtByWorkspaceId.get(agent.workspaceId);
|
||||
if (latestActivityAt && enteredAt <= latestActivityAt) {
|
||||
continue;
|
||||
}
|
||||
latestActivityAtByWorkspaceId.set(agent.workspaceId, enteredAt);
|
||||
|
||||
const status = deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
});
|
||||
activityByWorkspaceId.set(agent.workspaceId, {
|
||||
status: deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
}),
|
||||
agentId: agent.id,
|
||||
status,
|
||||
enteredAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [workspaceId, activity] of activityByWorkspaceId) {
|
||||
const previousActivity = previous?.get(workspaceId);
|
||||
if (
|
||||
previousActivity?.agentId === activity.agentId &&
|
||||
previousActivity.status === activity.status
|
||||
) {
|
||||
activityByWorkspaceId.set(workspaceId, previousActivity);
|
||||
}
|
||||
}
|
||||
|
||||
if (previous && areWorkspaceAgentActivityIndexesIdentical(previous, activityByWorkspaceId)) {
|
||||
return previous instanceof Map ? previous : new Map(previous);
|
||||
}
|
||||
return activityByWorkspaceId;
|
||||
}
|
||||
|
||||
function areWorkspaceAgentActivityIndexesIdentical(
|
||||
previous: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
next: ReadonlyMap<string, WorkspaceAgentActivity>,
|
||||
): boolean {
|
||||
if (previous.size !== next.size) {
|
||||
return false;
|
||||
}
|
||||
for (const [workspaceId, activity] of next) {
|
||||
if (previous.get(workspaceId) !== activity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user