From ab59e64698cd13237b26683b866b587156d33e7c Mon Sep 17 00:00:00 2001 From: Shruti Rajagopal Nejad Date: Thu, 22 Oct 2026 21:42:22 -0700 Subject: [PATCH] LIM-2424 v6: a declined proposal is visible to the asking team MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sean's #7 integration-pass follow-up (Medium): ProposalsView tells the owner " sees that you declined", but nothing showed it. DecisionsView filtered out REJECTED and Your turn skipped it, so after a Decline the asking team only got a fresh "Propose a change" card. - Decisions lists declined proposals: a "Declined" pill, "Declined by · ", the time of the reject audit event, and a Declined filter shown once there is one. A row opens the proposal. - Your turn: when the latest proposal was declined, the asking team's "Propose a change" card says " declined your last proposal." Test: test-ui-loop U14 (fresh fixture → propose → owner declines → the asking team's Your turn and Decisions both name the decline). It fails with the component change removed and passes with it. U10 now reads the agent's wake-ups before U14 replaces the scenario, which clears them. Gate: scripts/test-all.sh 3247, all 15 steps pass (ui-loop 35/35, ui-northstar 125 files unchanged against the v6 baseline). --- components/DecisionsView.tsx | 28 +++++++++++++++++++++++++--- components/YourTurnView.tsx | 5 ++++- scripts/test-ui-loop.mjs | 29 ++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/components/DecisionsView.tsx b/components/DecisionsView.tsx index 1ad8c5bb..8620dd01 100644 --- a/components/DecisionsView.tsx +++ b/components/DecisionsView.tsx @@ -6,11 +6,11 @@ import { agreementStatus, recordFacts } from "./launch"; import { STATE_LABEL, fmtTime } from "./ui"; import { Pill, type PillTone } from "./v6/record"; -type Filter = "all" | "open" | "accepted"; +type Filter = "all" | "open" | "accepted" | "declined"; interface Row { key: string; - kind: "open" | "accepted"; + kind: "open" | "accepted" | "declined"; pill: string; tone: PillTone; title: string; @@ -65,7 +65,28 @@ export function DecisionsView(props: ViewProps) { }; }); - const rows = [...openRows, ...accRows] + // A declined proposal stays visible, so "the asking team sees that you declined" (ProposalsView) is true. + const declinedRows: Row[] = s.proposals + .filter((p) => p.state === "REJECTED") + .map((p) => { + const latest = p.counter_versions.at(-1); + const at = [...(s.audit ?? [])].reverse().find((e) => e.action === "reject" && e.target === p.id)?.at ?? latest?.at ?? p.created_at; + return { + key: `r-${p.id}`, + kind: "declined", + pill: "Declined", + tone: "grey", + title: `Change ${poss(owning)} goal`, + sub: `Declined by ${L.person.security} · ${latest?.proposed_measure_text ?? p.proposed_measure_text}`, + at, + open: () => { + props.select({ kind: "proposal", id: p.id }); + props.go("proposals"); + }, + }; + }); + + const rows = [...openRows, ...accRows, ...declinedRows] .filter((r) => filter === "all" || r.kind === filter) .sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); @@ -88,6 +109,7 @@ export function DecisionsView(props: ViewProps) { {seg("all", "All")} {seg("open", "Open")} {seg("accepted", "Accepted")} + {declinedRows.length > 0 && seg("declined", "Declined")} {rows.map((r) => ( diff --git a/components/YourTurnView.tsx b/components/YourTurnView.tsx index 1606cdd1..9c8fa1fb 100644 --- a/components/YourTurnView.tsx +++ b/components/YourTurnView.tsx @@ -115,7 +115,10 @@ export function waitingOnMe(s: StateView, actor: Actor, open: (v: View, p?: Prop me: true, title: `The new company requirement needs ${L.team.security} to change its goal`, quote: kr?.text, - sub: `${L.team.product} and ${L.team.security} haven’t agreed yet`, + // After a decline the asking team is told so here (ProposalsView tells the owner they will be). + sub: [...s.proposals].reverse().find((p) => p.state !== "DRAFT")?.state === "REJECTED" + ? `${L.person.security} declined your last proposal. ${L.team.product} and ${L.team.security} haven’t agreed yet` + : `${L.team.product} and ${L.team.security} haven’t agreed yet`, cta: "Propose a change", open: () => open("proposals", undefined, true), }); diff --git a/scripts/test-ui-loop.mjs b/scripts/test-ui-loop.mjs index 0a1de436..a387631d 100644 --- a/scripts/test-ui-loop.mjs +++ b/scripts/test-ui-loop.mjs @@ -136,6 +136,7 @@ async function main() { const c = await api(null, "POST", "/api/sessions/events", { harness: "claude-code", session_id: AGENT, hook_event: "PostToolUse", repo: AGENT_REPO, cwd: AGENT_REPO, tool_name: "mcp__align__align_get_accepted_plan" }); check("agent: consent for a synthetic repo, and a session that read Align", g.status === 200 && c.status === 200, `${g.status} ${c.status}`); + let directivesAfterApproval = null; // read before U14 replaces the scenario, which clears the agent's wake-ups const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ["--no-first-run", "--no-default-browser-check"] }); try { const [A, O] = await Promise.all([browser.createBrowserContext(), browser.createBrowserContext()].map(async (ctx) => (await ctx).newPage())); @@ -328,12 +329,38 @@ async function main() { // U13 After a decided proposal, + New conflict opens the composer, not the old decided thread. await click(A, "New conflict"); check("U13 after a ratified proposal, + New conflict opens the composer", await waitText(A, "Send proposal to"), (await text(A)).slice(0, 400)); + + // U14 A declined proposal is visible to the asking team. Fresh fixture, the asking team proposes, the owner + // declines (ProposalsView tells the owner "… sees that you declined"): Your turn and Decisions both say so. + directivesAfterApproval = await api(null, "GET", `/api/directives?session=claude-code:${AGENT}`); + const s14 = await api(lead, "POST", "/api/scenario", { ...fixture, confirm: "replace" }); + await A.reload({ waitUntil: "networkidle2" }); + await click(A, "New conflict"); + if (!(await waitText(A, "Send proposal to"))) await click(A, "Propose a change"); + await fill(A, `New target for ${OWNING}`, `${MEASURE} (declined run)`); + await fill(A, "Why", "UILOOP decline path."); + await fill(A, `What ${ASKING} will do in return`, "UILOOP nothing extra."); + await click(A, "Send proposal to"); + await waitText(A, "Waiting"); + const owner = await signIn(BASE, "security"); + const pend = (await api(lead, "GET", "/api/state")).json?.proposals?.find((x) => x.state === "PENDING_SECURITY"); + const rj = await api(owner, "POST", `/api/proposals/${pend?.id}/reject`, {}); + await A.reload({ waitUntil: "networkidle2" }); + await click(A, "Your turn"); + const yt = await waitText(A, "declined your last proposal"); + await click(A, "Decisions"); + const dl = await waitText(A, "Declined by"); + check( + "U14 after the owner declines, the asking team's Your turn and Decisions both show the decline", + s14.status === 200 && rj.status === 200 && yt && dl, + JSON.stringify({ setup: s14.status, reject: rj.status, body: rj.text.slice(0, 120), yt, dl, tail: (await text(A)).slice(0, 300) }), + ); } finally { await browser.close(); } // The agent's wake-up: one, with the conditions quoted and no command - const after = await api(null, "GET", `/api/directives?session=claude-code:${AGENT}`); + const after = directivesAfterApproval ?? (await api(null, "GET", `/api/directives?session=claude-code:${AGENT}`)); const d = after.json?.pending ?? []; check( "U10 after approval the agent has exactly one wake-up, conditions quoted, no run command",