From 9834714fa61df292a4b077b6b7259c2889c1ca3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 8 Jul 2026 23:50:17 -0400 Subject: [PATCH] Renderer sweep round two: survive orphans, hiccups, and busy inboxes The renderer half of the project review surfaced five real defects: - One unresolvable message sender or mention target blanked the whole Messages page mid-render; both now degrade to a visible "Former member" placeholder instead of throwing - A failed initial snapshot load left a permanently blank window with a swallowed rejection; loads now log loudly and retry every 1.5s until the main process answers - With no conversation selected, the view followed the recency sort: any incoming message yanked the visible conversation to the newest space and marked it read unseen. The implicit default now pins itself once (render-time state adjustment) - The avatar directory keyed names across ALL organizations, so a same-named principal in another org hijacked the custom avatar; it now scopes to the active org - The conversation search query survived switching spaces, silently filtering the new space by the old term; ChatConversation is keyed by space id so per-space UI state resets Also verified clean by the same sweep: no lingering references to the removed snapshot fields, no stale closures in the office rAF loop, org-chart zoom math, and the chat message ordering invariant is guaranteed at the data layer (created_at desc limit + reverse). --- src/renderer/src/app/App.tsx | 8 +++++++- src/renderer/src/app/useSnapshot.ts | 18 ++++++++++++++++-- src/renderer/src/i18n/zh.ts | 1 + src/renderer/src/pages/ChatPage.tsx | 6 ++++++ src/renderer/src/pages/chat/ChatMessageRow.tsx | 10 +++++----- src/renderer/src/pages/chat/chat-helpers.ts | 9 +++++---- 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 9b28536a..d2c00674 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -18,15 +18,21 @@ export function App() { const { snapshot, actions } = useSnapshot(); const [route, setRoute] = useState("home"); const avatarDirectory = useMemo(() => { + // Scope the directory to the active organization: names are only unique + // within one org, and a same-named principal in another org must not + // hijack the avatar shown here. const directory = new Map(); + const orgId = snapshot?.activeOrgId ?? null; for (const agent of snapshot?.agents ?? []) { + if (agent.orgId !== orgId) continue; if (agent.avatarEmoji || agent.avatarColor) directory.set(agent.name, { emoji: agent.avatarEmoji, color: agent.avatarColor }); } for (const employee of snapshot?.employees ?? []) { + if (employee.orgId !== orgId) continue; if (employee.avatarEmoji || employee.avatarColor) directory.set(employee.displayName, { emoji: employee.avatarEmoji, color: employee.avatarColor }); } return directory; - }, [snapshot?.agents, snapshot?.employees]); + }, [snapshot?.agents, snapshot?.employees, snapshot?.activeOrgId]); const [creatingOrg, setCreatingOrg] = useState(false); // Keeps the wizard mounted through its post-bootstrap connectors step: the // organization exists from Launch onward, so the org-count check alone diff --git a/src/renderer/src/app/useSnapshot.ts b/src/renderer/src/app/useSnapshot.ts index 8f736466..de20e5f3 100644 --- a/src/renderer/src/app/useSnapshot.ts +++ b/src/renderer/src/app/useSnapshot.ts @@ -31,7 +31,20 @@ export function useSnapshot(): { useEffect(() => { let disposed = false; let timer: ReturnType | null = null; + let retryTimer: ReturnType | null = null; let lastLoadAt = 0; + // A failed load must not strand the app on a blank shell: log it loudly + // and retry until the main process answers (IPC hiccups are transient). + const guardedLoad = () => { + load().catch((error) => { + console.error(`[snapshot] load failed, retrying: ${error instanceof Error ? error.message : String(error)}`); + if (disposed || retryTimer) return; + retryTimer = setTimeout(() => { + retryTimer = null; + guardedLoad(); + }, 1_500); + }); + }; // Change events arrive in bursts while agents run; reload at most every // 800ms with a trailing load so the UI converges without render storms. const scheduleReload = () => { @@ -40,12 +53,12 @@ export function useSnapshot(): { timer = setTimeout(() => { timer = null; lastLoadAt = Date.now(); - void load(); + guardedLoad(); }, wait); }; void Promise.resolve().then(() => { lastLoadAt = Date.now(); - return load(); + guardedLoad(); }); const unsubscribe = window.agentCompany.subscribe((event) => { if (event.type === "snapshot-changed") scheduleReload(); @@ -53,6 +66,7 @@ export function useSnapshot(): { return () => { disposed = true; if (timer) clearTimeout(timer); + if (retryTimer) clearTimeout(retryTimer); unsubscribe(); }; }, [load]); diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 8f2e1e44..68911c59 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -124,6 +124,7 @@ export const zh: Record = { "Edit task": "编辑任务", "Edit {name}": "编辑 {name}", "Edit profile": "编辑个人资料", + "Former member": "已离职成员", "Whip": "鞭子", "Office tools": "办公室工具", "Crack the whip: slackers in the snack bar or restroom hustle back to their desks. Working or meeting agents are unaffected.": "挥动鞭子:在茶水间或洗手间摸鱼的成员会赶回工位;正在工作或开会的成员不受影响。", diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 5e3a349e..c6d4fc53 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -61,6 +61,11 @@ export function ChatPage({ snapshot, actions }: PageProps) { const activeSpaceId = selectedSpaceId && spaces.some((space) => space.id === selectedSpaceId) ? selectedSpaceId : firstAvailableSpaceId; + // Pin the implicit default as soon as one exists (render-time state + // adjustment): the conversation list re-sorts by recency on every + // snapshot, and an unpinned default would follow it - yanking the view to + // whichever space just got a message and marking it read unseen. + if (!selectedSpaceId && firstAvailableSpaceId) setSelectedSpaceId(firstAvailableSpaceId); const activeSpace = spaces.find((space) => space.id === activeSpaceId) ?? null; const latestActiveMessageId = useMemo(() => { if (!activeSpaceId) return null; @@ -181,6 +186,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { /> item.id === mention.targetId); - if (!employee) throw new Error("Message mention employee target is missing."); - return employee.displayName; + return employee ? employee.displayName : t("Former member"); } if (mention.targetType === "agent") { const agent = context.agents.find((item) => item.id === mention.targetId); - if (!agent) throw new Error("Message mention agent target is missing."); - return agent.name; + return agent ? agent.name : t("Former member"); } if (mention.targetType === "board") return t("Board"); if (mention.targetType === "org_group" && mention.targetId.endsWith(":oncall")) return t("OnCall"); - throw new Error("Message mention target type is unsupported."); + return t("Former member"); } function AttachmentCard({ file, handoff = false, onOpen }: { file: FileObjectRecord; handoff?: boolean; onOpen: (() => void) | null }) { diff --git a/src/renderer/src/pages/chat/chat-helpers.ts b/src/renderer/src/pages/chat/chat-helpers.ts index 0bf8cf50..84e68b58 100644 --- a/src/renderer/src/pages/chat/chat-helpers.ts +++ b/src/renderer/src/pages/chat/chat-helpers.ts @@ -17,15 +17,16 @@ export interface MessageContext { } export function senderName(message: ChatMessageRecord, context: MessageContext): string { + // A message whose principal no longer resolves must degrade to a visible + // placeholder, not throw: one orphaned row would otherwise blank the whole + // Messages page mid-render. if (message.senderType === "employee") { const employee = context.employees.find((item) => item.id === message.senderId); - if (!employee) throw new Error("Message employee sender is missing."); - return employee.displayName; + return employee ? employee.displayName : t("Former member"); } if (message.senderType === "agent") { const agent = context.agents.find((item) => item.id === message.senderId); - if (!agent) throw new Error("Message agent sender is missing."); - return agent.name; + return agent ? agent.name : t("Former member"); } if (message.senderType === "board") return t("Board"); return t("System");