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).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-08 23:50:17 -04:00
1 parent 04093c4869
commit 9834714fa6
6 files changed
+40 -12

No files matched your search

+7 -1
View File
@@ -18,15 +18,21 @@ export function App() {
const { snapshot, actions } = useSnapshot();
const [route, setRoute] = useState<RouteId>("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<string, AvatarLook>();
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
+16 -2
View File
@@ -31,7 +31,20 @@ export function useSnapshot(): {
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let retryTimer: ReturnType<typeof setTimeout> | 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]);
+1
View File
@@ -124,6 +124,7 @@ export const zh: Record<string, string> = {
"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.": "挥动鞭子:在茶水间或洗手间摸鱼的成员会赶回工位;正在工作或开会的成员不受影响。",
+6
View File
@@ -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) {
/>
<ChatConversation
key={activeSpace?.id ?? "none"}
space={activeSpace}
participants={participants}
messagesInSpace={messagesInSpace}
@@ -166,19 +166,19 @@ function formatInline(text: string, keyPrefix: string): ReactNode[] {
}
function mentionLabel(mention: ChatMentionRecord, context: MessageContext): string {
// Degrade unresolvable targets to a placeholder instead of throwing: one
// stale mention row must not blank the entire timeline.
if (mention.targetType === "employee") {
const employee = context.employees.find((item) => 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 }) {
+5 -4
View File
@@ -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");