feat(dashboard): Review replaces Learning and owns every pending decision - #873
Open
kmonsoe wants to merge 11 commits into
Open
feat(dashboard): Review replaces Learning and owns every pending decision#873kmonsoe wants to merge 11 commits into
kmonsoe wants to merge 11 commits into
Conversation
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups from the stack-wide code review, stacked on #872.
Backend
needsHumanAttention, so resuming across the attention boundary drops no rows; the ORDER BY tiebreaker isidto match the cursor. The queue pager strips the flag (its ordering never used it). New boundary-crossing test.list()andqueue()share one query parser; the invalid-status error gains itsconv_invalid:prefix.Dashboard
role=dialog/aria-modal, closes on Escape, and focuses the textarea on open (see the draft exception below).extendNavGroupsbehind the console/settings wrappers; agent settings gating keys offACCOUNT_SETTINGS_HREF; the live-now dot halo derives from the accent token in both themes.Runtime
SetDraftReplyOptsexported 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.
conv_list_topicsandconv_list_topic_automation; set onconv_create_topic; edited by the newconv_update_topictool orPOST /v1/conversations/topics/:topicId. Slug stays immutable — it is how export/import addresses a topic.skill://conv/set-topic-and-titlereads 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 requiredslug; fixed while there.Read-model contract for the auto-send gate
agentModeon a read is the gated effective mode;topicAgentModeis what an operator configured. The inbox row shows the first, the automation page the second. The old single test asserted anautotopic reads back asautoeverywhere — the pre-gate contract, passing only because nothing evaluated the threshold yet. Now two tests: the override rule usesoff(the one mode the gate cannot veto), and a second pins the fold.Mobile oversight
Approve & sendsat above a draft that appeared nowhere. The collapsed bar is now a singleReview 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.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.mdinstead 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 behindmd:/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_approle under RLS.Added in 49fb890 — Review replaces Learning
/dashboard/learningbecomes/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: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.
---/ Unsubscribe footer, as the literal textcomposeOutreachBodywill 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.evidencewas already one call away on/v1/outreach/proposals/:idand 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.cadenceRules, which are not on the DTO, so they are omitted rather than faked.N edits against the agent's original), withSee 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.DTO additions (all read-only, all off data the queries already had):
ProposalCampaignSummary.ctaUrl(previously selected only to computeappendsCta, then discarded),ProposalDelivery.sender/senderNamefrom the channel's own addressing config routed throughpublicChannelConfigso no credential can reach a DTO, andProposalContactSummary.companyName.Pillgains amarkervariant 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.BodyDiffgainswrap: 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 importsLearningPage. It needsdashboard/review/{page,[id]/page}.tsxandReviewPagebefore 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