diff --git a/AGENTS.md b/AGENTS.md index 2d5ec33..833c870 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,12 @@ # Agent Guidelines — Amend +## Vocab + +Shorthand for dashboard UI regions (React DevTools component → the name the user says): + +- **Page content** — `DashboardWorkspaceSurface` (`dashboard-workspace-surface.tsx`): the rounded, scrollable main content region rendered inside every dashboard view. It's on every page — when the user says "page content," this is it. +- **Toolbar** — `ToolbarBar` (`dashboard-toolbar.tsx`) and its per-view wrappers (`RoadmapToolbar` / `FeedbackToolbar` / `ChangelogToolbar`): the full-width sub-nav/filter bar directly under the dashboard header. + ## UI Interaction Animations **No scale transforms on interactive elements.** Do not use `active:scale-[0.96]`, `hover:scale-*`, or any geometry-changing transform on buttons, links, sidebar items, or nav elements. They feel jittery and distracting. @@ -16,6 +23,10 @@ **Premium shell direction:** Amend should keep its own source-linked identity, but use a Tripwire-like level of refinement: near-black background, raised rounded app surfaces, soft borders, compact sans-serif body text, mono only for data/code, and small high-signal accents. Do not copy Tripwire one-for-one. +## Icons + +We use **Hugeicons only** — never import `lucide-react` (it is not a dependency). Import icons from the lucide-compatible wrapper: `@/lib/icons` in `apps/web`, `@amend/ui/components/icons` in `packages/ui`. Add a new glyph by mapping a Hugeicons `*Icon` export to a lucide-style name inside the wrapper. + ## Charting Do not install `recharts` — it has CJS/ESM interop issues with this Vite + TanStack Start setup (causes `require_isUnsafeProperty is not a function` at runtime). Use pure CSS/SVG bar charts instead (see `proactivation-analytics-panel.tsx` for the pattern: flex columns with `height: pct%` + `bg-foreground`). 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/fumadocs/content/docs/api-reference.mdx b/apps/fumadocs/content/docs/api-reference.mdx index 63c0776..5f80d72 100644 --- a/apps/fumadocs/content/docs/api-reference.mdx +++ b/apps/fumadocs/content/docs/api-reference.mdx @@ -90,5 +90,9 @@ signed provider webhooks remain callable without the owner token where appropria | `POST` | `/api/v1/:workspace/github` | `X-Hub-Signature-256` with `GITHUB_WEBHOOK_SECRET` | | `POST` | `/api/v1/:workspace/stripe` | Stripe webhook secret and raw body | +Legacy `/ingest/githubWebhook`, `/ingest/discordWebhook`, and `/ingest/sourceEvent` routes are +kept for compatibility. GitHub uses the same `X-Hub-Signature-256` verification, and the Discord +and generic source-event routes require the owner bearer token. + Use the OpenAPI file for schema-level details and generated client types. Use this page for the route/auth map when wiring product surfaces. diff --git a/apps/fumadocs/content/docs/source-events.mdx b/apps/fumadocs/content/docs/source-events.mdx index f2223b1..6b96507 100644 --- a/apps/fumadocs/content/docs/source-events.mdx +++ b/apps/fumadocs/content/docs/source-events.mdx @@ -52,6 +52,11 @@ POST /api/v1/:workspace/github The request must include `X-GitHub-Event`, `X-GitHub-Delivery`, and `X-Hub-Signature-256` when the secret is configured. +Legacy ingest routes under `/ingest/*` are fail-closed in production too: +`/ingest/githubWebhook` requires the same GitHub signature verification, while +`/ingest/discordWebhook` and `/ingest/sourceEvent` require +`Authorization: Bearer `. + ## CLI Imports The local CLI supports direct imports and file-based imports: diff --git a/apps/web/package.json b/apps/web/package.json index 738735e..4f197c0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@amend/ui": "workspace:*", "@convex-dev/better-auth": "catalog:", "@convex-dev/react-query": "^0.1.0", + "@dnd-kit/core": "^6.3.1", "@hugeicons/core-free-icons": "^4.2.0", "@hugeicons/react": "^1.1.6", "@posthog/cli": "^0.7.22", @@ -30,14 +31,14 @@ "convex": "catalog:", "dotenv": "catalog:", "gsap": "^3.15.0", - "lucide-react": "^1.18.0", "next-themes": "catalog:", "posthog-js": "^1.386.6", "react": "^19.2.7", "react-dom": "^19.2.7", "react-grab": "^0.1.44", "satori": "^0.26.0", - "sileo": "^0.1.5", + "simple-icons": "^16.23.0", + "sonner": "^2.0.5", "tailwindcss": "^4.3.1", "zod": "catalog:" }, diff --git a/apps/web/public/fonts/inter-400.woff2 b/apps/web/public/fonts/inter-400.woff2 new file mode 100644 index 0000000..f15b025 Binary files /dev/null and b/apps/web/public/fonts/inter-400.woff2 differ diff --git a/apps/web/public/fonts/inter-500.woff2 b/apps/web/public/fonts/inter-500.woff2 new file mode 100644 index 0000000..54f0a59 Binary files /dev/null and b/apps/web/public/fonts/inter-500.woff2 differ diff --git a/apps/web/public/fonts/inter-LICENSE.txt b/apps/web/public/fonts/inter-LICENSE.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/apps/web/public/fonts/inter-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/public/images/bg-hero.webp b/apps/web/public/images/bg-hero.webp new file mode 100644 index 0000000..9cb2305 Binary files /dev/null and b/apps/web/public/images/bg-hero.webp differ diff --git a/apps/web/src/components/account-avatar.tsx b/apps/web/src/components/account-avatar.tsx new file mode 100644 index 0000000..c6fc37e --- /dev/null +++ b/apps/web/src/components/account-avatar.tsx @@ -0,0 +1,40 @@ +import { cn } from "@amend/ui/lib/utils"; + +import { UserRound } from "@/lib/icons"; + +/** Two-letter monogram from a name (falls back to email), e.g. "Amend Dev" → "AD". */ +export function initialsFromIdentity(name: string | undefined, email: string | undefined) { + const source = (name ?? email ?? "").trim(); + if (!source) return ""; + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase(); + return source.slice(0, 2).toUpperCase(); +} + +/** The signed-in person's avatar: image when present, else a monogram, else a glyph. */ +export function AccountAvatar({ + className, + image, + initials, +}: { + className?: string; + image?: string | null; + initials: string; +}) { + return ( + + {image ? ( + + ) : initials ? ( + initials + ) : ( + + )} + + ); +} diff --git a/apps/web/src/components/account-workspace.tsx b/apps/web/src/components/account-workspace.tsx new file mode 100644 index 0000000..f6d5af3 --- /dev/null +++ b/apps/web/src/components/account-workspace.tsx @@ -0,0 +1,312 @@ +import { useState } from "react"; +import type { KeyboardEvent } from "react"; + +import { Check, KeyRound, Link2, Loader2, LogOut, RadioTower, UserRound, X } from "@/lib/icons"; + +import { AccountAvatar, initialsFromIdentity } from "@/components/account-avatar"; +import { PageHeader } from "@/components/amend-agent-chrome"; +import { DashboardWorkspaceSurface } from "@/components/dashboard-workspace-surface"; +import { + SettingsField, + SettingsInput, + SettingsRow, + SettingsSection, + StatePill, + settingsSecondaryButtonClass, +} from "@/components/settings-workspace-panel-primitives"; +import { SettingsAutoSaveIndicator } from "@/components/settings-workspace-toolbar"; +import { + type AccountWorkspaceController, + useAccountWorkspaceController, +} from "@/components/use-account-workspace-controller"; + +/** Filled primary action — matches the dashboard's foreground-on-background buttons. */ +const primaryButtonClass = + "inline-flex h-9 items-center gap-1.5 rounded-lg bg-foreground px-3.5 text-xs font-semibold text-background transition-colors duration-150 ease-linear hover:bg-foreground/85 active:opacity-75 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0"; + +/** Quiet destructive action — same footprint as the secondary button, danger-tinted. */ +const destructiveButtonClass = + "inline-flex h-9 items-center gap-1.5 rounded-lg bg-destructive/10 px-3 text-xs font-medium text-destructive ring-1 ring-destructive/20 ring-inset transition-colors duration-150 ease-linear hover:bg-destructive/15 active:opacity-75 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0"; + +/** Avatar control: clickable preview + an inline URL editor and remove action. */ +function AvatarField({ account }: { account: AccountWorkspaceController }) { + const { user } = account; + const initials = initialsFromIdentity(user?.name, user?.email); + const [urlDraft, setUrlDraft] = useState(null); + + function commit() { + if (urlDraft !== null) account.onSetImageUrl(urlDraft); + setUrlDraft(null); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } + if (event.key === "Escape") setUrlDraft(null); + } + + if (urlDraft !== null) { + return ( +
+ setUrlDraft(event.target.value)} + onKeyDown={onKeyDown} + /> + + +
+ ); + } + + return ( +
+ + + {account.imageBusy ? ( + + + + ) : null} + + + {user?.image ? ( + + ) : null} +
+ ); +} + +/** Collapsed "Change password" affordance that expands into a verified-change form. */ +function PasswordField({ account }: { account: AccountWorkspaceController }) { + const [open, setOpen] = useState(false); + + function close() { + setOpen(false); + account.onResetPasswordForm(); + } + + if (!open) { + return ( + + + + ); + } + + return ( + +
{ + event.preventDefault(); + void account.onChangePassword().then((ok) => { + if (ok) setOpen(false); + }); + }} + > + account.setCurrentPassword(event.target.value)} + /> + account.setNewPassword(event.target.value)} + /> + account.setConfirmPassword(event.target.value)} + /> + {account.passwordError ? ( +

{account.passwordError}

+ ) : null} +
+ + +
+

+ For your security, changing your password signs you out of other devices. +

+ +
+ ); +} + +/** + * Personal account settings — a dedicated surface separate from project + * settings, reusing the airy settings design system. Reached from the account + * menu in the sidebar. + */ +export function AccountWorkspace() { + const account = useAccountWorkspaceController(); + const { user } = account; + const email = user?.email ?? "—"; + const displayName = user?.name?.trim() || user?.email?.split("@")[0] || "Your account"; + const initials = initialsFromIdentity(user?.name, user?.email); + + return ( + <> + + + +
+ {/* Identity — grounds the page in the person, not the project. The + autosave cue rides here, mirroring the project settings surface. */} +
+
+ +
+

+ {displayName} +

+

+ {email} · Personal account +

+
+
+ +
+ +
+ + + + + + + account.setName(event.target.value)} + /> + + + + {email} + Read-only + + } + /> + + + + + + + {account.otherSessionsBusy ? ( + + ) : ( + + )} + Sign out other devices + + } + /> + + + + + + Sign out + + } + /> + +
+
+
+ + ); +} 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..baa83b4 --- /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, + actions, + filters, + className, +}: { + icon?: LucideIcon; + title: ReactNode; + actions?: ReactNode; + /** Per-view filter/sub-nav row, rendered as the header's second line directly + * above the content surface (keeps every page to header + surface). */ + filters?: ReactNode; + className?: string; +}) { + return ( +
+
+
+ {Icon ? ( + + + + ) : null} +

{title}

+
+ {actions ?
{actions}
: null} +
+ {filters} +
+ ); +} + +/** 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..392c67c --- /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 + ); + } + // disconnected | attention → connect (attention re-arms a record that exists). + return ( + + ); +} + +function IntegrationTile({ + item, + role, + status, + pending, + onConnect, + onDisconnect, +}: { + item: Integration; + role: Role; + status: TileStatus; + pending: boolean; + onConnect: () => void; + onDisconnect: () => void; +}) { + const connected = status === "connected"; + const sub = + status === "attention" ? "Needs reconnecting" : (item.blurbByRole?.[role] ?? item.blurb); + + return ( +
+
+ + {item.brand ? ( + + ) : item.glyph ? ( + + ) : null} + + +
+ +

{item.name}

+

+ {sub} +

+ + +
+ ); +} + +function RoleSection({ + role, + label, + description, + byProvider, + pendingProviders, + onConnect, + onDisconnect, +}: { + role: Role; + label: string; + description: string; + byProvider: Map; + pendingProviders: Set; + onConnect: (item: Integration) => void; + onDisconnect: (item: Integration) => void; +}) { + // Connected first, then attention/disconnected, planned last — alphabetical + // within a tier — so live sources lead each category. + const items = CATALOG.filter((item) => item.roles.includes(role)) + .map((item) => ({ item, status: tileStatusFor(item, byProvider) })) + .sort( + (a, b) => + STATUS_RANK[b.status] - STATUS_RANK[a.status] || a.item.name.localeCompare(b.item.name), + ); + const connectedCount = items.filter((entry) => entry.status === "connected").length; + + return ( +
+ 0 ? connectedCount : undefined}>{label} +

{description}

+
+ {items.map(({ item, status }) => ( + onConnect(item)} + onDisconnect={() => onDisconnect(item)} + /> + ))} +
+
+ ); +} + +function ConnectedStat({ count }: { count: number }) { + if (count <= 0) return null; + return ( +
+ + {count} + connected +
+ ); +} + +function ConnectionsToolbar({ + active, + onChange, +}: { + active: Role | "all"; + onChange: (role: Role | "all") => void; +}) { + return ( + + + onChange("all")}> + All + + {ROLES.map((role) => ( + onChange(role.key)} + > + {role.label} + + ))} + + + ); +} + +function ConnectionsSkeleton() { + return ( +
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + +
+ ))} +
+
+ ); +} + +/** Centered, padded column inside the workspace surface — the role grids get the + * same elevated panel + breathing room every other page renders into. */ +function Body({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function AmendConnectionsScreen({ workspaceId }: { workspaceId: string }) { + const isRealWorkspace = workspaceId !== fallbackWorkspace.id; + const queryArgs = isRealWorkspace ? { workspaceSlug: workspaceId } : {}; + const settings = useQuery(workspaceSettingsQuery, queryArgs) as WorkspaceSettingsData | undefined; + const upsertIntegration = useMutation(upsertIntegrationConnectionMutation); + + const [activeRole, setActiveRole] = useState("all"); + const [pendingProviders, setPendingProviders] = useState>(() => new Set()); + + const byProvider = useMemo(() => { + const map = new Map(); + for (const record of settings?.integrations ?? []) map.set(record.provider, record); + return map; + }, [settings]); + + const connectedCount = useMemo( + () => CATALOG.filter((item) => tileStatusFor(item, byProvider) === "connected").length, + [byProvider], + ); + + async function applyState(item: Integration, nextState: "connected" | "disabled") { + if (!item.provider) return; + const provider = item.provider; + setPendingProviders((prev) => new Set(prev).add(provider)); + try { + await upsertIntegration({ + ...(isRealWorkspace ? { workspaceSlug: workspaceId } : {}), + provider, + direction: directionFor(item.roles), + displayName: item.name, + state: nextState, + }); + if (nextState === "connected") { + toast.success({ + title: `${item.name} connected`, + description: "Amend will start using this source.", + }); + } else { + toast.success({ + title: `${item.name} disconnected`, + description: "Amend will stop using this source.", + button: { title: "Undo", onClick: () => void applyState(item, "connected") }, + }); + } + } catch (error) { + toast.error(errorMessage(error, `Couldn't update ${item.name}. Please try again.`)); + } finally { + setPendingProviders((prev) => { + const next = new Set(prev); + next.delete(provider); + return next; + }); + } + } + + const visibleRoles = ROLES.filter((role) => activeRole === "all" || activeRole === role.key); + + return ( + <> + } + filters={} + /> + + + {settings === undefined ? ( + + + + ) : ( + + {visibleRoles.map((role) => ( + void applyState(item, "connected")} + onDisconnect={(item) => void applyState(item, "disabled")} + /> + ))} + + )} + + + ); +} diff --git a/apps/web/src/components/amend-dashboard-constants.ts b/apps/web/src/components/amend-dashboard-constants.ts index 1144d8f..a22df12 100644 --- a/apps/web/src/components/amend-dashboard-constants.ts +++ b/apps/web/src/components/amend-dashboard-constants.ts @@ -6,7 +6,17 @@ import type { Workspace, } from "@/components/amend-dashboard-types"; -export const viewValues: DashboardView[] = ["posts", "roadmap", "changelog", "settings", "setup"]; +export const viewValues: DashboardView[] = [ + "inbox", + "posts", + "roadmap", + "changelog", + "memory", + "connections", + "settings", + "account", + "setup", +]; export const boardValues: BoardId[] = ["feature", "bug", "changelog", "feedback"]; export const statusValues: Array = [ "all", diff --git a/apps/web/src/components/amend-dashboard-content-record-types.ts b/apps/web/src/components/amend-dashboard-content-record-types.ts index cfa7047..120f0e3 100644 --- a/apps/web/src/components/amend-dashboard-content-record-types.ts +++ b/apps/web/src/components/amend-dashboard-content-record-types.ts @@ -21,6 +21,7 @@ export type Post = { stableKey: string; updatedAt: number; voters: number; + hasVoted: boolean; date: string; }; @@ -38,6 +39,7 @@ export type DashboardFeedback = { title: string; updatedAt: number; votes: number; + viewerHasVoted?: boolean; }; export type DashboardRoadmap = { @@ -53,6 +55,7 @@ export type DashboardRoadmap = { target?: string; title: string; updatedAt: number; + viewerHasVoted?: boolean; }; export type RoadmapView = { @@ -66,6 +69,9 @@ export type DashboardChangelog = { authorName: string; body: string; category: string; + coverImageStorageId?: string | null; + coverImageUrl?: string | null; + metaDescription?: string | null; publishedAt?: number; recordId: string | null; scheduledFor?: number; diff --git a/apps/web/src/components/amend-dashboard-content-types.ts b/apps/web/src/components/amend-dashboard-content-types.ts index 140425e..3e8ff0c 100644 --- a/apps/web/src/components/amend-dashboard-content-types.ts +++ b/apps/web/src/components/amend-dashboard-content-types.ts @@ -17,6 +17,8 @@ import type { export type ChangelogSavePayload = { body: string; category: string; + coverImageStorageId?: string | null; + metaDescription?: string; stableKey?: string; status: string; summary: string; @@ -25,6 +27,13 @@ export type ChangelogSavePayload = { version?: string; }; +/** Content payload plus the publish intent, sent when committing from the review surface. */ +export type ChangelogPublishPayload = ChangelogSavePayload & { + mode: "now" | "schedule"; + scheduledFor?: number; + notifySubscribers?: boolean; +}; + export type DashboardContentProps = { activeBoard: Board; activeChangelogCategory: string; @@ -44,15 +53,19 @@ export type DashboardContentProps = { searchQuery: string; requiresProjectSetup: boolean; selectedChangelog: DashboardChangelog | null; + selectedChangelogKey: string | null; selectedFeedback: Post | null; selectedRoadmap: DashboardRoadmap | null; workspace: Workspace; onAddFeedbackNote: (note: string) => Promise; + onAddRoadmapNote: (item: DashboardRoadmap, note: string) => Promise; onAddRoadmap: (status: RoadmapStatus) => void; onBackFromChangelog: () => void; onBackFromFeedback: () => void; onBackFromRoadmap: () => void; + onChangelogAutoSave: (payload: ChangelogSavePayload) => Promise; onChangelogCategoryChange: (category: string) => void; + onChangelogPublish: (payload: ChangelogPublishPayload) => Promise; onChangelogSave: (payload: ChangelogSavePayload) => Promise; onChangelogStatusChange: (status: ChangelogStatusFilter) => void; onCreate: () => void; @@ -69,6 +82,7 @@ export type DashboardContentProps = { onSearchChange: (query: string) => void; onStatusChange: (status: RoadmapStatus | "all") => void; roadmapViews: RoadmapView[]; + onVoteFeedbackPost: (post: Post) => Promise; onVoteRoadmapItem: (item: DashboardRoadmap) => Promise; onVoteSelectedRoadmap: (item: DashboardRoadmap) => Promise; }; diff --git a/apps/web/src/components/amend-dashboard-content.tsx b/apps/web/src/components/amend-dashboard-content.tsx index 3e1f807..e59a237 100644 --- a/apps/web/src/components/amend-dashboard-content.tsx +++ b/apps/web/src/components/amend-dashboard-content.tsx @@ -1,13 +1,58 @@ import { getDashboardDetailView } from "@/components/amend-dashboard-detail-router"; import { AmendDashboardMainWorkspace } from "@/components/amend-dashboard-main-workspace"; import type { DashboardContentProps } from "@/components/amend-dashboard-content-types"; +import { DashboardOnboardingLauncher } from "@/components/dashboard-onboarding-launcher"; +import { + computeOnboardingState, + type OnboardingStepAction, +} from "@/components/dashboard-onboarding-model"; export function AmendDashboardContent(props: DashboardContentProps) { const detailView = getDashboardDetailView(props); - if (detailView) { + // The changelog editor is a full-screen editing mode with its own chrome. + if (detailView && props.activeView === "changelog") { return
{detailView}
; } - return ; + // Post-setup activation lives in a floating launcher (see component) rather + // than an inline banner, so it never pushes the board views around. + const onboarding = computeOnboardingState({ + activeProject: props.activeProject, + dashboard: props.dashboard, + workspace: props.workspace, + }); + const showLauncher = + Boolean(props.dashboard) && props.activeProject.id !== "new-project" && !detailView; + + const handleStepAction = (action: OnboardingStepAction) => { + switch (action.kind) { + case "setup": + props.onOpenSetup(); + break; + case "compose": + if (props.activeView === "changelog") props.onNewChangelog(); + else props.onCreate(); + break; + case "settings": + props.onOpenSettingsSection(action.section); + break; + case "none": + break; + } + }; + + return ( + <> + + {showLauncher ? ( + + ) : null} + + ); } diff --git a/apps/web/src/components/amend-dashboard-core-types.ts b/apps/web/src/components/amend-dashboard-core-types.ts index 653caf4..72af2f6 100644 --- a/apps/web/src/components/amend-dashboard-core-types.ts +++ b/apps/web/src/components/amend-dashboard-core-types.ts @@ -2,8 +2,23 @@ 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 SettingsSection = "accounts" | "automation" | "general" | "portal" | "services"; +export type DashboardView = + | "inbox" + | "posts" + | "roadmap" + | "changelog" + | "memory" + | "connections" + | "settings" + | "account" + | "setup"; +export type SettingsSection = + | "accounts" + | "automation" + | "general" + | "portal" + | "services" + | "tags"; export type BoardId = "feature" | "bug" | "changelog" | "feedback"; export type WorkspaceId = string; export type RoadmapViewId = string; diff --git a/apps/web/src/components/amend-dashboard-data.tsx b/apps/web/src/components/amend-dashboard-data.tsx index 85f2ef7..b70051c 100644 --- a/apps/web/src/components/amend-dashboard-data.tsx +++ b/apps/web/src/components/amend-dashboard-data.tsx @@ -12,10 +12,26 @@ export const recordFeedbackInteractionMutation = makeFunctionReference<"mutation export const upsertChangelogEntryMutation = makeFunctionReference<"mutation">( "amend:upsertChangelogEntry", ); +export const publishChangelogEntryMutation = makeFunctionReference<"mutation">( + "amend:publishChangelogEntry", +); +export const generateChangelogCoverUploadUrlMutation = makeFunctionReference<"mutation">( + "amend:generateChangelogCoverUploadUrl", +); export const upsertRoadmapItemMutation = makeFunctionReference<"mutation">("amend:upsertRoadmapItem"); export const voteRoadmapItemMutation = makeFunctionReference<"mutation">("amend:voteRoadmapItem"); +export const workspaceSettingsQuery = makeFunctionReference<"query">("amend:getWorkspaceSettings"); +export const upsertIntegrationConnectionMutation = makeFunctionReference<"mutation">( + "amend:upsertIntegrationConnection", +); + +export const listWorkspaceTagsQuery = makeFunctionReference<"query">("tags:list"); +export const createWorkspaceTagMutation = makeFunctionReference<"mutation">("tags:create"); +export const updateWorkspaceTagMutation = makeFunctionReference<"mutation">("tags:update"); +export const removeWorkspaceTagMutation = makeFunctionReference<"mutation">("tags:remove"); + export const feedbackBoard: Board = { id: "feedback", name: "Feedback", diff --git a/apps/web/src/components/amend-dashboard-detail-router.tsx b/apps/web/src/components/amend-dashboard-detail-router.tsx index c77dc23..716f287 100644 --- a/apps/web/src/components/amend-dashboard-detail-router.tsx +++ b/apps/web/src/components/amend-dashboard-detail-router.tsx @@ -1,30 +1,55 @@ import type { ReactNode } from "react"; +import { fallbackWorkspace } from "@/components/amend-dashboard-constants"; import { ChangelogEditorWorkspace, FeedbackDetailWorkspace, RoadmapDetailWorkspace, } from "@/components/amend-dashboard-workspaces"; import type { DashboardContentProps } from "@/components/amend-dashboard-content-types"; +import { roadmapSourceFeedbackKey } from "@/components/amend-dashboard-utils"; export function getDashboardDetailView({ activeView, + feedbackPosts, selectedChangelog, selectedFeedback, + selectedChangelogKey, selectedRoadmap, onAddFeedbackNote, + onAddRoadmapNote, onBackFromChangelog, - onBackFromFeedback, - onBackFromRoadmap, - onChangelogSave, + onChangelogAutoSave, + onChangelogPublish, onOpenFeedbackKey, + onVoteFeedbackPost, onVoteSelectedRoadmap, + workspace, }: DashboardContentProps): ReactNode { if (selectedRoadmap && (activeView === "posts" || activeView === "roadmap")) { + // A roadmap item synced from feedback is the same entity as its feedback post, + // so render the identical feedback detail — /roadmap?item=… then matches + // /posts?feedback=… exactly (status, tags, comments, source evidence, vote all + // resolve against the shared feedback record). This closes the direct-link gap + // that openRoadmapItem already covers for in-app navigation. Genuine roadmap + // items (no backing feedback) keep the roadmap detail. + const sourcePost = feedbackPosts.find( + (post) => + !post.sourceRoadmapKey && + post.stableKey === roadmapSourceFeedbackKey(selectedRoadmap), + ); + if (sourcePost) { + return ( + onAddRoadmapNote(selectedRoadmap, note)} + onVote={onVoteFeedbackPost} + /> + ); + } return ( @@ -35,8 +60,8 @@ export function getDashboardDetailView({ return ( ); } @@ -44,9 +69,12 @@ export function getDashboardDetailView({ if (selectedChangelog && activeView === "changelog") { return ( ); } diff --git a/apps/web/src/components/amend-dashboard-main-workspace.tsx b/apps/web/src/components/amend-dashboard-main-workspace.tsx index 4760822..77944c5 100644 --- a/apps/web/src/components/amend-dashboard-main-workspace.tsx +++ b/apps/web/src/components/amend-dashboard-main-workspace.tsx @@ -1,29 +1,34 @@ -import type { - DashboardChangelog, - DashboardRoadmap, - DashboardView, - Post, -} from "@/components/amend-dashboard-types"; +import { type ReactNode, useState } from "react"; + +import { ArrowLeft, Map, MessageSquareText } from "@/lib/icons"; + +import { AccountWorkspace } from "@/components/account-workspace"; +import { AmendConnectionsScreen } from "@/components/amend-connections-screen"; +import { AmendInboxScreen } from "@/components/amend-inbox-screen"; +import { AmendMemoryScreen } from "@/components/amend-memory-screen"; import { ChangelogWorkspace, PostsWorkspace, RoadmapWorkspace, } from "@/components/amend-dashboard-workspaces"; +import { PageHeader } from "@/components/amend-agent-chrome"; import type { DashboardContentProps } from "@/components/amend-dashboard-content-types"; -import { changelogCategoryFilters } from "@/components/amend-dashboard-utils"; +import { + DEFAULT_SORT, + SORT_OPTIONS, + type SortableView, + asSortableView, + sortChangelog, + sortPosts, + sortRoadmap, +} from "@/components/dashboard-sort"; import { ChangelogToolbar } from "@/components/changelog-toolbar"; import { DashboardHeader } from "@/components/dashboard-navigation"; import { FeedbackToolbar } from "@/components/feedback-toolbar"; import { RoadmapToolbar } from "@/components/roadmap-toolbar"; -import { DashboardOnboardingChecklist } from "@/components/dashboard-onboarding-checklist"; -import { - computeOnboardingState, - type OnboardingStepAction, -} from "@/components/dashboard-onboarding-model"; +import { SettingsSectionNav } from "@/components/settings-workspace-toolbar"; import { SettingsWorkspace } from "@/components/settings-workspace"; -const ONBOARDING_VIEWS = new Set(["posts", "roadmap", "changelog"]); - export function AmendDashboardMainWorkspace({ activeBoard, activeChangelogCategory, @@ -34,7 +39,6 @@ export function AmendDashboardMainWorkspace({ activeStatus, activeView, changelogEntries, - dashboard, feedbackPosts, scopedChangelogEntries, scopedPosts, @@ -51,146 +55,145 @@ export function AmendDashboardMainWorkspace({ onOpenFeedback, onOpenRoadmapItem, onOpenSettingsSection, - onOpenSetup, onRoadmapChange, onSearchChange, onStatusChange, + onVoteFeedbackPost, onVoteRoadmapItem, roadmapViews, -}: DashboardContentProps) { - const onboarding = computeOnboardingState({ activeProject, dashboard, workspace }); - const showOnboarding = - Boolean(dashboard) && activeProject.id !== "new-project" && ONBOARDING_VIEWS.has(activeView); + selectedRoadmap, + onBackFromFeedback, + onBackFromRoadmap, + detailView, +}: DashboardContentProps & { detailView?: ReactNode }) { + // Sort is per-view local UI state (kept out of the URL to keep links short). + // Declared before the early returns so the hook runs on every render. + const [sortByView, setSortByView] = useState>(DEFAULT_SORT); - const handleStepAction = (action: OnboardingStepAction) => { - switch (action.kind) { - case "setup": - onOpenSetup(); - break; - case "compose": - if (activeView === "changelog") onNewChangelog(); - else onCreate(); - break; - case "settings": - onOpenSettingsSection(action.section); - break; - case "none": - break; - } + // 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 === "inbox") return ; + if (activeView === "memory") return ; + if (activeView === "connections") return ; + if (activeView === "account") return ; + + const sortableView = asSortableView(activeView); + const sortOptions = sortableView ? SORT_OPTIONS[sortableView] : []; + const activeSort = sortableView ? sortByView[sortableView] : ""; + const handleSortChange = (value: string) => { + if (sortableView) setSortByView((prev) => ({ ...prev, [sortableView]: value })); }; - return ( - <> - {showOnboarding ? ( - - ) : null} + // Sort the already-filtered lists, so search + sort compose. Roadmap is a board, + // so the chosen order applies within each status column. + const sortedPosts = sortPosts(scopedPosts, sortByView.posts); + const sortedRoadmapEntries = sortRoadmap(scopedRoadmapEntries, sortByView.roadmap); + const sortedChangelogEntries = sortChangelog(scopedChangelogEntries, sortByView.changelog); - + ) : activeView === "roadmap" ? ( + + ) : activeView === "changelog" ? ( + + ) : activeView === "settings" ? ( + + ) : undefined; - {activeView === "posts" ? ( - <> - - - - ) : null} - {activeView === "roadmap" ? ( - <> - - - - ) : null} + return ( + <> + {detailView ? ( + // Reading a single item: drop the list's filter tabs / search / sort (they + // act on a list that isn't shown) for a calm breadcrumb back to the section. + + + {activeView === "roadmap" ? "Roadmap" : "Feedback"} + + } + /> + ) : ( + + )} + + {activeView === "posts" + ? (detailView ?? ( + + )) + : null} + {activeView === "roadmap" + ? (detailView ?? ( + + )) + : null} {activeView === "changelog" ? ( - <> - - - + ) : null} {activeView === "settings" ? ( ) : null} ); } - -function getHeaderItemCount({ - activeView, - scopedChangelogEntries, - scopedPosts, - scopedRoadmapEntries, -}: { - activeView: DashboardView; - scopedChangelogEntries: DashboardChangelog[]; - scopedPosts: Post[]; - scopedRoadmapEntries: DashboardRoadmap[]; -}) { - if (activeView === "posts") return scopedPosts.length; - if (activeView === "roadmap") return scopedRoadmapEntries.length; - if (activeView === "changelog") return scopedChangelogEntries.length; - return undefined; -} diff --git a/apps/web/src/components/amend-dashboard-shared.tsx b/apps/web/src/components/amend-dashboard-shared.tsx index a16736e..4889fb0 100644 --- a/apps/web/src/components/amend-dashboard-shared.tsx +++ b/apps/web/src/components/amend-dashboard-shared.tsx @@ -1,43 +1,5 @@ import { Button } from "@amend/ui/components/button"; -import type { ReactElement, ReactNode } from "react"; - -import { cn } from "@amend/ui/lib/utils"; - -export function SettingsPanel({ - action, - children, - icon, - title, -}: { - action?: ReactNode; - children: ReactNode; - icon: ReactElement; - title: string; -}) { - return ( -
-
-
- - {icon} - -

{title}

-
- {action} -
-
{children}
-
- ); -} - -export function StatusRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} +import type { ReactElement } from "react"; export function EmptyModule({ action, @@ -72,21 +34,3 @@ export function EmptyModule({ ); } - -export function BooleanRow({ checked, label }: { checked: boolean; label: string }) { - return ( -
- {label} - - {checked ? "Y" : "N"} - -
- ); -} diff --git a/apps/web/src/components/amend-dashboard-status-utils.ts b/apps/web/src/components/amend-dashboard-status-utils.ts index f31f514..4cb3fa2 100644 --- a/apps/web/src/components/amend-dashboard-status-utils.ts +++ b/apps/web/src/components/amend-dashboard-status-utils.ts @@ -80,7 +80,9 @@ 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"; + // The agent's Board + Drafts folded into the Inbox — keep old deep links alive. + if (value === "board" || value === "drafts") return "inbox"; return viewValues.includes(value as DashboardView) ? (value as DashboardView) : "posts"; } @@ -115,10 +117,14 @@ export function statusTitle(status: RoadmapStatus | "all") { export function viewTitle(view: DashboardView) { const titles: Record = { + inbox: "Inbox", posts: "Feedback", roadmap: "Roadmap", changelog: "Changelog", + memory: "Memory", + connections: "Connections", settings: "Settings", + account: "Account", setup: "Create project", }; return titles[view]; diff --git a/apps/web/src/components/amend-dashboard-status.tsx b/apps/web/src/components/amend-dashboard-status.tsx index 30fcf53..6c17585 100644 --- a/apps/web/src/components/amend-dashboard-status.tsx +++ b/apps/web/src/components/amend-dashboard-status.tsx @@ -1,4 +1,4 @@ -import { CalendarClock, Check, Circle, Radio } from "@/lib/icons"; +import { Activity, CalendarClock, CircleCheckBig, CircleDashed } from "@/lib/icons"; import type { ReactElement } from "react"; import type { RoadmapStatus } from "@/components/amend-dashboard-types"; @@ -10,7 +10,7 @@ export const statusMeta: Record< backlog: { label: "Under Review", short: "Review", - icon: , + icon: , dot: "bg-muted-foreground", }, next: { @@ -22,13 +22,13 @@ export const statusMeta: Record< progress: { label: "In Progress", short: "Progress", - icon: , + icon: , dot: "bg-foreground", }, done: { label: "Done", short: "Done", - icon: , + icon: , dot: "bg-foreground", }, }; diff --git a/apps/web/src/components/amend-dashboard-sync-mappers.ts b/apps/web/src/components/amend-dashboard-sync-mappers.ts index 4d59962..08188d4 100644 --- a/apps/web/src/components/amend-dashboard-sync-mappers.ts +++ b/apps/web/src/components/amend-dashboard-sync-mappers.ts @@ -44,6 +44,7 @@ export function roadmapItemToPost(item: DashboardRoadmap): Post { title: item.title, updatedAt: item.updatedAt, voters: item.feedbackCount, + hasVoted: item.viewerHasVoted ?? false, }; } @@ -80,6 +81,7 @@ export function feedbackPostToRoadmapItem(post: Post): DashboardRoadmap { status: roadmapStatusToPortalStatus(post.status), title: post.title, updatedAt: post.updatedAt, + viewerHasVoted: post.hasVoted, }; } @@ -100,6 +102,19 @@ export function sourceFeedbackKey(item: DashboardRoadmap) { return source?.externalId?.replace(/^feedback:/, "") ?? ""; } +/** + * The feedback post a synced roadmap item was minted from. Prefers the explicit + * `feedback:` source link, but falls back to the `roadmap-feedback-` stableKey: + * a roadmap item persisted by a board move (see {@link persistedRoadmapKey}) drops + * its source links, yet the key still encodes the originating feedback post. + */ +export function roadmapSourceFeedbackKey(item: DashboardRoadmap): string { + const fromLinks = sourceFeedbackKey(item); + if (fromLinks) return fromLinks; + const prefix = "roadmap-feedback-"; + return item.stableKey.startsWith(prefix) ? item.stableKey.slice(prefix.length) : ""; +} + export function feedbackToPost(item: DashboardFeedback): Post { return { authorName: item.authorName, @@ -117,5 +132,6 @@ export function feedbackToPost(item: DashboardFeedback): Post { title: item.title, updatedAt: item.updatedAt, voters: item.votes, + hasVoted: item.viewerHasVoted ?? false, }; } diff --git a/apps/web/src/components/amend-dashboard.tsx b/apps/web/src/components/amend-dashboard.tsx index 8bf521e..b6bb12d 100644 --- a/apps/web/src/components/amend-dashboard.tsx +++ b/apps/web/src/components/amend-dashboard.tsx @@ -28,7 +28,7 @@ export default function AmendDashboard() { } return ( -
+
{children}
; +} + +function TabEmpty({ children }: { children: ReactNode }) { + return

{children}

; +} + +// --------------------------------------------------------------------------- +// Row primitives — plain icon, status chip, stat line, low-chrome actions. +// --------------------------------------------------------------------------- + +const glyphColor = { + muted: "text-muted-foreground/70", + success: "text-amend-success", + warm: "text-amend-warm", +} as const; + +function RowGlyph({ + icon: Icon, + tone, + className, +}: { + icon: LucideIcon; + tone: keyof typeof glyphColor; + className?: string; +}) { + return ; +} + +function StatusChip({ + children, + tone = "muted", +}: { + children: ReactNode; + tone?: "muted" | "success" | "warm"; +}) { + return ( + + {children} + + ); +} + +function ReadyChip() { + return ( + + + + + + Ready + + ); +} + +function ProofStats({ proof, className }: { proof: Proof; className?: string }) { + return ( +
+ + {proof.people} people + + {proof.payingPeople > 0 ? ( + + {proof.payingPeople} paying + + ) : null} + + {proof.sources.length}{" "} + {proof.sources.length === 1 ? "source" : "sources"} + +
+ ); +} + +// Low-chrome action buttons — soft-filled primary, quiet secondary. No borders. +function PrimaryAction({ onClick, children }: { onClick: () => void; children: ReactNode }) { + return ( + + ); +} + +function QuietAction({ + onClick, + children, + danger = false, + className, +}: { + onClick: () => void; + children: ReactNode; + danger?: boolean; + className?: string; +}) { + return ( + + ); +} + +const rowGrid = "grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-5 py-3.5 md:px-6"; + +// --------------------------------------------------------------------------- +// Draft row — held changelog entry / message, open in place with the full text. +// --------------------------------------------------------------------------- + +function RecipientList({ draft }: { draft: DraftProposal }) { + if (!draft.recipients?.length) return null; + return ( +
+ + Reaches {draft.recipients.length} {draft.recipients.length === 1 ? "person" : "people"}: + + {draft.recipients.map((r) => ( + + + {r.handle} + + + ))} +
+ ); +} + +function DraftRow({ draft }: { draft: DraftProposal }) { + const [editing, setEditing] = useState(false); + const [text, setText] = useState(draft.draftText); + const isNotify = draft.kind === "notify"; + + function approve(value?: string) { + if (value !== undefined) updateDraftText(draft.id, value); + approveDraft(draft.id); + toast.success({ + title: "Approved", + description: isNotify + ? "Your message will go to the people who asked." + : "It'll be added to your changelog.", + }); + } + function reject() { + rejectDraft(draft.id); + toast.info({ title: "Draft rejected", description: "The agent won't send this one." }); + } + + return ( +
+
+ +
+
+ {draft.needTitle} + Held +
+ + {isNotify ? : null} + + {editing ? ( +