From 37fe74ceb872150148bcb3f050076559ccff3389 Mon Sep 17 00:00:00 2001 From: Leo Date: Tue, 16 Jun 2026 15:45:22 -0400 Subject: [PATCH 1/7] polish Amend homepage flow --- .../components/home/home-connect-section.tsx | 2 +- .../components/home/home-features-section.tsx | 19 +- apps/web/src/components/home/home-hero.tsx | 3 +- .../components/home/home-pricing-section.tsx | 5 +- .../components/home/home-workflow-section.tsx | 176 +++++++++++------- apps/web/src/index.css | 91 ++------- apps/web/src/routes/index.tsx | 1 - 7 files changed, 136 insertions(+), 161 deletions(-) diff --git a/apps/web/src/components/home/home-connect-section.tsx b/apps/web/src/components/home/home-connect-section.tsx index 231a799..78414c4 100644 --- a/apps/web/src/components/home/home-connect-section.tsx +++ b/apps/web/src/components/home/home-connect-section.tsx @@ -32,7 +32,7 @@ export function ConnectSection() { {group.items.map((item) => ( {item.brand ? ( diff --git a/apps/web/src/components/home/home-features-section.tsx b/apps/web/src/components/home/home-features-section.tsx index 3dfbcd9..2930c70 100644 --- a/apps/web/src/components/home/home-features-section.tsx +++ b/apps/web/src/components/home/home-features-section.tsx @@ -17,16 +17,17 @@ export function FeaturesSection() {

-
+
{features.map((feature) => ( -
- {feature.index} -
-

- {feature.title} -

-

{feature.copy}

-
+
+ {feature.index} +

+ {feature.title} +

+

{feature.copy}

))}
diff --git a/apps/web/src/components/home/home-hero.tsx b/apps/web/src/components/home/home-hero.tsx index 55735f3..9c9793f 100644 --- a/apps/web/src/components/home/home-hero.tsx +++ b/apps/web/src/components/home/home-hero.tsx @@ -7,11 +7,10 @@ export function HomeHero() { return (
-
-
+

Users asked. You shipped. diff --git a/apps/web/src/components/home/home-pricing-section.tsx b/apps/web/src/components/home/home-pricing-section.tsx index 333a9a9..17edc84 100644 --- a/apps/web/src/components/home/home-pricing-section.tsx +++ b/apps/web/src/components/home/home-pricing-section.tsx @@ -28,10 +28,11 @@ export function PricingSection() {
-

+

Pricing

+

Start free. Pay when it pays off.

-

+

Self-host the whole thing for nothing, or let Amend run it. Every plan includes the full loop. You only pay for more projects, seats, and signal volume.

diff --git a/apps/web/src/components/home/home-workflow-section.tsx b/apps/web/src/components/home/home-workflow-section.tsx index 4108d71..5e5e3f6 100644 --- a/apps/web/src/components/home/home-workflow-section.tsx +++ b/apps/web/src/components/home/home-workflow-section.tsx @@ -1,9 +1,11 @@ +import type { ReactNode } from "react"; + import { docsUrl } from "@/lib/docs-url"; -import { BrandIcon } from "./brand-icons"; +import { BrandIcon, type BrandName } from "./brand-icons"; import { requestStory } from "./home-content"; -function LoopGlyph({ className = "size-[22px]" }: { className?: string }) { +function LoopGlyph({ className = "size-4" }: { className?: string }) { return ( - ); } +/** Channels the shipped update reaches the people who asked through. */ +const channels: { label: string; brand?: BrandName }[] = [ + { label: "Changelog" }, + { label: "Discord", brand: "discord" }, + { label: "Email" }, + { label: "In-app widget" }, +]; + export function WorkflowSection() { const { ask, shipped } = requestStory; + + const steps: { icon: ReactNode; title: string; meta?: string; warm?: boolean; body: ReactNode }[] = + [ + { + icon: , + title: "A community member asked", + meta: `${ask.where} · ${ask.when}`, + body:

“{ask.message}”

, + }, + { + icon: +12, + title: "Grouped into one demand thread", + body: ( +

+ Repeated asks roll up with account context, so the weight is obvious before anyone + builds. +

+ ), + }, + { + icon: , + title: "Followed through GitHub & Linear", + body: ( +

+ issue opened → PR merged → released +

+ ), + }, + { + icon: , + title: shipped.title, + warm: true, + body: ( + <> +

{shipped.note}

+
+ {channels.map((channel) => ( + + {channel.brand ? ( + + ) : null} + {channel.label} + + ))} +
+ + ), + }, + ]; + return (
-
+
-

+

Workflow

+

Follow one request from message to shipped.

-

+

Amend keeps working after setup. It watches selected sources, groups real demand, - follows GitHub and Linear, and helps the team update people when the work ships. + follows GitHub and Linear, and updates the people who asked when the work ships.

-
- {/* The ask */} -
-
+
    + {steps.map((step, index) => ( +
  1. + {index < steps.length - 1 ? ( + + ) : null} - + {step.icon} -
    -

    {ask.who}

    -

    - {ask.where} · {ask.when} -

    +
    +
    +

    {step.title}

    + {step.meta ? ( + {step.meta} + ) : null} +
    + {step.body}
    -
    -

    {ask.message}

    -
    - - +12 asked - - grouped into one thread -
    -
- - {/* Amend, closing the loop. Connector is vertical on mobile, horizontal on desktop. */} -
- - -
- - - - - Amend - -
- - -
+ + ))} + - {/* Shipped */} -
-
- - - {shipped.label} - - -
-

{shipped.title}

-

{shipped.note}

-
- - - changelog - - email - in-app widget -
-
-
- -
-

- Posts go out by your rule: silent, a quiet receipt, or only after approval. -

+

+ Posts go out by your rule — silent, a quiet receipt, or only after approval.{" "} See the source trace -

+

); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e1fdf2d..168a743 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -105,7 +105,7 @@ } /* ---------------------------------------------------------------------------- - Hero — full-bleed cinematic "closing the loop" composition + Hero — one calm "closing the loop" image, anchored right, text left ---------------------------------------------------------------------------- */ .amend-hero { position: relative; @@ -115,41 +115,20 @@ overflow: hidden; } -/* The generated warm-loop photograph, anchored to the right. Painted as a - background so a still-missing asset degrades cleanly to the CSS aura below - (no broken-image icon). */ +/* The warm-loop photograph, anchored to the right and held still. */ .amend-hero-photo { position: absolute; inset: 0; - z-index: -3; - opacity: 0.9; + z-index: -2; + opacity: 0.92; background-image: url("/images/hero-loop.webp"); background-size: cover; - background-position: 76% 44%; + background-position: 78% 42%; background-repeat: no-repeat; - animation: amend-hero-drift 24s ease-in-out infinite alternate; } -/* CSS-built warm signal arc — enhances the photo, and stands alone as a - premium fallback if the asset is missing. */ -.amend-hero-aura { - position: absolute; - inset: -10% -5% -20% 20%; - z-index: -2; - pointer-events: none; - background: - radial-gradient( - 60% 55% at 72% 38%, - rgb(var(--amend-glow) / 0.26) 0%, - rgb(var(--amend-glow) / 0.08) 38%, - transparent 68% - ), - radial-gradient(40% 60% at 88% 70%, rgb(var(--amend-glow) / 0.12) 0%, transparent 60%); - filter: blur(8px); - animation: amend-hero-pulse 9s ease-in-out infinite; -} - -/* Legibility veil: keeps the left text column on calm darkness. */ +/* Legibility veil: dark under the left text column, clearing so the loop reads + on the right, with a soft fade into the next section along the bottom edge. */ .amend-hero-veil { position: absolute; inset: 0; @@ -159,44 +138,31 @@ linear-gradient( 90deg, var(--background) 0%, - rgb(13 13 15 / 0.92) 34%, - rgb(13 13 15 / 0.55) 58%, - transparent 92% + rgb(13 13 15 / 0.88) 30%, + rgb(13 13 15 / 0.4) 56%, + transparent 82% ), - linear-gradient(to top, var(--background) 2%, transparent 28%), - linear-gradient(to bottom, var(--background) 0%, transparent 22%); + linear-gradient(to bottom, transparent 62%, var(--background) 100%); } @media (max-width: 767px) { .amend-hero-photo { - object-position: 70% 30%; - opacity: 0.55; + opacity: 0.5; + background-position: 72% 30%; } .amend-hero-veil { background: linear-gradient( 90deg, - var(--background) 8%, - rgb(13 13 15 / 0.86) 52%, - rgb(13 13 15 / 0.62) 100% + var(--background) 6%, + rgb(13 13 15 / 0.82) 50%, + rgb(13 13 15 / 0.5) 100% ), - linear-gradient(to top, var(--background) 4%, transparent 40%), - linear-gradient(to bottom, var(--background) 0%, transparent 30%); + linear-gradient(to bottom, transparent 55%, var(--background) 100%); } } -/* fine film grain over the whole page, GPU-cheap (fixed, no repaint on scroll) */ -.amend-grain { - position: fixed; - inset: 0; - z-index: 30; - pointer-events: none; - opacity: 0.15; - mix-blend-mode: soft-light; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); -} - /* Hero entrance — staggered rise */ .amend-rise > * { animation: amend-rise 0.85s var(--amend-ease) both; @@ -336,27 +302,6 @@ } } -@keyframes amend-hero-pulse { - 0%, - 100% { - opacity: 0.85; - transform: scale(1); - } - 50% { - opacity: 1; - transform: scale(1.04); - } -} - -@keyframes amend-hero-drift { - 0% { - transform: scale(1.02) translateX(0); - } - 100% { - transform: scale(1.08) translateX(-1.5%); - } -} - @keyframes amend-marquee { 0% { transform: translate3d(0, 0, 0); @@ -376,8 +321,6 @@ animation-iteration-count: 1 !important; } - .amend-hero-photo, - .amend-hero-aura, .amend-rise > *, .amend-flowline, .amend-flowline-v, diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 0a0f33f..346edac 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -56,7 +56,6 @@ function HomeComponent() { }), }} /> -
From 89d337e5e18bd0c313d87f669590e9fc7187aa64 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 18 Jun 2026 22:03:39 -0400 Subject: [PATCH 2/7] update --- BACKEND_PLAN.txt | 241 ++++++ FRONTEND_PLAN.txt | 187 ++++ .../web/src/components/amend-agent-chrome.tsx | 68 ++ .../web/src/components/amend-agent-shared.tsx | 431 ++++++++++ .../web/src/components/amend-board-screen.tsx | 410 +++++++++ .../components/amend-dashboard-constants.ts | 11 +- .../components/amend-dashboard-core-types.ts | 10 +- .../amend-dashboard-main-workspace.tsx | 9 + .../amend-dashboard-status-utils.ts | 5 +- .../src/components/amend-drafts-screen.tsx | 219 +++++ .../src/components/amend-memory-screen.tsx | 294 +++++++ .../components/amend-need-detail-screen.tsx | 402 +++++++++ .../components/dashboard-sidebar-chrome.tsx | 43 +- .../web/src/components/dashboard-view-nav.tsx | 14 +- apps/web/src/index.css | 19 +- apps/web/src/lib/amend-agent-format.ts | 92 ++ apps/web/src/lib/amend-contract.ts | 143 ++++ apps/web/src/lib/icons.tsx | 36 + apps/web/src/lib/mock-amend.ts | 809 ++++++++++++++++++ packages/backend/convex/_generated/api.d.ts | 34 + packages/backend/convex/agent.ts | 54 ++ .../amendFeedbackCreateMutationHandler.ts | 13 + packages/backend/convex/amendSourceIngest.ts | 13 + packages/backend/convex/backfill.ts | 29 + packages/backend/convex/changelog.ts | 59 ++ packages/backend/convex/crons.ts | 14 + packages/backend/convex/digest.ts | 101 +++ packages/backend/convex/drafts.ts | 150 ++++ packages/backend/convex/http.ts | 4 + packages/backend/convex/ingest.ts | 41 + packages/backend/convex/memory.ts | 68 ++ packages/backend/convex/needs.ts | 136 +++ packages/backend/convex/pipeline.ts | 208 +++++ packages/backend/convex/proactiveArgs.ts | 51 ++ .../backend/convex/proactiveClassifier.ts | 100 +++ packages/backend/convex/proactiveProof.ts | 130 +++ packages/backend/convex/proactiveShared.ts | 231 +++++ .../backend/convex/proactiveValidators.ts | 157 ++++ .../backend/convex/schemaProactiveTables.ts | 184 ++++ .../backend/convex/schemaWorkspaceTables.ts | 2 + packages/backend/convex/sources.ts | 41 + packages/backend/tests/draftSendGate.test.ts | 36 + .../backend/tests/proactiveClassifier.test.ts | 37 + 43 files changed, 5329 insertions(+), 7 deletions(-) create mode 100644 BACKEND_PLAN.txt create mode 100644 FRONTEND_PLAN.txt create mode 100644 apps/web/src/components/amend-agent-chrome.tsx create mode 100644 apps/web/src/components/amend-agent-shared.tsx create mode 100644 apps/web/src/components/amend-board-screen.tsx create mode 100644 apps/web/src/components/amend-drafts-screen.tsx create mode 100644 apps/web/src/components/amend-memory-screen.tsx create mode 100644 apps/web/src/components/amend-need-detail-screen.tsx create mode 100644 apps/web/src/lib/amend-agent-format.ts create mode 100644 apps/web/src/lib/amend-contract.ts create mode 100644 apps/web/src/lib/mock-amend.ts create mode 100644 packages/backend/convex/agent.ts create mode 100644 packages/backend/convex/backfill.ts create mode 100644 packages/backend/convex/changelog.ts create mode 100644 packages/backend/convex/crons.ts create mode 100644 packages/backend/convex/digest.ts create mode 100644 packages/backend/convex/drafts.ts create mode 100644 packages/backend/convex/ingest.ts create mode 100644 packages/backend/convex/memory.ts create mode 100644 packages/backend/convex/needs.ts create mode 100644 packages/backend/convex/pipeline.ts create mode 100644 packages/backend/convex/proactiveArgs.ts create mode 100644 packages/backend/convex/proactiveClassifier.ts create mode 100644 packages/backend/convex/proactiveProof.ts create mode 100644 packages/backend/convex/proactiveShared.ts create mode 100644 packages/backend/convex/proactiveValidators.ts create mode 100644 packages/backend/convex/schemaProactiveTables.ts create mode 100644 packages/backend/convex/sources.ts create mode 100644 packages/backend/tests/draftSendGate.test.ts create mode 100644 packages/backend/tests/proactiveClassifier.test.ts diff --git a/BACKEND_PLAN.txt b/BACKEND_PLAN.txt new file mode 100644 index 0000000..e6667ad --- /dev/null +++ b/BACKEND_PLAN.txt @@ -0,0 +1,241 @@ +=============================================================================== +AMEND — PROACTIVE AGENT · BACKEND PLAN (v1) +Stack: Convex (packages/backend/convex). TypeScript. Better Auth. PostHog. +Pairs with: FRONTEND_PLAN.txt (same CONTRACT block lives in both — keep in sync) +=============================================================================== + +------------------------------------------------------------------------------- +COORDINATION PROTOCOL — READ FIRST (this is how the two plans don't collide) +------------------------------------------------------------------------------- +1. OWNERSHIP. Backend touches ONLY `packages/backend/convex/**`. It never edits + anything under `apps/web/**`. Frontend never edits `convex/**`. No shared + source files except the CONTRACT (below), which is the single agreed seam. + +2. THE CONTRACT IS THE SEAM. The only thing the two sides share is the set of + Convex query/mutation names + their argument and return shapes (the CONTRACT + section). Frontend codes against those shapes; backend implements them. + Nothing else crosses the line. + +3. STUBS FIRST (unblocks frontend on day one). Backend Phase 0 ships every + CONTRACT function as a STUB that returns fixture data of the correct shape. + The moment P0 lands, frontend can call real `api.*` functions and get + correctly-typed (fake) data, then we fill in real logic behind the same + signatures. Frontend is NEVER blocked waiting on backend logic. + +4. CHANGING THE CONTRACT. If a signature/shape must change, update the CONTRACT + block in BOTH files in the same commit and tell the other side. Never change + a return shape silently — that is the one thing that breaks the other side. + +5. INTEGRATION HANDSHAKE. Per screen, frontend flips one data hook from its + mock layer to `useQuery(api.x.y)`. Because the stub already returns the real + shape, that swap is a no-op for the UI. Do screens one at a time. + +------------------------------------------------------------------------------- +CONTRACT — the Convex API surface (IDENTICAL in FRONTEND_PLAN.txt) +------------------------------------------------------------------------------- +All functions are workspace-scoped; `workspaceId` is resolved from the auth'd +session server-side (frontend passes it explicitly only where noted). Types are +the RETURN shapes the frontend renders. Treat these as load-bearing. + + // ---- shared data shapes ---- + Ghost = { + id, workspaceId, title, + status: "ghost" | "accepted" | "killed", + proof: { + people: number, // distinct identity-verified people + payingPeople: number, // subset that map to a paying account + sources: { channel: "discord"|"support"|"github"|"embed", count: number }[], + strength: "thin" | "building" | "strong", // derived, NOT a raw float + growthPerWeek: number + }, + sampleQuotes: { text, author, channel, url }[], // max 3 + firstSeen: number, lastSeen: number + } + + Need = Ghost & { // an accepted ghost + evidence: Evidence[], + linkedShip?: ShipLink + } + + Evidence = { id, sourceChannel, author, text, url, + confidenceBucket: "clear"|"worth-a-look"|"unsure", + promotedBy: "agent"|"human" } + + ShipLink = { prNumber, sha, releaseTag?, mergedAt, url } + + DraftProposal = { id, kind: "changelog"|"notify", + needId, needTitle, draftText, + recipients?: { handle, channel }[], + status: "pending"|"approved"|"rejected" } + + ChangelogEntry = { id, title, body, shippedAt, ship: ShipLink, published: boolean } + + MemoryRule = { id, kind: "noise"|"dedupe"|"addressed"|"allowlist"|"pattern", + text, taughtBy, taughtAt, blastRadius: number, enabled: boolean } + + DigestPreview = { + period: { from, to }, + resolved: { needTitle, ship: ShipLink, peopleNotified: number }[], + readyGhosts: Ghost[], + handledSilently: number + } + + // ---- QUERIES (frontend reads; reactive) ---- + needs.listGhosts({ }) -> Ghost[] // status === "ghost", sorted by strength + needs.listAccepted({ }) -> Need[] // status === "accepted" + needs.get({ needId }) -> Need | null // full evidence trail + drafts.listPending({ }) -> DraftProposal[] + changelog.list({ }) -> ChangelogEntry[] + memory.listRules({ }) -> MemoryRule[] + digest.preview({ }) -> DigestPreview + sources.status({ }) -> { github: boolean, feedback: boolean } + + // ---- MUTATIONS (user actions from the UI) ---- + needs.acceptGhost({ ghostId }) -> { ok: true } + needs.keepGathering({ ghostId }) -> { ok: true } + needs.killGhost({ ghostId, reason? }) -> { ok: true } // writes noise: memory + drafts.approve({ draftId }) -> { ok: true } // triggers the actual send + drafts.reject({ draftId, edits? }) -> { ok: true } + memory.toggleRule({ ruleId, enabled }) -> { ok: true } + memory.undoRule({ ruleId }) -> { ok: true } + changelog.publish({ entryId }) -> { ok: true } + sources.connectGithub({ repo, token }) -> { ok: true } // kicks off backfill + + // ---- INTERNAL (webhooks / cron / pipeline — UI NEVER calls these) ---- + httpAction ingest.githubWebhook + httpAction ingest.discordWebhook + internalAction pipeline.processEvent + internalAction backfill.gitHistory + internalMutation agent.linkShipsToNeeds + cron digest.sendWeekly + +=============================================================================== +BUILD PHASES (sequenced so frontend is unblocked early, hard parts come later) +=============================================================================== + +PHASE 0 — SCHEMA + CONTRACT STUBS (do this first; unblocks frontend immediately) +------------------------------------------------------------------------------- +GOAL: every CONTRACT query/mutation exists and returns correctly-shaped fixture +data. No real logic yet. +- Extend schema in `convex/schema*.ts`. New tables (reuse existing automation + tables where they fit — automationDecisions ≈ DraftProposal, agentRuns = run + ledger): + needs (the ghost/need; status, title, denormalized proof.* fields, + conditionFlags for cheap trigger checks) + evidence (many-to-many join: needId, sourceEventId, person, channel, + handle, text, url, confidenceBucket, promotedBy) + needVectors (SEPARATE table: needId, embedding[]; vector index, filter + field = workspaceId) // vectors are large; keep them apart + sourceEvents (raw verbatim ingest + thin normalized fields; immutable) + persons + identityHandles (identity graph; deterministic joins) + shipLinks (needId, prNumber, sha, releaseTag, mergedAt, url) + proposals (the human-gated drafts; reuse/extend automationDecisions) + changelogEntries + memoryRules (kind, text, taughtBy, taughtAt, enabled, version history) +- Write all CONTRACT functions as stubs returning fixtures of the right shape. +- DELIVERABLE: `bunx convex dev` typechecks; `api.needs.listGhosts` returns + 3 fake ghosts. Tell frontend P0 is live. +GOTCHAS: put `workspaceId` in vector index `filterFields`. One accent: do not +let the frontend import anything but generated `api` — keep internals private. + +PHASE 1 — GITHUB + GIT-HISTORY BACKFILL (the wedge + the cold-start fix) +------------------------------------------------------------------------------- +GOAL: connect a repo, replay 90 days of merged PRs/releases, populate the board. +- `sources.connectGithub` stores the connection, schedules `backfill.gitHistory`. +- `backfill.gitHistory`: CHUNKED + self-rescheduling (Convex action 10-min cap — + never backfill in one action). For each merged PR: create a shipLink; if the + PR body / linked issues mention a requester or an existing need, draft a + ChangelogEntry. Produces the "you closed #12 and never told the 3 people" moment. +- `ingest.githubWebhook`: HTTP action that ONLY enqueues (validate signature, + store raw sourceEvent, schedule pipeline). Idempotent on delivery id. +DELIVERABLE: connect repo -> changelog list populates from real history. +GOTCHAS: chunk backfill; idempotent handlers; never call OpenAI in the webhook. + +PHASE 2 — INGEST ONE FEEDBACK SOURCE + THE FUNNEL +------------------------------------------------------------------------------- +GOAL: one feedback inbound (the embed/SDK for v1; stable IDs + identity). +- The funnel before any expensive call: + Stage 0 free heuristics (bot/length/channel/regex) -> drop 70-90% + Stage 1 embedding-centroid gate (is-feedback vs chatter exemplars) + Stage 2 chat LLM ONLY on the ambiguous middle +- Route all model work through a bounded Workpool (concurrency 5-10) so bursts + queue, not stampede. Every handler idempotent (dedupe key = source msg id). +DELIVERABLE: a submitted feedback item flows in and becomes a sourceEvent that +survives the funnel. + +PHASE 3 — DEDUP ENGINE + GHOST PROOF ACCUMULATION (the hard core) +------------------------------------------------------------------------------- +GOAL: turn surviving signal into ghosts that accumulate proof correctly. +- classify -> retrieve -> judge -> commit: + classify into fixed schema (type, sentiment, facets: area/verb/polarity/platform) + retrieve top-K from needVectors (filter workspaceId) + judge with LLM ONLY when cosine in 0.80-0.95 band + facet guard (hardcoded): refuse merge across facet mismatch even at 0.97 + COMMIT via transactional MUTATION keyed by deterministic cluster key + (NOT in the action) -> closes the concurrent false-split race +- Recompute denormalized proof.* on the need each time evidence attaches: + people, payingPeople, sources[], strength (thin/building/strong = a derived + bucket, never a raw float exposed), growthPerWeek. +DELIVERABLE: two paraphrases of one need merge; "export" vs "import" do NOT. +`needs.listGhosts` returns real ghosts with real proof. + +PHASE 4 — IDENTITY (deterministic-first) + PAYING FLAG +------------------------------------------------------------------------------- +GOAL: proof counts distinct VERIFIED people, and knows who pays. +- persons/identityHandles via deterministic joins ONLY for v1: verified email, + OAuth-linked account, commit-email == ticket-email, exact handle. Fuzzy + inference is a non-auto suggestion (defer). +- payingPeople = persons mapped to a billing/paying account (flag for now). +DELIVERABLE: proof.people = distinct persons; proof.payingPeople correct. + +PHASE 5 — THE WALL + HUMAN-GATED PROPOSALS +------------------------------------------------------------------------------- +GOAL: nothing reaches a customer/public surface without a human + a safety gate. +- On accept/resolution, agent DRAFTS a proposal (changelog | notify). The LLM + emits a proposal row; it never sends. `drafts.approve` is the only path that + performs the real send / publish (deterministic code). +- Safety gate before any external crossing: PII/secret scan, paraphrase + + attribution-strip for public; support-sourced evidence NEVER auto-publishes. +DELIVERABLE: `drafts.listPending` returns real drafts; approve actually sends. + +PHASE 6 — MEMORY SYSTEM +------------------------------------------------------------------------------- +GOAL: learn from corrections, legibly and reversibly. +- killGhost(reason) / explicit "always noise" writes a memoryRule (kind=noise), + with taughtBy/taughtAt and a computed blastRadius (how many items it hides). +- Pipeline reads memoryRules before deciding (suppress / auto-attach / escalate). +- Rules are versioned + reversible (`memory.undoRule`); "not now" != durable + suppression (durable = admin action). Memory written from validated decisions + only, never raw model free-text. +DELIVERABLE: `memory.listRules` real; toggling a rule changes pipeline behavior. + +PHASE 7 — PROACTIVITY TRIGGERS + WEEKLY DIGEST +------------------------------------------------------------------------------- +GOAL: the push retention hook + honest triggers. +- Reactive: in-mutation condition checks schedule the actor (weight crossed / + linked PR merged). Scheduled sweep (cron) for time/absence/aggregate. +- `agent.linkShipsToNeeds`: when a PR merges, attach the shipLink to matching + needs, draft the "we shipped your thing" proposal. +- `digest.sendWeekly` (cron): compose DigestPreview, send via email/Slack. + THIS is the retention hook — measure open rate (the make-or-break metric). +DELIVERABLE: weekly digest sends; `digest.preview` powers the in-app preview. + +------------------------------------------------------------------------------- +VALIDATION (run the narrowest relevant check each phase) +------------------------------------------------------------------------------- +- `bunx convex dev` typechecks after every phase. +- Phase 3: unit-test the facet guard (export/import stay split) + the + transactional commit under two concurrent inserts. +- Phase 5: assert no function except `drafts.approve` can perform an external + send (grep for the send call; it lives in exactly one place behind the gate). +- Phase 7: trigger a test PR merge -> confirm a draft proposal appears, not a + sent message. + +------------------------------------------------------------------------------- +DO / DON'T +------------------------------------------------------------------------------- +DO: keep the pipeline hardcoded TS; LLM only at classify + pairwise-judge with + fixed prompts; treat all source text as untrusted delimited data. +DON'T: edit apps/web/**. DON'T change a CONTRACT return shape without updating + FRONTEND_PLAN.txt in the same commit. DON'T let the LLM call tools or send. +=============================================================================== diff --git a/FRONTEND_PLAN.txt b/FRONTEND_PLAN.txt new file mode 100644 index 0000000..2382f5f --- /dev/null +++ b/FRONTEND_PLAN.txt @@ -0,0 +1,187 @@ +=============================================================================== +AMEND — PROACTIVE AGENT · FRONTEND PLAN (v1) +Stack: TanStack Start + React 19 + TanStack Query (apps/web). Tailwind v4 + + shadcn/ui (packages/ui). Icons via @/lib/icons (Hugeicons — NEVER lucide). +Pairs with: BACKEND_PLAN.txt (same CONTRACT block lives in both — keep in sync) +=============================================================================== + +------------------------------------------------------------------------------- +COORDINATION PROTOCOL — READ FIRST (this is how the two plans don't collide) +------------------------------------------------------------------------------- +1. OWNERSHIP. Frontend touches ONLY `apps/web/**` (+ shared components in + `packages/ui` if needed). It never edits `packages/backend/convex/**`. + Backend never edits `apps/web/**`. The CONTRACT is the only shared seam. + +2. THE CONTRACT IS THE SEAM. Build every screen against the CONTRACT shapes + below — the Convex query/mutation names + their return types. Do not assume + anything about backend internals (tables, pipeline, LLM). If you need data + the CONTRACT doesn't expose, that's a CONTRACT change (rule 4), not a guess. + +3. NEVER BLOCKED ON BACKEND. Phase 0 builds a mock data layer returning fixtures + that match the CONTRACT exactly. Every screen is fully buildable + reviewable + against mocks before backend logic exists. You ship the whole UI on fixtures. + +4. CHANGING THE CONTRACT. If a screen needs a new field/function, update the + CONTRACT block in BOTH files in the same commit and tell backend. Never + render off a shape backend didn't agree to — that's the thing that desyncs. + +5. INTEGRATION HANDSHAKE. Once backend Phase 0 stubs are live, flip ONE data + hook per screen from the mock layer to `useQuery(api.x.y)`. Because the stub + returns the real shape, the swap is invisible to the component. One screen at + a time; the rest keep running on mocks until their backend function is ready. + +------------------------------------------------------------------------------- +CONTRACT — the Convex API surface (IDENTICAL in BACKEND_PLAN.txt) +------------------------------------------------------------------------------- +RETURN shapes you render. Workspace is resolved server-side. These are +load-bearing — render exactly these, nothing more. + + Ghost = { + id, workspaceId, title, + status: "ghost" | "accepted" | "killed", + proof: { + people: number, payingPeople: number, + sources: { channel: "discord"|"support"|"github"|"embed", count: number }[], + strength: "thin" | "building" | "strong", // render as a label/bar, never a number + growthPerWeek: number + }, + sampleQuotes: { text, author, channel, url }[], // max 3 + firstSeen: number, lastSeen: number + } + Need = Ghost & { evidence: Evidence[], linkedShip?: ShipLink } + Evidence = { id, sourceChannel, author, text, url, + confidenceBucket: "clear"|"worth-a-look"|"unsure", promotedBy: "agent"|"human" } + ShipLink = { prNumber, sha, releaseTag?, mergedAt, url } + DraftProposal = { id, kind: "changelog"|"notify", needId, needTitle, draftText, + recipients?: { handle, channel }[], status: "pending"|"approved"|"rejected" } + ChangelogEntry = { id, title, body, shippedAt, ship: ShipLink, published: boolean } + MemoryRule = { id, kind: "noise"|"dedupe"|"addressed"|"allowlist"|"pattern", + text, taughtBy, taughtAt, blastRadius: number, enabled: boolean } + DigestPreview = { period:{from,to}, resolved:{needTitle,ship:ShipLink,peopleNotified}[], + readyGhosts: Ghost[], handledSilently: number } + + QUERIES (reactive reads): + needs.listGhosts({}) -> Ghost[] needs.listAccepted({}) -> Need[] + needs.get({needId}) -> Need|null drafts.listPending({}) -> DraftProposal[] + changelog.list({}) -> ChangelogEntry[] memory.listRules({}) -> MemoryRule[] + digest.preview({}) -> DigestPreview sources.status({}) -> {github,feedback} + + MUTATIONS (user actions): + needs.acceptGhost({ghostId}) needs.keepGathering({ghostId}) + needs.killGhost({ghostId,reason?}) drafts.approve({draftId}) + drafts.reject({draftId,edits?}) memory.toggleRule({ruleId,enabled}) + memory.undoRule({ruleId}) changelog.publish({entryId}) + sources.connectGithub({repo,token}) + + INTERNAL (webhooks/cron) — the UI NEVER calls these. Do not reference them. + +=============================================================================== +DESIGN POSTURE (so it doesn't look AI-generated — match existing Amend theme) +=============================================================================== +- Calm, premium, rounded surfaces + ring-1 white/low-opacity borders (the current + theme; square border-border is legacy). Compact sans; MONO for data/IDs/SHAs. +- One restrained accent. Status color does semantic work (ghost=faint/dashed, + ready=accent, paying=warm). +- NO confidence decimals ever. Render strength as Thin / Building / Strong + a + bar, with a plain because-line ("12 people, 3 paying, across 3 sources"). +- The board is a BRIEFING not an inbox: lead with what the agent handled, not a + pile of pending work. No marketing hero, no card-soup. +- Every list has real loading (skeleton), empty, and error states. + +=============================================================================== +BUILD PHASES (every screen ships on mocks; swap to api.* per screen at the end) +=============================================================================== + +PHASE 0 — MOCK DATA LAYER + ROUTE SKELETON (do first; zero backend needed) +------------------------------------------------------------------------------- +- `apps/web/src/lib/amend-contract.ts`: TS interfaces copied from the CONTRACT + (single source the components import). This is your typed seam. +- `apps/web/src/lib/mock-amend.ts`: fixtures matching every CONTRACT return shape + (3 ghosts at different strengths, 2 accepted needs, 2 drafts, changelog, memory + rules, a digest). A `useAmend(queryName)` hook that returns mock data now and + will swap to `useQuery(api...)` later — keep the swap to ONE file. +- Routes (TanStack Start file-based): /board, /board/$needId, /drafts, + /changelog, /memory, /settings. +DELIVERABLE: app runs, all routes render fixtures, fully clickable. No backend. + +PHASE 1 — THE GHOST BOARD (the hero screen — invest the most here) +------------------------------------------------------------------------------- +Route: /board. Consumes: needs.listGhosts, needs.listAccepted, + needs.acceptGhost, needs.keepGathering, needs.killGhost. +- "Gathering proof" section: GHOST cards = faint, dashed border, ribbon + ("Just forming" vs pulsing "Ready when you are"). Each shows: title, the proof + line (people + payingPeople), source chips with counts, a strength bar + (thin/building/strong — NOT a %), growthPerWeek ("4 new this week"), one sample + quote. Actions: Add to board / Keep gathering / Not a thing. + -> "thin" strength = Add disabled (must earn it). optimistic update on accept. +- "On the board" section: accepted Needs (solid), linkedShip badge if present. +DELIVERABLE: the ghost board from the showcase, on real fixture data, with +working accept/keep/kill (optimistic). + +PHASE 2 — NEED DETAIL / EVIDENCE TRAIL +------------------------------------------------------------------------------- +Route: /board/$needId. Consumes: needs.get. +- SOURCES-FIRST (the differentiation — don't bury it). Headline = computed + synthesis ("9 people across Discord, GitHub, support — 3 paying — first raised + Mar 2, active this week"). Evidence grouped by source, collapsed, 2 quotes + inline. People (avatars) are the spine, not messages. linkedShip with the SHA. +DELIVERABLE: click a ghost/need -> readable evidence brief. + +PHASE 3 — DRAFT-REVIEW LANE +------------------------------------------------------------------------------- +Route: /drafts. Consumes: drafts.listPending, drafts.approve, drafts.reject. +- Each pending DraftProposal: kind (changelog/notify), the need it's for, the + draft text (editable), recipients for notify. Approve / Edit+approve / Reject. +- Make clear nothing sends until Approve (the trust surface). Pre-send hold note. +DELIVERABLE: review queue works on fixtures; approve/reject call mutations. + +PHASE 4 — PUBLIC CHANGELOG / PORTAL +------------------------------------------------------------------------------- +Route: /changelog. Consumes: changelog.list, changelog.publish. +- Entries with title/body/shippedAt + the SHA proof link (the wedge made visible). + Published vs draft state. This is also the public-facing surface (themeable). +DELIVERABLE: changelog renders; publish toggles state. + +PHASE 5 — MEMORY UI ("what Amend learned") +------------------------------------------------------------------------------- +Route: /memory. Consumes: memory.listRules, memory.toggleRule, memory.undoRule. +- Browsable rules grouped by kind (noise/dedupe/addressed/allowlist/pattern), in + PLAIN LANGUAGE, each with who taught it, when, blastRadius ("hides ~212/mo"), + a toggle, undo. The teaching receipt toast (on kill/correct). The audit nudge + ("this rule hid 40 items in 3 weeks — still right?"). +DELIVERABLE: the memory showcase, on real fixture rules, toggles working. + +PHASE 6 — SETTINGS + DIGEST PREVIEW +------------------------------------------------------------------------------- +Routes: /settings (+ a digest preview block). Consumes: sources.status, + sources.connectGithub, digest.preview. +- Connect GitHub (the cold-start entry point). Source status. Conservative/ + Balanced/Aggressive presets (NO raw numeric thresholds in onboarding). +- Digest preview: "3 asks resolved this week + proof; 1 ghost ready" — the thing + that gets pushed to Slack/email. (Sending is backend; this is the in-app view.) +DELIVERABLE: settings + a preview of the weekly digest. + +PHASE 7 — INTEGRATION SWAP (per screen, after backend P0+ lands) +------------------------------------------------------------------------------- +- For each screen whose backend function is implemented, flip `useAmend` from + mock to `useQuery(api.x.y)` / `useMutation`. Verify the shape matches (it will, + if both CONTRACT blocks agree). Keep un-ready screens on mocks. +DELIVERABLE: screens go live one by one with zero component rewrites. + +------------------------------------------------------------------------------- +VALIDATION +------------------------------------------------------------------------------- +- Typecheck + lint after every phase (oxlint). App runs on mocks end-to-end. +- Screenshot review (desktop + mobile) against the design posture each screen — + reject card-soup, raw decimals, buried evidence, empty-looking board. +- Phase 1 + 5 are the polish-critical screens; do an extra visual pass. + +------------------------------------------------------------------------------- +DO / DON'T +------------------------------------------------------------------------------- +DO: build the whole UI on fixtures first; keep the mock->api swap in ONE hook; + render exactly the CONTRACT shapes; icons from @/lib/icons. +DON'T: edit packages/backend/convex/**. DON'T import lucide-react. DON'T show + confidence decimals. DON'T add fields the CONTRACT doesn't define without + updating BACKEND_PLAN.txt in the same commit. +=============================================================================== diff --git a/apps/web/src/components/amend-agent-chrome.tsx b/apps/web/src/components/amend-agent-chrome.tsx new file mode 100644 index 0000000..aae49cd --- /dev/null +++ b/apps/web/src/components/amend-agent-chrome.tsx @@ -0,0 +1,68 @@ +/** + * Page chrome for the agent views, rendered inside the existing dashboard + * content column (so it sits under the shared sidebar + workspace switcher). + * Each agent view composes + the same way the + * existing feedback/roadmap/changelog workspaces compose their header + body. + */ +import { cn } from "@amend/ui/lib/utils"; +import type { ReactNode } from "react"; + +import type { LucideIcon } from "@/lib/icons"; + +export function PageHeader({ + icon: Icon, + title, + subtitle, + actions, +}: { + icon?: LucideIcon; + title: ReactNode; + subtitle?: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+ {Icon ? ( + + + + ) : null} +
+

+ {title} +

+ {subtitle ? ( +

{subtitle}

+ ) : null} +
+
+ {actions ?
{actions}
: null} +
+ ); +} + +/** Scrollable body with a restrained, reduced-motion-aware view-enter. */ +export function PageScroll({ + children, + className, + routeKey, +}: { + children: ReactNode; + className?: string; + routeKey?: string; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/web/src/components/amend-agent-shared.tsx b/apps/web/src/components/amend-agent-shared.tsx new file mode 100644 index 0000000..fcc201b --- /dev/null +++ b/apps/web/src/components/amend-agent-shared.tsx @@ -0,0 +1,431 @@ +/** + * Shared primitives for the proactive-agent console. + * + * One small design system, locked to the existing Amend dark theme: rounded + * surfaces, hairline white rings, mono for data, a single warm accent for + * "paying" and a single success accent for "ready". Status color does the + * semantic work; everything else stays grayscale on purpose. + */ +import { cn } from "@amend/ui/lib/utils"; +import type { ComponentProps, ReactNode } from "react"; + +import { + AlertCircle, + Discord, + Github, + GitMerge, + Globe, + LifeBuoy, + Loader2, + Sparkles, + type LucideIcon, +} from "@/lib/icons"; +import type { + ConfidenceBucket, + Proof, + ProofStrength, + ShipLink, + SourceChannel, +} from "@/lib/amend-contract"; +import { formatDayMonth, strengthLabel, strengthSegments } from "@/lib/amend-agent-format"; + +// --------------------------------------------------------------------------- +// Source channels +// --------------------------------------------------------------------------- + +export const channelMeta: Record = { + discord: { label: "Discord", Icon: Discord }, + support: { label: "Support", Icon: LifeBuoy }, + github: { label: "GitHub", Icon: Github }, + embed: { label: "Embed", Icon: Globe }, +}; + +export function ChannelGlyph({ + channel, + className, +}: { + channel: SourceChannel; + className?: string; +}) { + const { Icon, label } = channelMeta[channel]; + return ; +} + +/** Neutral source chip with a mono count — color stays reserved for status. */ +export function SourceChip({ channel, count }: { channel: SourceChannel; count: number }) { + const { label } = channelMeta[channel]; + return ( + + + {label} + {count} + + ); +} + +export function SourceChips({ + sources, + className, +}: { + sources: { channel: SourceChannel; count: number }[]; + className?: string; +}) { + return ( +
+ {sources.map((s) => ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Proof: strength meter + because-line +// --------------------------------------------------------------------------- + +const strengthFill: Record = { + thin: "bg-muted-foreground/45", + building: "bg-foreground/70", + strong: "bg-amend-success", +}; + +export function StrengthMeter({ + strength, + withLabel = true, + className, +}: { + strength: ProofStrength; + withLabel?: boolean; + className?: string; +}) { + const filled = strengthSegments(strength); + return ( + + + {[0, 1, 2].map((i) => ( + + ))} + + {withLabel ? ( + + {strengthLabel(strength)} + + ) : null} + + ); +} + +/** "12 people · 3 paying · across 3 sources" — paying carries the warm accent. */ +export function ProofLine({ proof, className }: { proof: Proof; className?: string }) { + return ( +

+ {proof.people}{" "} + people + {proof.payingPeople > 0 ? ( + <> + {" · "} + + {proof.payingPeople} paying + + + ) : null} + {" · across "} + + {proof.sources.length} + {" "} + {proof.sources.length === 1 ? "source" : "sources"} +

+ ); +} + +// --------------------------------------------------------------------------- +// People spine — avatars +// --------------------------------------------------------------------------- + +export function initialsOf(name: string): string { + const clean = name + .replace(/^@/, "") + .replace(/[·•|].*/, "") + .trim(); + const parts = clean.split(/[\s_\-./]+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return clean.slice(0, 2).toUpperCase() || "?"; +} + +const avatarSize = { + sm: "size-6 text-[0.58rem]", + md: "size-7 text-[0.62rem]", + lg: "size-9 text-[0.7rem]", +} as const; + +export function Avatar({ + name, + size = "md", + className, + title, +}: { + name: string; + size?: keyof typeof avatarSize; + className?: string; + title?: string; +}) { + return ( + + {initialsOf(name)} + + ); +} + +export function AvatarStack({ + names, + max = 5, + size = "md", +}: { + names: string[]; + max?: number; + size?: keyof typeof avatarSize; +}) { + const shown = names.slice(0, max); + const extra = names.length - shown.length; + return ( +
+ {shown.map((name, i) => ( + 0 && "-ml-2")} + /> + ))} + {extra > 0 ? ( + + +{extra} + + ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Ship proof (SHA) +// --------------------------------------------------------------------------- + +export function ShaChip({ ship, className }: { ship: ShipLink; className?: string }) { + return ( + + + #{ship.prNumber} + + {ship.sha} + + {ship.releaseTag ? ( + + {ship.releaseTag} + + ) : null} + + ); +} + +// --------------------------------------------------------------------------- +// Confidence +// --------------------------------------------------------------------------- + +export const confidenceMeta: Record = { + clear: { label: "Clear", dot: "bg-foreground" }, + "worth-a-look": { label: "Worth a look", dot: "bg-muted-foreground" }, + unsure: { label: "Unsure", dot: "bg-transparent ring-1 ring-muted-foreground/55 ring-inset" }, +}; + +export function ConfidenceTag({ bucket }: { bucket: ConfidenceBucket }) { + const { label, dot } = confidenceMeta[bucket]; + return ( + + + {label} + + ); +} + +// --------------------------------------------------------------------------- +// Agent identity mark +// --------------------------------------------------------------------------- + +export function AgentMark({ className }: { className?: string }) { + return ( + + + + ); +} + +// --------------------------------------------------------------------------- +// Buttons (rounded theme — the square @amend/ui Button is legacy) +// --------------------------------------------------------------------------- + +type ActionVariant = "primary" | "secondary" | "ghost" | "success" | "danger"; +type ActionSize = "sm" | "md"; + +const actionBase = + "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg font-semibold transition-colors duration-150 ease-linear outline-none select-none active:opacity-75 disabled:pointer-events-none disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ring/45 [&_svg]:shrink-0"; + +const actionVariant: Record = { + primary: "border border-foreground bg-foreground text-background hover:bg-foreground/85", + secondary: + "bg-white/[0.04] text-foreground ring-1 ring-white/[0.09] ring-inset hover:bg-white/[0.07]", + ghost: "text-muted-foreground hover:bg-white/[0.05] hover:text-foreground", + success: + "border border-amend-success/25 bg-amend-success/12 text-amend-success hover:bg-amend-success/[0.18]", + danger: "text-muted-foreground hover:bg-destructive/12 hover:text-destructive", +}; + +const actionSize: Record = { + sm: "h-7 px-2.5 text-[0.72rem] [&_svg]:size-3.5", + md: "h-8 px-3 text-xs [&_svg]:size-4", +}; + +export function agentButtonClass( + variant: ActionVariant = "secondary", + size: ActionSize = "md", + className?: string, +) { + return cn(actionBase, actionVariant[variant], actionSize[size], className); +} + +export function ActionButton({ + variant = "secondary", + size = "md", + className, + ...props +}: ComponentProps<"button"> & { variant?: ActionVariant; size?: ActionSize }) { + return + +
+ + + + + +
+ + + + {growthPhrase(ghost.proof.growthPerWeek)} + + + first raised {formatDayMonth(ghost.firstSeen)} + +
+ + {quote ? ( +
+
+ +
+
+ “{quote.text}” +
+
+ + {quote.author} + · {channelMeta[quote.channel].label} +
+
+
+
+ ) : null} + +
+ + + Add to board + + + + Keep gathering + + + + Not a thing + +
+ + {thin ? ( +

+ Too thin to add yet — the agent keeps gathering until the proof earns a spot. +

+ ) : null} + + ); +} + +function AcceptedRow({ need, onOpen }: { need: Need; onOpen: () => void }) { + const requesters = need.sampleQuotes.map((q) => q.author); + return ( + + ); +} + +function BoardSkeleton() { + return ( +
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+
+ + {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+ ); +} + +function BoardBriefing({ + handledSilently, + onBoard, + gathering, + ready, +}: { + handledSilently: number; + onBoard: number; + gathering: number; + ready: number; +}) { + const cells: { label: string; value: number; accent?: boolean }[] = [ + { label: "Handled silently", value: handledSilently }, + { label: "On the board", value: onBoard }, + { label: "Gathering", value: gathering }, + { label: "Ready", value: ready, accent: ready > 0 }, + ]; + return ( +
+
+ +

+ Handled {handledSilently}{" "} + signals on its own this week.{" "} + {gathering}{" "} + {gathering === 1 ? "need is" : "needs are"} still gathering proof + {ready > 0 ? ( + <> + {" — "} + {ready} ready when you are. + + ) : ( + "." + )} +

+
+
+ {cells.map((c) => ( +
+
+ {c.value} +
+
{c.label}
+
+ ))} +
+
+ ); +} + +export function AmendBoardScreen() { + const [selectedNeedId, setSelectedNeedId] = useState(null); + const ghostsQuery = useGhosts(); + const acceptedQuery = useAcceptedNeeds(); + const digestQuery = useDigestPreview(); + + if (selectedNeedId) { + return setSelectedNeedId(null)} />; + } + + const isLoading = ghostsQuery.isLoading || acceptedQuery.isLoading || digestQuery.isLoading; + const isError = ghostsQuery.isError || acceptedQuery.isError || digestQuery.isError; + + const ghosts = ghostsQuery.data ?? []; + const accepted = acceptedQuery.data ?? []; + const ready = ghosts.filter((g) => g.proof.strength === "strong").length; + + return ( + <> + + + {isError ? ( + + ) : isLoading ? ( + + ) : ghosts.length === 0 && accepted.length === 0 ? ( + + Connect a source + + } + /> + ) : ( + <> + + +
+ On the board + {accepted.length ? ( +
+ {accepted.map((need) => ( + setSelectedNeedId(need.id)} + /> + ))} +
+ ) : ( +

+ Nothing committed yet. Add a ghost below when its proof is strong enough. +

+ )} +
+ +
+
+ Gathering proof + Sorted by strength +
+ {ghosts.length ? ( +
+ {ghosts.map((ghost) => ( + setSelectedNeedId(ghost.id)} + /> + ))} +
+ ) : ( +

+ No ghosts gathering right now — the agent is watching your sources. +

+ )} +
+ + )} +
+ + ); +} diff --git a/apps/web/src/components/amend-dashboard-constants.ts b/apps/web/src/components/amend-dashboard-constants.ts index 1144d8f..69696c4 100644 --- a/apps/web/src/components/amend-dashboard-constants.ts +++ b/apps/web/src/components/amend-dashboard-constants.ts @@ -6,7 +6,16 @@ import type { Workspace, } from "@/components/amend-dashboard-types"; -export const viewValues: DashboardView[] = ["posts", "roadmap", "changelog", "settings", "setup"]; +export const viewValues: DashboardView[] = [ + "posts", + "roadmap", + "changelog", + "board", + "drafts", + "memory", + "settings", + "setup", +]; export const boardValues: BoardId[] = ["feature", "bug", "changelog", "feedback"]; export const statusValues: Array = [ "all", diff --git a/apps/web/src/components/amend-dashboard-core-types.ts b/apps/web/src/components/amend-dashboard-core-types.ts index 653caf4..6974b9a 100644 --- a/apps/web/src/components/amend-dashboard-core-types.ts +++ b/apps/web/src/components/amend-dashboard-core-types.ts @@ -2,7 +2,15 @@ import type { ReactElement } from "react"; export type RoadmapStatus = "backlog" | "next" | "progress" | "done"; export type ChangelogStatusFilter = "all" | "draft" | "in_review" | "scheduled" | "published"; -export type DashboardView = "posts" | "roadmap" | "changelog" | "settings" | "setup"; +export type DashboardView = + | "posts" + | "roadmap" + | "changelog" + | "board" + | "drafts" + | "memory" + | "settings" + | "setup"; export type SettingsSection = "accounts" | "automation" | "general" | "portal" | "services"; export type BoardId = "feature" | "bug" | "changelog" | "feedback"; export type WorkspaceId = string; diff --git a/apps/web/src/components/amend-dashboard-main-workspace.tsx b/apps/web/src/components/amend-dashboard-main-workspace.tsx index 4760822..456bc09 100644 --- a/apps/web/src/components/amend-dashboard-main-workspace.tsx +++ b/apps/web/src/components/amend-dashboard-main-workspace.tsx @@ -4,6 +4,9 @@ import type { DashboardView, Post, } from "@/components/amend-dashboard-types"; +import { AmendBoardScreen } from "@/components/amend-board-screen"; +import { AmendDraftsScreen } from "@/components/amend-drafts-screen"; +import { AmendMemoryScreen } from "@/components/amend-memory-screen"; import { ChangelogWorkspace, PostsWorkspace, @@ -58,6 +61,12 @@ export function AmendDashboardMainWorkspace({ onVoteRoadmapItem, roadmapViews, }: DashboardContentProps) { + // The proactive-agent views are self-contained (their own header + scroll) and + // render on the mock layer, so they bypass the CRUD DashboardHeader entirely. + if (activeView === "board") return ; + if (activeView === "drafts") return ; + if (activeView === "memory") return ; + const onboarding = computeOnboardingState({ activeProject, dashboard, workspace }); const showOnboarding = Boolean(dashboard) && activeProject.id !== "new-project" && ONBOARDING_VIEWS.has(activeView); diff --git a/apps/web/src/components/amend-dashboard-status-utils.ts b/apps/web/src/components/amend-dashboard-status-utils.ts index f31f514..4055d12 100644 --- a/apps/web/src/components/amend-dashboard-status-utils.ts +++ b/apps/web/src/components/amend-dashboard-status-utils.ts @@ -80,7 +80,7 @@ export function composerStatusToRoadmapStatus(status: ComposerSubmitPayload["sta export function normalizeView(value?: string): DashboardView { if (value === "members") return "settings"; - if (value === "share" || value === "board") return "posts"; + if (value === "share") return "posts"; return viewValues.includes(value as DashboardView) ? (value as DashboardView) : "posts"; } @@ -118,6 +118,9 @@ export function viewTitle(view: DashboardView) { posts: "Feedback", roadmap: "Roadmap", changelog: "Changelog", + board: "The board", + drafts: "Drafts", + memory: "Memory", settings: "Settings", setup: "Create project", }; diff --git a/apps/web/src/components/amend-drafts-screen.tsx b/apps/web/src/components/amend-drafts-screen.tsx new file mode 100644 index 0000000..0322ae5 --- /dev/null +++ b/apps/web/src/components/amend-drafts-screen.tsx @@ -0,0 +1,219 @@ +/** + * PHASE 3 — Draft-review lane (the trust surface). + * + * The agent writes changelog entries and notify-the-requesters messages, then + * holds them here. Nothing sends until you approve. Every draft is editable in + * place (Edit + approve), and the pre-send hold is stated plainly up top. + */ +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; + +import { + ActionButton, + Avatar, + ChannelGlyph, + EmptyState, + ErrorState, + SectionLabel, + SkeletonBar, + channelMeta, +} from "@/components/amend-agent-shared"; +import { PageHeader, PageScroll } from "@/components/amend-agent-chrome"; +import type { DraftProposal } from "@/lib/amend-contract"; +import { approveDraft, rejectDraft, updateDraftText, usePendingDrafts } from "@/lib/mock-amend"; +import { Check, Inbox, Megaphone, Newspaper, ShieldCheck, SquarePen, X } from "@/lib/icons"; +import { toast } from "@/lib/toast"; + +const kindMeta = { + changelog: { label: "Changelog entry", Icon: Newspaper, verb: "added to your changelog" }, + notify: { label: "Notify requesters", Icon: Megaphone, verb: "sent to the people who asked" }, +} as const; + +function HoldNote() { + return ( +
+ +

+ Nothing here has gone out. The agent writes the drafts;{" "} + nothing sends until you approve. Edit + anything first — your wording wins. +

+
+ ); +} + +function RecipientChips({ draft }: { draft: DraftProposal }) { + if (!draft.recipients?.length) return null; + return ( +
+

+ Will reach{" "} + + {draft.recipients.length} + {" "} + {draft.recipients.length === 1 ? "person" : "people"} who asked +

+
+ {draft.recipients.map((r) => ( + + + {r.handle} + + + ))} +
+
+ ); +} + +function DraftCard({ draft }: { draft: DraftProposal }) { + const { label, Icon, verb } = kindMeta[draft.kind]; + const [editing, setEditing] = useState(false); + const [text, setText] = useState(draft.draftText); + + function approve(value?: string) { + if (value !== undefined) updateDraftText(draft.id, value); + approveDraft(draft.id); + toast.success({ + title: "Approved", + description: `Your ${draft.kind === "notify" ? "message" : "entry"} will be ${verb}.`, + }); + } + + function reject() { + rejectDraft(draft.id); + toast.info({ title: "Draft rejected", description: "The agent won't send this one." }); + } + + return ( +
+
+
+ + + +
+
{label}
+ + for “{draft.needTitle}” + +
+
+ + Held — not sent + +
+ +
+ + + {editing ? ( +