Sibling refactor to the sidebar-badges cleanup. The inbox aggregates
five item categories (pending approvals, failed runs, budget warnings,
blocked/review tasks, unread company/incident/urgent messages) — every
row is already in `ProfileSnapshot`. The server-side `getInbox`
aggregation (180 lines of SQL + projection) was a redundant second
path.
New: `src/shared/inbox-items.ts::deriveInboxItems(snapshot, companyId)`
produces the same InboxItem[] shape with the same severity ordering
and 24h failed-run window. Pure function, no IPC, no state.
Deleted:
- database/company-crud.ts::getInbox (180-line aggregation)
- database-base.ts::getInbox 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
- state/slices/company.ts: `inboxItems` field, `setInboxItems` action
- useDataLoader.ts: both getInbox call sites (initial snapshot load
and section-change effect)
- useCompanyController.ts: inbox reset on company switch
OperationsSections now computes `inboxItems` via `useMemo` off the
current snapshot + company id. The snapshot is already company-scoped
at the main/database/snapshot.ts layer when `currentCompanyId` is
non-null; the derivation re-filters defensively so the function works
correctly even for unscoped snapshots.
Tests: 603/603 green. No new tests added — the derivation is a pure
function whose shape is exercised end-to-end by the existing Inbox
component integration.
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.
Per user request: the "显示控制台 / Show console" button in the top-right
header and the bottom drawer it opened are no longer needed — the Runs
section already renders the same log tail in-page via RunDetail, so the
drawer was a duplicate surface. Deleted the entire feature instead of
leaving a hidden toggle that attracts bugs.
Removed files:
- src/renderer/views/ConsoleView.tsx (126 LOC)
Removed renderer integration points:
- App.tsx: header "Show/Hide console" button, ConsoleView render,
setConsoleOpen(true) call after startRun, TerminalWindow icon import
- views/sections/ExecutionSections.tsx: onOpenConsole callback,
setConsoleOpen subscription
- components/runs/RunCenter.tsx: onOpenConsole prop (removed from
forwarding chain)
- components/runs/RunDetail.tsx: onOpenConsole prop + the Lightning
"Open console" ActionButton. The cancel-run button remains as the
sole Panel action now that the console entry point is gone.
- hooks/useUIController.ts: consoleOpen / setConsoleOpen subscription
- hooks/useDesktopEvents.ts: "toggle-console" shortcut dispatcher
- state/slices/navigation.ts: consoleOpen field, setConsoleOpen,
toggleConsole actions, and the two auto-open-on-navigate triggers
that used to flip the drawer when jumping to a run
Removed main-process integration:
- main/desktop-shell-menu.ts: "Toggle Console" menu item + CmdOrCtrl+`
accelerator
- main/desktop-shell-window.ts: onShortcut union type narrowed
- shared/types/events.ts: ShortcutEvent action union narrowed
Removed i18n keys (zh + en):
- app.hideConsole / app.showConsole
- console.title / console.noRuns / console.close
- common.openLog / common.noRunSelected / common.waitingForLog
- runs.openConsole
Log capture itself (logBufferRef in useCompanyController + run-log
events) is unchanged — the Runs section still displays it. Nothing
was moved to a "disabled but present" state; the feature is out of
the tree entirely. Typecheck and lint pass.
Before this commit one piece of state (`selectedAgentId`) did three
jobs: roster highlight, navigation destination, and Edit-modal open
flag. The three jobs had incompatible lifecycles — highlight should
persist across modal close, navigation should land on a screen
without opening a modal, and the modal itself only wants to open on
explicit intent. The conflation was the root cause of the "Edit: CEO
auto-opens on first Team view" bug (commit 41132e2 patched the most
visible symptom by not pre-selecting the lead agent, but the coupling
remained).
Split into two slice fields:
- `selectedAgentId` — which agent is currently highlighted in the
roster / OrgChart. Survives modal close. Set by: row click, card
click, navigation from Inbox / Hiring (all of which pass through
`openAgentEditor` which sets both so the highlight follows the last
interaction).
- `editingAgentId` — which agent's Edit modal is open, or
NEW_ENTITY_ID for the "Request new hire" modal. Only set by
explicit user action; cleared on modal close. Never set by
navigation or onboarding — the pre-select pathway cannot
accidentally open a modal again.
Plumbing:
- `selection.ts`: new `editingAgentId` + `setEditingAgentId`, plus
clear on `clearCompanyScopedSelections`.
- `useCompanyData.ts`: derive `editingAgent` record from the id.
- `useCompanyController.ts`: forward `editingAgent` on the section
context.
- `OrganizationSections.tsx`: `agents` section modals read
`editingAgentId` / `editingAgent`. "Request new hire" button uses
`setEditingAgentId(NEW_ENTITY_ID)`. Single-click handlers go
through `openAgentEditor(id)` which updates both states.
Also improved Edit modal title (was "Edit: CEO", a machined English
prefix on a Chinese UI with zero context about the role):
- zh: "编辑 {name}({role})"
- en: "Edit {name} ({role})"
Renders as "编辑 CEO(首席执行官)" in the screenshot example.
Covers J9 / S2.
Delete the useUIController.navigateTo duplicate; augment slice version with
task-with-run lookup (via get().snapshot) so notification-click and command
palette share one codepath. App.tsx now reads navigateTo from the store.
CompanySlice's standalone currentCompanyId field + setCurrentCompanyId action
were never written by any caller, so useDesktopEvents.ts read null and fell
through to full refresh for every entity event -- the granular patch
machinery was dead code.
- Remove currentCompanyId field + setCurrentCompanyId from CompanySlice
- refreshBadges reads from snapshot.currentCompanyId
- useDesktopEvents reads companyId from snapshot.currentCompanyId
- Update store unit test to seed snapshot instead of calling removed action
setSearchQuery only incremented searchRequestId without flipping
searchLoading, so the UI could not distinguish "searching" from "no
results". Now loading flips true on non-empty query and clears
(results + loading) on empty query. setSearchResults keeps the
existing id-match logic to flip loading false on arrival.
- Add "skills" to SectionId union in shared types
- Add SkillRecord, SkillImportResult, SkillUpdateStatus types to shared/types.ts
- Create SkillSlice with skills list, selection, file path, and import modal state
- Wire SkillSlice into Zustand store composition
- Add "nav.skills" i18n keys for English and Chinese
- Add Skills sidebar nav entry with PuzzlePiece icon between Communication and Documents
- Add section === "skills" rendering route in App.tsx
- Clear skill state on company switch
- Add skills case to navigateTo in NavigationSlice
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Create SearchResults component with virtualized list, term highlighting via <mark>, keyboard navigation, loading/empty states
- Add search scope toggle (All Channels / This Channel) with radio group accessibility
- Integrate debounced (300ms) IPC search into CommunicationHub with stale response discard via requestId
- Update CommunicationSlice with search state (query, scope, results, loading, requestId)
- Modify FilterBar to accept scopeToggle prop and Escape key handler
- AnimatePresence cross-fade between SearchResults and MessageList views
- Replace all 31 useState declarations with useAppStore selectors
- Add direct setters (setCommandPaletteOpen, setConsoleOpen, etc.) to NavigationSlice
- Replace IPC debounce with createEventBatcher (50ms window)
- Route shortcut events through store.getState() for toggle actions
- Remove unused type imports (SidebarBadges, CompanyMetrics, etc.)
- Zero useState calls remain in App.tsx (down from 31)
- TypeScript compilation passes with zero errors
- All 14 unit tests continue to pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Install zustand 5.x and immer 11.x dependencies
- Create 6 Zustand slices: navigation, company, selection, ui, communication, social
- Create combined store with devtools + immer middleware
- Create IPC event batcher with 50ms debounce window (schedule/flush/dispose)
- Create typed selector hooks for narrow state subscriptions
- Add 14 unit tests covering store initialization, slice behaviors, and batcher timing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>