diff --git a/src/app/(app)/issues/page.tsx b/src/app/(app)/issues/page.tsx index 81819a4..2651b38 100644 --- a/src/app/(app)/issues/page.tsx +++ b/src/app/(app)/issues/page.tsx @@ -31,6 +31,7 @@ import { } from "@/components/issue-empty"; import { SelectMenu } from "@/components/select-menu"; import { WatchQueue } from "@/components/watch-queue"; +import { FixQueue } from "@/components/fix-queue"; import { WATCH_EMPTY } from "@/lib/watch-copy"; /** @@ -155,11 +156,21 @@ export default function IssuesPage() { return ; } - // Watch is not grouped by remediation and not sorted by impact: it is a run - // of fixes waiting on evidence, ordered by what is heard from next. It reads - // the queue's cases directly rather than the folded groups. + /** + * Two of the four queues draw themselves, and both for the same reason. + * + * Decide and Show all are lists of undecided things, so they are grouped by + * remediation, folded at the savings gate and sorted however the reader asks — + * every one of those is a way of deciding. Fix and Watch hold things that have + * already been decided, and neither question left has a sort control as its + * answer: Fix asks which to do next, Watch asks what is heard from when. Both + * read the queue's cases directly rather than the folded groups, because a + * fold is a triage affordance and there is no triage left to do. + */ + const isFix = queue === "fix"; const isWatch = queue === "watch"; - const hasRows = isWatch ? view.inQueue.length > 0 : view.groups.length > 0 || view.tail.length > 0; + const ownQueue = isFix || isWatch; + const hasRows = ownQueue ? view.inQueue.length > 0 : view.groups.length > 0 || view.tail.length > 0; return (
@@ -167,9 +178,11 @@ export default function IssuesPage() { linkTo({ queue: next })} /> + {hasRows && isFix ? : null} + {hasRows && isWatch ? : null} - {hasRows && !isWatch ? ( + {hasRows && !ownQueue ? ( <>
- ) : topRibbonRec ? ( - <> - {topRibbonEvidence && } - - + ) : topRibbonRec && topRibbonEvidence ? ( + ) : null} {`Open ${QUEUE_LABEL.decide}`} diff --git a/src/app/(app)/tasks/page.tsx b/src/app/(app)/tasks/page.tsx deleted file mode 100644 index e761bcc..0000000 --- a/src/app/(app)/tasks/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { redirect } from "next/navigation"; -import { getEnv } from "@/lib/env"; -import { normalizeBasePath, withBasePath } from "@/lib/paths"; -import { DESTINATION_PATH } from "@/lib/vocabulary"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Retired destination. What lived here is now the Fix queue on the issues - * list; this route only exists to keep old links from 404ing. - */ -export default function RedirectToFixQueue() { - redirect(withBasePath(normalizeBasePath(getEnv("BASE_URL")), `${DESTINATION_PATH.issues}?queue=fix`)); -} diff --git a/src/app/api/agent-audits/verify/route.ts b/src/app/api/agent-audits/verify/route.ts deleted file mode 100644 index fc55ce7..0000000 --- a/src/app/api/agent-audits/verify/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { relayAgentAuditResponse, requestAgentAuditVerify } from "@/lib/agentAuditServer"; -import { authorizedProjectForRequest } from "@/lib/projects"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * Re-run the provider checks recorded on one implemented agent task. - * - * Only the task key travels. The collector resolves which checks to run from - * stored state, so an arbitrary check set can never be submitted. - */ -export async function POST(request: Request): Promise { - const body = (await request.json().catch(() => ({}))) as { recKey?: unknown }; - if (typeof body.recKey !== "string" || !body.recKey) { - return Response.json({ error: "recKey is required" }, { status: 400 }); - } - try { - const project = await authorizedProjectForRequest(request, "admin"); - return relayAgentAuditResponse(await requestAgentAuditVerify(project.tenant, body.recKey)); - } catch (error) { - return Response.json( - { error: error instanceof Error ? error.message : "Verification is unavailable" }, - { status: 503 }, - ); - } -} diff --git a/src/components/case-detail.tsx b/src/components/case-detail.tsx index 43e9c4c..a5a9f73 100644 --- a/src/components/case-detail.tsx +++ b/src/components/case-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { attributionOf } from "@/lib/caller"; import { RESOLVING_INTERVAL, @@ -10,8 +10,10 @@ import { primaryActionFor, type IssueCase, } from "@/lib/issue-case"; -import { runOf } from "@/lib/checkpoint-evaluation"; -import { formatImpact } from "@/lib/impact-format"; +import { fixedAtOf, runOf } from "@/lib/checkpoint-evaluation"; +import { formatCaseImpact } from "@/lib/impact-format"; +import { ticketMarkdown } from "@/lib/fix-ticket"; +import { FIX_NOTES_PLACEHOLDER } from "@/lib/fix-copy"; import { CONFIDENCE_LABEL, DESTINATION_LABEL, @@ -19,6 +21,7 @@ import { EVIDENCE_SOURCE_LABEL, ISSUE_ACTION_LABEL, WORK_STATE_LABEL, + queueHoldsState, type ExclusionReason, type IssueAction, } from "@/lib/vocabulary"; @@ -41,6 +44,7 @@ import { ObjectDetailHeader } from "@/components/object-detail-header"; import { StatusChip } from "@/components/status-chip"; import { CasePages } from "@/components/case-pages"; import { CheckpointTrack } from "@/components/checkpoint-track"; +import { CopyTicketButton } from "@/components/copy-ticket-button"; import { daysOf, formatDate } from "@/lib/watch-copy"; /** @@ -52,9 +56,10 @@ import { daysOf, formatDate } from "@/lib/watch-copy"; * 2 impact/effort what it costs and what it takes — the decision inputs * 3 taxonomy category, severity, confidence * 4 affected pages which pages, and which of them count - * 5 evidence who measured it, never averaged - * 6 checkpoints only once there is something to check - * 7 history what happened, oldest last + * 5 notes free text, for whoever picks this up + * 6 evidence who measured it, never averaged + * 7 checkpoints only once there is something to check + * 8 history what happened, oldest last * * Taxonomy never comes first. A row of chips above the diagnosis asks the * reader to classify a problem they have not been told about yet — the @@ -78,6 +83,22 @@ export interface CaseDetailProps { onAction?: (action: IssueAction, issue: IssueCase) => void; onExclude?: (pageId: string, reason: ExclusionReason) => void; onInclude?: (pageId: string) => void; + /** + * Persist the case's notes. + * + * Withheld the same way `onExclude` is, and for the same reason: a notes box + * that accepts what you type, shows it, and loses it on reload is worse than + * no notes box. The section renders read-only when there is a note and no + * handler, and not at all when there is neither. + */ + onNotesChange?: (notes: string) => void; + /** + * The deployment's public URL, for the link in a copied ticket. + * + * Absent yields the root-relative `/issues/{id}`, which is wrong in a ticket + * visibly rather than silently. See `absoluteUrl`. + */ + appUrl?: string; now?: Date; locale?: string; } @@ -93,6 +114,96 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } +/** + * Free text, for whoever picks this up. + * + * No schema, no required fields, no length limit and nothing derived from what + * is written here. That is the point of it: the other eight sections of a case + * are shapes the app imposed on a finding, and every one of them is occasionally + * the wrong shape for the thing somebody needs to say. This is where that goes. + * + * Three states, and the middle one is the one worth explaining. With a handler + * it is editable. With a note and no handler it is shown but not editable — a + * box that takes what you type and loses it on reload is worse than a box that + * does not take it, and hiding a note that exists because nothing can save a new + * one would be hiding evidence. With neither it is not rendered at all. + * + * Committed on blur rather than on every keystroke. A case is derived from + * stored records, so a write is a round trip; per-keystroke saving would put one + * in flight per character and reorder them under any latency at all. + */ +function CaseNotes({ + notes, + onNotesChange, +}: { + notes?: string; + onNotesChange?: (notes: string) => void; +}) { + const [draft, setDraft] = useState(notes ?? ""); + const [seen, setSeen] = useState(notes); + + // A case re-derived by a later run brings its own note, and the draft follows + // it so an edit made elsewhere is not overwritten by a stale buffer. + // + // Adjusted during render rather than in an effect. An effect would paint the + // old note first and correct it on the next pass, which is a visible flash of + // the wrong text; React re-runs this component before committing anything, so + // the reader only ever sees the new one. + if (notes !== seen) { + setSeen(notes); + setDraft(notes ?? ""); + } + + if (!onNotesChange) { + if (!notes) return null; + return ( +
+

+ {notes} +

+
+ ); + } + + return ( +
+