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:
1 parent
04093c4869
commit
9834714fa6
6 files changed
+40
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.": "挥动鞭子:在茶水间或洗手间摸鱼的成员会赶回工位;正在工作或开会的成员不受影响。",
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user