Skip to content

feat(dashboard): Review replaces Learning and owns every pending decision - #873

Open
kmonsoe wants to merge 11 commits into
feat/oversight-settings-chromefrom
fix/oversight-review-followups
Open

feat(dashboard): Review replaces Learning and owns every pending decision#873
kmonsoe wants to merge 11 commits into
feat/oversight-settings-chromefrom
fix/oversight-review-followups

Conversation

@kmonsoe

@kmonsoe kmonsoe commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Follow-ups from the stack-wide code review, stacked on #872.

Backend

  • The conversation list pager's cursor now carries needsHumanAttention, so resuming across the attention boundary drops no rows; the ORDER BY tiebreaker is id to match the cursor. The queue pager strips the flag (its ordering never used it). New boundary-crossing test.
  • list() and queue() share one query parser; the invalid-status error gains its conv_invalid: prefix.

Dashboard

  • Detail-load failures render a translated error with a retry instead of an eternal spinner.
  • The mobile full-screen composer declares role=dialog/aria-modal, closes on Escape, and focuses the textarea on open (see the draft exception below).
  • The queue scroll fade is a CSS variable on the scroll container — scrolling no longer re-renders every row.
  • Learning: failed decisions fetch is surfaced with a retry; published/dismissed counts are labelled as recent.
  • One generic extendNavGroups behind the console/settings wrappers; agent settings gating keys off ACCOUNT_SETTINGS_HREF; the live-now dot halo derives from the accent token in both themes.

Runtime

  • SetDraftReplyOpts exported once and reused by the in-process client.

Added in 6a848ad

Topic descriptions (migration 0089, nullable conv_topics.description)

Picking a topic is an automation decision — a topic mode overrides the conversation's — and the classifier was making it from a name and its kebab-case slug, the same word twice. Nothing told it where an org draws the line between Support, Technical and Security, or that an existing topic already covered the case it was about to duplicate.

  • Returned by conv_list_topics and conv_list_topic_automation; set on conv_create_topic; edited by the new conv_update_topic tool or POST /v1/conversations/topics/:topicId. Slug stays immutable — it is how export/import addresses a topic.
  • skill://conv/set-topic-and-title reads descriptions ahead of names, treats one that rules a case out as decisive, and writes a description for every topic it creates — but never rewrites one it did not author, since one conversation is not evidence enough to redraw an operator's boundary. setup-email-and-widget-channels' topic examples were missing the required slug; fixed while there.
  • Editable in the automation page's policy dialog. Fixtures regenerated (219 tools).

Read-model contract for the auto-send gate

agentMode on a read is the gated effective mode; topicAgentMode is what an operator configured. The inbox row shows the first, the automation page the second. The old single test asserted an auto topic reads back as auto everywhere — the pre-gate contract, passing only because nothing evaluated the threshold yet. Now two tests: the override rule uses off (the one mode the gate cannot veto), and a second pins the fold.

Mobile oversight

  • You can no longer act on a draft you were never shown. The draft lives in the composer textarea and pending drafts are filtered out of the thread, so on a phone Approve & send sat above a draft that appeared nowhere. The collapsed bar is now a single Review draft →; approve and reject both live in the editor it opens. Blind rejection was the quieter half — a reject counts against the topic's approved-unedited share, moving the auto-send gate on evidence nobody read.
  • The no-draft bar is a button (Write reply → / Continue reply →) instead of markup styled as a text input, which read as somewhere to type when tapping it only ever opened the editor.
  • Opening the editor onto an unedited draft no longer focuses the textarea: focus raises the keyboard over the text you opened the screen to read. Note tab, empty composer, and already-edited drafts focus as before.
  • Editor gutters: the tab row and error banner were the only strips without a mobile value, so they kept the desktop 20px against a 16px header and body.
  • The automation table stacks under md instead of scrolling sideways at every phone width — which put the policy, the reason you opened the page, off-screen. Weekly volume and the percentage bar are dropped there rather than shrunk. Desktop layout is unchanged; every rule is behind md:/max-md:.

Verified: workspace typecheck, backend-core conv 453, db 24 (journal guard included), dashboard-pages 101, en/nb key parity. Migration 0089 applied to a DB already at 0088 and exercised through the non-superuser munin_app role under RLS.


Added in 49fb890 — Review replaces Learning

/dashboard/learning becomes /dashboard/review, and the page stops being only about knowledge proposals. CMS drafts, CRM merge proposals, outreach drafts and forwarded feedback leave the admin overview and join the KB candidates, split by urgency:

  • Blocking — everything that stops something shipping, sorted oldest first. A blocking queue sorted by recency buries exactly the item that has been ignored longest, which is the opposite of what the section is for.
  • Improvements — knowledge proposals, newest first. No deadline: the base already answers customers, and a proposal only makes it answer better. Keeping them in one list with an unsent email made them look overdue; tinting or dimming the rows overcorrected, so the section label carries it.
  • Decided · last 7 days stays as a third section.

The overview drops the queue concept entirely — no list, no hero count, no stat row. The sidebar badge on Review is the single signal that work is pending, and it counts all five kinds instead of only KB candidates.

Selecting a blocking item renders its detail in the split's right pane rather than a sheet, so the CMS field editor, outreach scheduling and the merge preview all work unchanged and are now deep-linkable at /dashboard/review/:id. DrawerHeader's close button became optional for that: in a persistent pane it pointed nowhere, because the list immediately re-selects the first row.

Outreach gets a purpose-built pane, ordered by what must not go wrong — the message as it will arrive, then why it is being sent, then when.

  • The envelope is rendered, and so is everything the send path appends: the campaign's CTA URL and the fixed --- / Unsubscribe footer, as the literal text composeOutreachBody will append, under one "appended on send" marker. Note the CTA is a bare URL on its own line — not the styled button the design drew.
  • "Why this, why now" now exists. evidence was already one call away on /v1/outreach/proposals/:id and the dashboard never fetched it. It is freeform jsonb whose shape varies per drafting agent, so it renders by shape: long values and reason-ish keys become prose, kb:// / kdoc_ references become chips, the rest become labelled chips. A parser keyed to the shape the drafting skills document rendered nothing for the proposals already in the dev database — that is what the new tests pin.
  • Send timing is a control on the page, not a dialog behind a button. The cadence annotations the design asks for beside it need campaign cadenceRules, which are not on the DTO, so they are omitted rather than faked.
  • SMS and voice are first-class: no subject row, no CTA or unsubscribe appends, a live segment count as you edit, and the sender as a number. A contact with no address on file blocks approval outright and says why, instead of tinting a note and failing at the service.
  • The full diff sits behind a disclosure (N edits against the agent's original), with See what changed → in the revised-after-review banner. The final text is already rendered above it in full, so inline it was taller than the message and pushed the timing control off-screen.
  • The old reply-thread quote is dropped, not carried over — it quoted the proposal's own snippet, not the inbound message it was replying to. Showing the real thread needs the conversation fetch.

DTO additions (all read-only, all off data the queries already had): ProposalCampaignSummary.ctaUrl (previously selected only to compute appendsCta, then discarded), ProposalDelivery.sender / senderName from the channel's own addressing config routed through publicChannelConfig so no credential can reach a DTO, and ProposalContactSummary.companyName.

Pill gains a marker variant so a pane's module pill carries the same glyph as its list row — square for CMS, diamond for CRM, hollow ring for KB — instead of the generic filled dot every tone rendered. BodyDiff gains wrap: these are prose bodies in a ~700px pane, where horizontal scrolling through an email diff is unusable.

Heads-up for cloud: the route rename is breaking for apps/web-cloud, which mirrors these routes itself and imports LearningPage. It needs dashboard/review/{page,[id]/page}.tsx and ReviewPage before it builds against the next @getmunin/dashboard-pages.

Verified: workspace typecheck, dashboard-pages 136 (11 new: review partition + outreach evidence parsing), backend-core outreach unit tests, en/nb key parity with no unused or missing keys. Every pane state exercised against the dev database in light, dark and at mobile width — email initial with a diff, email follow-up, SMS follow-up, and an SMS proposal for a contact with no phone.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NEtUitV5jQ1nbmwmtTML3u

kmonsoe and others added 4 commits August 29, 2026 00:41
The conversation list pager orders attention-first but its cursor only
encoded (lastMessageAt, id), so resuming from a page that ended inside the
needs-attention block dropped every calmer row with newer activity. The
cursor now carries needsHumanAttention, the tiebreaker is id in both the
ORDER BY and the cursor, and the queue pager strips the flag since its
ordering never used it. The list and queue endpoints share one query
parser, which also gives the invalid-status error its conv_invalid prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPwvC1MqF1uhpNqC8BmvQN
…uieter scroll

The pane shows a translated error with a retry when a conversation detail
fails to load instead of spinning forever. The mobile full-screen editor
declares dialog semantics, closes on Escape, and focuses its textarea on
open. The queue's scroll fade writes a CSS variable on the scroll
container so rows no longer re-render on every scroll tick. The learning
page surfaces a failed decisions fetch with a retry and labels its
published/dismissed counts as recent. Console and settings nav share one
generic group-extension helper, agent settings gating compares against
ACCOUNT_SETTINGS_HREF, the live-now dot halo derives from the accent token
in both themes, and SetDraftReplyOpts is exported from the runtime and
reused by the in-process client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPwvC1MqF1uhpNqC8BmvQN
…t fixes

Settings becomes admin-only: a support agent kept Workspace → Account,
whose only field is the organization name. PATCH /v1/orgs/me already
required owner|admin, so the form was a dead end that always 403'd. The
system-alert banner drops its call to action for members for the same
reason — every target is a settings page they can no longer reach.

Sign-out moves out of the settings nav into a user footer shared by the
desktop sidebar and the mobile menu sheet: avatar, name, overflow menu.

The membership cache is keyed to the session user, so signing out as an
owner and back in as a member no longer needs a hard refresh.

A draft is only produced when the customer wrote the last public message.
The on-demand path was exempt from that check entirely, the automatic path
treated an operator's own message as something to answer, and a request
after the agent had already replied was allowed as a follow-up proposal —
three paths, three answers. One rule now holds everywhere, and the
dashboard hides the button on the same condition.

A claimed conversation whose last public message is from the customer
sorts under "Needs your attention"; the read model gains a derived
endUserSpokeLast rather than widening what needsHumanAttention means.

Closed conversations offer Reopen — the status endpoint already accepted
'open', nothing surfaced it. Returning to the queue works after reloading
a conversation directly: the detail route's id prop no longer outranks the
queue's own path.

Mobile: the shell measures in dvh so the composer footer clears Safari's
toolbar; the conversation header collapses into the app bar; the
full-screen composer tracks visualViewport so its send button clears the
keyboard while an opaque backdrop covers the layout viewport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPwvC1MqF1uhpNqC8BmvQN
…oversight fixes

Topics gain an operator-written description (migration 0089): what belongs in
the topic, where its boundary runs against adjacent ones, and the vocabulary
customers use for it. Picking a topic is an automation decision, so the
classifier was making it from a name and its kebab-case slug — one word, twice.
Returned by conv_list_topics and conv_list_topic_automation, set on
conv_create_topic, edited via the new conv_update_topic tool or the automation
page's topic editor. skill://conv/set-topic-and-title reads descriptions ahead
of names and writes one for every topic it creates, but never rewrites one it
did not author.

Auto-send becomes a standing rule evaluated per reply against a per-topic
promote threshold, so reads now report two modes: agentMode is the gated
effective mode, topicAgentMode the one an operator configured. The read-model
tests split to match — the override rule is tested with `off`, the one mode the
gate cannot veto, and a second case pins the fold.

On phones you can no longer act on a draft you were never shown: the mobile
review pane's collapsed bar carries a single Review draft button, with approve
and reject inside the editor it opens, and opening onto an unedited draft no
longer raises the keyboard over the text you came to read. The automation table
stacks under md instead of scrolling sideways, dropping weekly volume and the
progress bar rather than shrinking them.

Changesets: topic-description, topic-promote-threshold, topic-automation-change-note,
mobile-draft-review-before-send, composer-action-split, composer-error-banner-tone,
console-hero, oversight-learning-proposals, take-over-actually-takes-over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEtUitV5jQ1nbmwmtTML3u
Comment thread packages/dashboard-pages/src/realtime.ts Fixed
@kmonsoe kmonsoe changed the title fix: oversight code-review follow-ups feat(conv): topic descriptions and oversight review follow-ups Sep 1, 2026
kmonsoe and others added 5 commits September 1, 2026 14:44
CodeQL flagged the channel value being interpolated into the console.debug
format string; a channel containing %s/%d could swallow trailing args via
util.format.
Replace the Learning page's scrolling card feed with the index-and-reading-pane
split the conversation queue already uses: proposals as compact rows on the left,
the selected one as the working surface on the right, action bar pinned to the
pane. Selection lives in the URL (/dashboard/learning/:id) via shallow pushState,
with desktop auto-selecting the first row through replaceState so it never becomes
a back-button trap.

Decisions from the last 7 days now sit in a second section of the same list rather
than behind a tab, since what is waiting and what just happened are read together.
Rows are attributed: CurationDecisionDto gains decidedByName, resolved by joining
users on decidedByActorId the way conversation claims resolve holderName. The
decided pane loads the article a publish produced via a new
GET /v1/kb/documents/:id, and states plainly that a dismissed draft's text is gone
— dismissal hard-deletes the candidate and its versions cascade with it.

Error handling is brought in line with the conversation pane. Publish and dismiss
failures record a queueActionError and render inline above the action bar instead
of a toast that auto-dismisses: on failure the proposal simply stays put, which is
indistinguishable from a click that never landed, and the likeliest failure is a
version conflict whose "reload and re-decide" instruction should not evaporate.
LoadFailed gains a 'pane' size so a failed conversation detail fetch keeps the
alert eyebrow and request_id without claiming, at 56px, that the whole app is down.
detailErrors now carries the ApiError rather than a pre-translated string so that
request id survives to the UI.

Also extracts the duplicated list section label into ConsoleSectionLabel, gives it
a rule and even vertical padding, and aligns the Learning pane's padding, button
sizing and muted link treatment with the conversation composer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEtUitV5jQ1nbmwmtTML3u
The two shells had drifted into three differences visible on one account.

The mobile drawer opened full-width on bg-paper in the console and as a 320px
bg-bone panel in settings, each rendering navigation its own way. The console
drawer now takes the settings panel, which lets NavList's `mobile` variant go
entirely — one list treatment across both shells and both breakpoints. Logo,
brand and the user footer stay, being the console's identity and its only
route to sign out.

The mobile top bar was bone in the console and white in settings because
neither header declared a background: DashboardShell roots at bg-bone while
the settings branch wraps children in bg-paper, so each inherited whatever sat
above it. Both now set the surface explicitly.

The desktop sidebar was a 280px grid column in the console and w-72 in
settings; settings now matches at 280px.

The console drawer's explicit close button goes with this, since the settings
drawer it now matches has never had one and SheetContent ships no built-in
close. Both dismiss by backdrop or Escape. nav.closeMenu is left in place —
dashboard-pages is shared with the cloud web app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEtUitV5jQ1nbmwmtTML3u
A brand-new org landed on a dashboard of zeroes. The four console pages now
render a first-run scene driven by one shared setup snapshot
(GET /v1/overview/setup), with a real "send a test message" loop that is
reversible, and the Overview hero greets the operator by first name on a
four-bucket clock instead of a fixed headline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEtUitV5jQ1nbmwmtTML3u
…sion

`/dashboard/learning` becomes `/dashboard/review`, and the page stops being only about
knowledge proposals. CMS drafts, CRM merge proposals, outreach drafts and forwarded
feedback leave the admin overview and join the KB candidates, split by urgency:

- Blocking — everything that stops something shipping, sorted oldest first, because a
  queue sorted by recency buries exactly the item ignored longest.
- Improvements — knowledge proposals, newest first. No deadline: the base already
  answers customers, a proposal only makes it answer better.

The overview drops the queue concept entirely — no list, no hero count, no stat row.
The sidebar badge is the single signal, and it now counts all five kinds, not just KB.

Outreach gets a purpose-built pane instead of the sheet drawer reused in place, ordered
by what must not go wrong: the message as it will arrive, then why, then when.

- The envelope is rendered, and so is everything the send path appends — the campaign's
  CTA URL and the fixed `---` / Unsubscribe footer — as literal text under one
  "appended on send" marker rather than two booleans in a note.
- "Why this, why now" now exists. `evidence` was one call away and never fetched. It is
  freeform jsonb whose shape varies per drafting agent, so it renders by shape: long
  values and reason keys become prose, kb refs become chips, the rest labelled chips. A
  parser keyed to the documented example rendered nothing for the rows already in the
  dev database, which is what the tests pin.
- Send timing is a control on the page, not a dialog. Cadence annotations need campaign
  `cadenceRules`, absent from the DTO, so they are left out rather than faked.
- SMS and voice are first-class: no subject, no appends, a live segment count, and a
  contact with no address blocks approval outright instead of failing at the service.

`ProposalCampaignSummary` gains `ctaUrl` (already selected, only used for a boolean and
dropped), `ProposalDelivery` gains `sender`/`senderName` from the channel's addressing
config via `publicChannelConfig` so no credential reaches a DTO, and
`ProposalContactSummary` gains `companyName`.

`Pill` gains a `marker` variant so a pane's module pill carries the same glyph as its
list row; `BodyDiff` gains `wrap`, since horizontal scrolling through an email diff in a
700px pane is unusable. The diff itself is collapsed behind a disclosure — the final text
is already rendered above it in full.

Selecting a blocking item renders its detail in the split's right pane, so the CMS field
editor, outreach scheduling and the merge preview all work unchanged and are now
deep-linkable at `/dashboard/review/:id`. `DrawerHeader`'s close button became optional
for that: in a persistent pane it pointed nowhere.

The old reply-thread quote is dropped rather than carried over — it quoted the proposal's
own snippet, not the inbound message it replied to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEtUitV5jQ1nbmwmtTML3u
@kmonsoe kmonsoe changed the title feat(conv): topic descriptions and oversight review follow-ups feat(dashboard): Review replaces Learning and owns every pending decision Sep 2, 2026
kmonsoe and others added 2 commits September 2, 2026 15:21
The old drawer was a header and one sentence of prose, while `recommendedPatch` and
`evidence` — both already on the wire — rendered nowhere.

"What changes on the keeper" is now the body: one row per patched field, old → new,
tagged Differs / Replaced / Added. `tags` and `customFields` overwrite rather than
merge, so a replacement names what it drops ("newsletter" is dropped) — the most
destructive thing an apply does and previously invisible. A wholesale field the keeper
does not have yet reads Added with no drop note, and a patch entry that would not change
anything is not rendered at all.

The evidence becomes the lede sentence — matched signals plus keeperReason — read by
shape rather than by an allow-list of keys, because the keys in the dev database
(sameCompanyDomain, emailVariation, phoneInB) differ from the ones
`skill://crm/clean-contact-data` documents (sameEmail, nameMatch, samePhoneNormalized).
Both shapes are pinned by tests; unlabelled keys are ignored rather than dumped as prose.

Untouched fields collapse behind Compare all — a keeper-vs-archived table with identical
values dimmed.

"And then" states what apply actually does, read off `applyMergeProposal`: history moves,
the duplicate is archived and set to do-not-contact, and queued outreach for the
duplicate is cancelled, naming the campaign. A reviewer could previously destroy a
scheduled email without being told.

`MergeProposalContactSummary` widens from 6 of ~24 contact columns to the patchable,
displayable set — both read paths already fetched the whole row — plus `companyName`.
New `MergeImpact` carries the duplicate's pending outreach and the count of other pending
proposals the apply supersedes, in two batched queries. It is nullable rather than
zeroed, so apply/dismiss responses say "not computed" instead of claiming nothing is at
stake.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEtUitV5jQ1nbmwmtTML3u
The CMS drawer fetches its preview link on open and embeds it in a
sandboxed frame behind Preview/Fields tabs, falling back to the fields
with a retry when the site refuses to embed or stays blank. Review pane
footers keep one primary action and move the rest into a bottom sheet,
and badges move onto Pill with a solid fill variant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEtUitV5jQ1nbmwmtTML3u
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants