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:
Mohamed Boudra
2026-07-11 15:39:14 +08:00
committed by GitHub
parent 860fcb2e35
commit 28a27b02d3
40 changed files with 1244 additions and 543 deletions
@@ -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;
}