refactor(badges): derive from snapshot; delete getSidebarBadges IPC chain

Badges were fetched via a dedicated IPC that aggregated counts on the
server, while the same data lived in ProfileSnapshot on the client.
Two paths to the same answer drift whenever one side mutates between
refreshes — the visible symptom being "badge = 1 unread" with an empty
inbox after a message gets marked read in another screen.

Delete the whole server path. Sidebar badges are now derived on the
client from the snapshot via `deriveSidebarBadges(snapshot, companyId)`
in `src/shared/sidebar-badges.ts`. One source of truth; drift is
physically impossible.

Deletions:
- database/company-settings.ts::getSidebarBadges (120-line SQL aggregation)
- database-base.ts::getSidebarBadges pass-through
- ipc-core-read-handlers.ts: handler
- preload/index.ts: bridge binding
- shared/contracts/channels.ts: channel name
- shared/contracts/desktop-api/companies.ts: API signature
- shared/contracts/schemas-badges.ts::sidebarBadgesSchema Zod schema
- renderer/state/slices/company.ts: `badges` field, `setBadges`,
  `refreshBadges`
- useDataLoader: both getSidebarBadges call sites
- useCompanyController: badge reset on company switch
- useDesktopEvents: refreshBadges call on granular patches
- tests/unit/store.test.ts: refreshBadges test (deleted code)

The snapshot's agentMessages already carries board-reader read state
via boardReadReceiptMap (`snapshot.ts:39-47` has always LEFT JOIN'd
agent_message_reads with agent_id = BOARD_READER_ID). So derivation
needs no schema change — the infrastructure was in place, just unused.

fix(communication/MessageList): render orphaned-reply messages at the
top level

The thread-flattening used `filteredMessages.filter(m => !m.parentMessageId)`
to pick top-level messages. When an agent replies to a company/incident
post with a DM (the CEO prompt explicitly instructs this in
wake-reasons/en.ts:119), the DM lands in the direct channel with
parentMessageId pointing to a message that lives in another channel.
That parent is not in `filteredMessages` for the direct channel, so the
reply had no parent to nest under AND was excluded from top-level. The
channel rendered as a blank pane even though exactly one message
matched — the "clicked Dr. X ↔ Dr. Y, got empty" symptom.

Fix: a message is top-level when it either has no parent OR its parent
is outside the current filtered view. Replies with in-view parents
still nest normally.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-18 21:38:34 -04:00
1 parent c83d670050
commit 487f2456ff
14 files changed
+168 -157

No files matched your search

@@ -41,10 +41,22 @@ export function MessageList({
const parentRef = useRef<HTMLDivElement>(null)
const threadedMessages = useMemo(() => {
const topLevel = filteredMessages.filter((m) => !m.parentMessageId)
// A message is rendered at the top level when it either has no
// parent, or its parent is outside the current filtered view
// (another channel, beyond the 1000-row snapshot cap, or simply
// not loaded yet). Without this second condition, a DM channel
// whose earliest message has scrolled off the snapshot would
// display as completely empty even though replies for it are
// present — the exact "clicked the channel, got a blank pane"
// symptom.
const idSet = new Set(filteredMessages.map((m) => m.id))
const topLevel: AgentMessageRecord[] = []
const replyMap = new Map<string, AgentMessageRecord[]>()
for (const m of filteredMessages) {
if (m.parentMessageId) {
const parentInView = m.parentMessageId ? idSet.has(m.parentMessageId) : false
if (!m.parentMessageId || !parentInView) {
topLevel.push(m)
} else {
const existing = replyMap.get(m.parentMessageId) ?? []
existing.push(m)
replyMap.set(m.parentMessageId, existing)
@@ -40,7 +40,6 @@ export function useCompanyController() {
store.setStandupReport(null);
store.setSocialAccounts([]);
store.setBrowserActions([]);
store.setBadges(null);
store.setSkills([]);
store.setSelectedSkillId(null);
store.setSelectedSkillFilePath("SKILL.md");
+19 -12
View File
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import type { CompanyRecord, ProfileSnapshot, SectionId } from "@shared/types";
import { deriveSidebarBadges } from "@shared/sidebar-badges";
import { useAppStore } from "../state/store";
function filterByCompany<T extends { companyId: string }>(
@@ -76,18 +77,24 @@ export function useCompanyData(
[companyWorkspaces, selectedWorkspaceId],
);
const navBadgesSource = useAppStore((s) => s.badges);
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => navBadgesSource ? {
runs:
navBadgesSource.failedRuns > 0
? { count: navBadgesSource.failedRuns, tone: "danger" }
: navBadgesSource.activeRuns > 0
? { count: navBadgesSource.activeRuns, tone: "default" }
: undefined,
tasks: navBadgesSource.todoTasks > 0 ? { count: navBadgesSource.todoTasks, tone: "default" } : undefined,
approvals: navBadgesSource.pendingApprovals > 0 ? { count: navBadgesSource.pendingApprovals, tone: "warn" } : undefined,
communication: navBadgesSource.unreadMessages > 0 ? { count: navBadgesSource.unreadMessages, tone: "warn" } : undefined,
} : {}, [navBadgesSource]);
// Derive sidebar badge counts from the snapshot. Single source of truth —
// no separate `store.badges` cache to drift when messages get marked read
// or approvals get accepted between server-side aggregations.
const navBadges = useMemo<Partial<Record<SectionId, { count: number; tone: "default" | "warn" | "danger" }>>>(() => {
if (!snapshot) return {};
const badges = deriveSidebarBadges(snapshot, currentCompany?.id ?? null);
return {
runs:
badges.failedRuns > 0
? { count: badges.failedRuns, tone: "danger" }
: badges.activeRuns > 0
? { count: badges.activeRuns, tone: "default" }
: undefined,
tasks: badges.todoTasks > 0 ? { count: badges.todoTasks, tone: "default" } : undefined,
approvals: badges.pendingApprovals > 0 ? { count: badges.pendingApprovals, tone: "warn" } : undefined,
communication: badges.unreadMessages > 0 ? { count: badges.unreadMessages, tone: "warn" } : undefined,
};
}, [snapshot]);
return useMemo(() => ({
tasks,
-2
View File
@@ -24,8 +24,6 @@ export function useDataLoader(
if (!currentSelectedConnectorId && next.connectors[0]) store.setSelectedConnectorId(next.connectors[0].id);
if (!currentSelectedRunId && next.runs[0]) store.setSelectedRunId(next.runs[0].id);
if (next.currentCompanyId) {
const badgeResult = await window.agentCompany.getSidebarBadges(next.currentCompanyId);
if (badgeResult.ok) store.setBadges(badgeResult.data);
const inboxResult = await window.agentCompany.getInbox(next.currentCompanyId);
if (inboxResult.ok) store.setInboxItems(inboxResult.data);
store.setSocialAccounts(next.socialAccounts?.filter((a) => a.companyId === next.currentCompanyId) ?? []);
+3 -3
View File
@@ -106,10 +106,10 @@ export function useDesktopEvents(
if (needsFullRefresh) {
void refreshRef.current();
} else {
// Refresh sidebar badges after granular patches (per pitfall 3)
void store.getState().refreshBadges();
}
// Granular patches already update the snapshot arrays, and
// sidebar badges derive from the snapshot live — no separate
// badge refresh is needed.
}
// 500ms window for full-refresh events (e.g. new-message,
+5 -27
View File
@@ -10,8 +10,11 @@ import type {
StandupReport,
TaskRecord,
} from "@shared/types";
import type { SidebarBadges } from "@shared/contracts";
// Sidebar badge counts live in the renderer — derived from the
// snapshot on every read via `deriveSidebarBadges` rather than cached
// on this slice. One source of truth is the snapshot; adding a
// separate `badges` field here is what let the counters drift.
export interface CompanySlice {
snapshot: ProfileSnapshot | null;
/**
@@ -24,13 +27,11 @@ export interface CompanySlice {
companyMetrics: CompanyMetrics | null;
standupReport: StandupReport | null;
inboxItems: InboxItem[];
badges: SidebarBadges | null;
error: string | null;
setSnapshot: (snapshot: ProfileSnapshot | null) => void;
setCompanyMetrics: (metrics: CompanyMetrics | null) => void;
setStandupReport: (report: StandupReport | null) => void;
setInboxItems: (items: InboxItem[]) => void;
setBadges: (badges: SidebarBadges | null) => void;
setError: (error: string | null) => void;
refreshSnapshot: () => Promise<void>;
patchTask: (task: TaskRecord) => void;
@@ -41,7 +42,6 @@ export interface CompanySlice {
removeAgent: (agentId: string) => void;
patchApproval: (approval: ApprovalRecord) => void;
removeApproval: (approvalId: string) => void;
refreshBadges: () => Promise<void>;
}
export const createCompanySlice: StateCreator<
@@ -49,13 +49,12 @@ export const createCompanySlice: StateCreator<
[["zustand/immer", never]],
[],
CompanySlice
> = (set, get) => ({
> = (set) => ({
snapshot: null,
snapshotRevision: 0,
companyMetrics: null,
standupReport: null,
inboxItems: [],
badges: null,
error: null,
setSnapshot: (snapshot) =>
set((state) => {
@@ -74,10 +73,6 @@ export const createCompanySlice: StateCreator<
set((state) => {
state.inboxItems = items;
}),
setBadges: (badges) =>
set((state) => {
state.badges = badges;
}),
setError: (error) =>
set((state) => {
state.error = error;
@@ -167,21 +162,4 @@ export const createCompanySlice: StateCreator<
if (!state.snapshot) return;
state.snapshot.approvals = state.snapshot.approvals.filter((a) => a.id !== approvalId);
}),
refreshBadges: async () => {
const companyId = get().snapshot?.currentCompanyId ?? null;
if (!companyId) return;
try {
const result = await window.agentCompany.getSidebarBadges(companyId);
if (result.ok) {
set((state) => {
state.badges = result.data;
});
}
} catch (error) {
console.warn(
`[renderer/company] refreshBadges failed for company ${companyId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
});