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:
1 parent
c83d670050
commit
487f2456ff
14 files changed
+168
-157
No files matched your search
@@ -1,13 +1,21 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import {
|
||||
BOARD_READER_ID,
|
||||
type ReadAll,
|
||||
type Transaction,
|
||||
} from "./helpers";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
|
||||
/**
|
||||
* Company-scoped read endpoints that do not belong to a single domain
|
||||
* CRUD module.
|
||||
*
|
||||
* Sidebar badges used to live here as a server-side aggregation
|
||||
* (`getSidebarBadges`) — it has been deleted. The desktop app is
|
||||
* local-first and the renderer already holds every row the badges
|
||||
* would count via `ProfileSnapshot`, so the aggregation became a
|
||||
* second source of truth that drifted from the inbox query whenever
|
||||
* messages were read between snapshot refreshes. Derivation now lives
|
||||
* in `src/shared/sidebar-badges.ts::deriveSidebarBadges`.
|
||||
*/
|
||||
export function buildCompanySettingsMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
_getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
_transaction: Transaction,
|
||||
_deps: {
|
||||
@@ -52,71 +60,5 @@ export function buildCompanySettingsMethods(
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
getSidebarBadges(companyId: string): {
|
||||
pendingApprovals: number;
|
||||
activeRuns: number;
|
||||
failedRuns: number;
|
||||
todoTasks: number;
|
||||
budgetWarnings: number;
|
||||
unreadMessages: number;
|
||||
} {
|
||||
const pendingApprovals =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from approvals where company_id = ? and state = 'pending'",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const activeRuns =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from runs where company_id = ? and status in ('queued', 'running')",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const failedRuns =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from runs where company_id = ? and status in ('failed', 'timed_out', 'interrupted')",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const todoTasks =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from tasks where company_id = ? and status in ('todo', 'in_progress', 'in_review')",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const budgetWarnings =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
"select count(*) as c from agents where company_id = ? and budget_monthly_usd > 0 and (spent_monthly_usd * 1.0 / budget_monthly_usd) >= 0.8",
|
||||
)
|
||||
.get(companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
const unreadMessages =
|
||||
(
|
||||
getDb()
|
||||
.prepare(
|
||||
`select count(*) as c
|
||||
from agent_messages messages
|
||||
left join agent_message_reads reads
|
||||
on reads.message_id = messages.id
|
||||
and reads.agent_id = ?
|
||||
where messages.company_id = ?
|
||||
and reads.read_at is null
|
||||
and (messages.channel = 'company' or messages.channel = 'incident' or messages.priority = 'urgent')`,
|
||||
)
|
||||
.get(BOARD_READER_ID, companyId) as { c: number }
|
||||
)?.c ?? 0;
|
||||
return { pendingApprovals, activeRuns, failedRuns, todoTasks, budgetWarnings, unreadMessages };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user