Files
PaperClipAI/ui/src/pages/CrossIssueCollaborationUxLab.tsx
T
DottaandClaude Opus 5 e8ae5286eb feat(issues): explain cross-task agent writes with attribution, audit receipts, and actionable denials (#10843)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents write to tasks they do not own. They comment, they change
fields, and the control plane now permits this by default for
standard-trust agents on any task they can read
> - This makes a task thread ambiguous. A reader sees a comment from an
agent that is not the assignee, but no surface says whose authority that
write rode
> - The same gap applies to field edits. The activity stream named the
verb, but it did not show the before value, the after value, or the
reason the write was permitted
> - The remaining refusals are also opaque. An agent that hits a wall
receives a 403 with no boundary name, no actor who can act, and no
sanctioned path. One real incident spent a full detour to find the
workaround
> - This pull request adds the three surfaces that make open cross-task
writes legible: an attribution chip, a field-level audit receipt, and an
actionable denial contract shared by the API and the UI
> - The benefit is that a reader can answer "who did this, on whose
authority, and was it allowed?" on the task itself, and a blocked writer
is told what to do next

## Linked Issues or Issue Description

No public issue exists for this work, so the enhancement is described
here.

**What existing behavior does this improve?**

Cross-task agent writes are permitted, but they are not explained. A
task thread can hold comments from agents that are not the assignee, and
the activity stream can hold field changes made by those agents. Neither
surface names the responsible user behind the write. When a write is
refused, the error text does not name the boundary or the way forward.

**Subsystem affected**

Issue detail UI (comment thread and activity stream), the issue write
authorization responses in the server, and the shared copy contract that
both consume.

**Current behavior**

- An agent comment on a task the agent does not own looks the same as an
assignee comment.
- An `issue.updated` activity row states the verb only. It does not show
the field-level before and after values, the responsible user, or the
authorization reason.
- A refused write returns a short message such as an ownership error.
The message does not state which rule fired, who is able to perform the
action, or which alternative path is sanctioned.

**Proposed behavior**

- An agent comment on a task the agent does not own carries a chip that
reads "for {user}". The chip names the responsible user. Its tooltip
states that the author is not the assignee and cannot exceed that user's
permissions.
- Each `issue.updated` row shows a receipt: the changed fields with
before and after values, the responsible user, and the authorization
reason. This applies to board edits as well as agent edits.
- Each refusal states three things: the boundary that fired, who is able
to act, and the sanctioned path. The API error body and the in-app
notice use the same words, because both read one shared contract.

Related pull requests, found by searching this repository:

- Refs #10837 — merged. It added the default-open cross-task write rule,
the comment attribution data, and the per-run containment cap that this
pull request makes visible.
- Refs #10114 — open. It proposes a narrower authorization change in the
same area.
- Refs #7998 — open. It proposes append-only cross-assignee comments as
an alternative to opening writes.

## What Changed

- Adds `packages/shared/src/issue-write-denial.ts`. This is one copy
contract for eight ways an issue write can be refused: not visible,
responsible-user ceiling, responsible user unavailable, excluded actor
class, assignee run lock, per-run cross-task cap, missing run context,
and rejected attribution. Each entry names the boundary, who can act,
and the sanctioned path.
- Maps server authorization decisions onto that contract in
`server/src/routes/issues.ts` and
`server/src/services/cross-issue-influence-limit.ts`. The flattened
`error` string carries all three obligations, and `details.code` lets
the UI render the same words. The two cap codes keep the names they
already ship under.
- Adds `CommentAttributionChip`. It renders "for {user}" beside the
author name on agent comments where the author is not the assignee. It
renders nothing when no responsible user is recorded, so older rows stay
clean. It is wired into both `IssueChatThread` and the flagged
`TaskChatThread` redesign.
- Adds `IssueFieldChangeReceipt`. It renders the change receipt under
`issue.updated` rows in the activity stream. Ids resolve to agent and
user names where the directory is loaded. Server-truncated text is
labelled as a preview, so the receipt never implies that it shows a
whole value.
- Adds `IssueWriteDenialNotice`. It renders the shared copy in the app,
keyed off the denial events the server logs on a task.
- Adds a public `/ux-lab/cross-issue-collaboration` page. It renders all
three surfaces and their edge cases for review without a seeded thread.
This follows the existing `ux-lab` pages.

## Verification

Automated, all green:

```
pnpm --filter @paperclipai/shared exec vitest run src/issue-write-denial.test.ts        # 17 tests
pnpm --filter @paperclipai/ui exec vitest run src/components/IssueWriteDenialNotice.test.tsx \
  src/components/IssueFieldChangeReceipt.test.tsx src/components/CommentAttributionChip.test.tsx \
  src/lib/issue-change-receipt.test.ts src/lib/comment-attribution.test.ts                # 46 tests
pnpm --filter @paperclipai/server exec vitest run src/__tests__/cross-issue-influence-limit.test.ts \
  src/__tests__/issue-comment-attribution-audit-routes.test.ts \
  src/__tests__/issue-agent-mutation-ownership-routes.test.ts \
  src/__tests__/low-trust-red-team-routes.test.ts                                        # 98 tests
```

`tsc --noEmit` passes for the shared, ui, and server packages.

Manual, in a browser:

1. Start the UI only: `pnpm --filter @paperclipai/ui exec vite`.
2. Open `/ux-lab/cross-issue-collaboration`. No session is needed,
because `ux-lab` routes are public.
3. All three surfaces were captured at 1440x900 in light mode and dark
mode, and at 390x844. The page reported no errors.
4. The chip tooltip was opened by a hover and by a keyboard focus.

Rendering the page found defects that the tests had missed. Three copy
and contrast defects were fixed, and two of them are now pinned by a
test. A design review then found three layout defects, which are also
fixed: the denial notice orphaned its label when a value wrapped, the
receipt icon wrapped onto its own line at narrow widths, and the chip
tooltip was reachable by hover only.

## Risks

Low risk, and additive.

- Every new surface renders nothing when its data is absent. Comments
without a recorded responsible user show no chip, and activity events
without a receipt show no receipt, so existing rows do not change.
- No migration is included. The data these surfaces read already ships.
- The wire values of the two per-run cap denial codes are unchanged.
Only the human-readable text changes, plus six codes that had no
`details.code` before.
- The denial copy is read by agents as well as people. If wording must
change later, one shared module is the only place to change it.
- Roadmap check: this extends the completed "Activity log & action
attribution" area rather than duplicating planned core work.

## Model Used

Claude Opus 5 (Anthropic), model id `claude-opus-5[1m]`, 1M context
window, extended thinking, with tool use and code execution. It ran as
an agent in Claude Code and drove a real browser to capture the review
screenshots.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 23:02:51 -05:00

285 lines
11 KiB
TypeScript

import type { ReactNode } from "react";
import { ISSUE_WRITE_DENIAL_CODES } from "@paperclipai/shared";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Card } from "@/components/ui/card";
import { CommentAttributionChip } from "@/components/CommentAttributionChip";
import { IssueFieldChangeReceipt } from "@/components/IssueFieldChangeReceipt";
import { IssueWriteDenialNotice } from "@/components/IssueWriteDenialNotice";
import { Identity } from "@/components/Identity";
import { cn } from "@/lib/utils";
/**
* UX lab for the three surfaces that make open
* cross-issue collaboration legible — the "for {user}" attribution chip, the
* field-edit audit receipt in the activity stream, and actionable denial copy.
*
* Route: /ux-lab/cross-issue-collaboration. Public (no session) so the states
* can be captured for UX review without seeding a live thread.
*/
function LabSection({
index,
title,
description,
children,
columns = 2,
}: {
index: string;
title: string;
description: string;
children: ReactNode;
columns?: 1 | 2;
}) {
return (
<section className="rounded-2xl border border-border/70 bg-background/85 p-5 shadow-sm">
<div className="mb-4">
<div className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
{index}
</div>
<h2 className="mt-1 text-base font-semibold text-foreground">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<div className={cn("grid gap-4", columns === 2 && "lg:grid-cols-2")}>{children}</div>
</section>
);
}
function Frame({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="space-y-2">
<div className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
{label}
</div>
<Card className="block border-border/60 p-3">{children}</Card>
</div>
);
}
/** A faithful copy of an agent comment bubble header + body (IssueChatThread.tsx). */
function AgentCommentBubble({
authorName,
onBehalfOf,
body,
}: {
authorName: string;
onBehalfOf?: string | null;
body: string;
}) {
return (
<div className="flex flex-col items-start py-1.5">
<div className="mb-1 flex items-center gap-1.5 px-1">
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<Avatar size="sm" className="size-5">
<AvatarFallback className="text-(length:--text-nano)">
{authorName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</span>
<span className="text-sm font-medium text-foreground">{authorName}</span>
{onBehalfOf ? (
<CommentAttributionChip agentName={authorName} userName={onBehalfOf} />
) : null}
</div>
<div className="min-w-0 max-w-(--pct-85) break-words border border-border bg-card px-3 py-2 text-sm text-foreground [border-radius:14px_14px_14px_4px]">
{body}
</div>
</div>
);
}
/** A faithful copy of an activity row in the issue run ledger (IssueDetail.tsx). */
function ActivityRow({
actorName,
verb,
children,
}: {
actorName: string;
verb: string;
children?: ReactNode;
}) {
return (
<div className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Identity name={actorName} size="sm" />
<span>{verb}</span>
<span className="ml-auto shrink-0">2m ago</span>
</div>
{children}
</div>
);
}
const AGENT_NAMES = new Map([
["3108ef8e-5ed0-41d9-b561-6b41c41b8545", "ClaudeCoder"],
["6670e11b-91d3-4429-82e0-436b88b51808", "UXDesigner"],
]);
export function CrossIssueCollaborationUxLab() {
const resolveAgentLabel = (id: string) => AGENT_NAMES.get(id) ?? null;
const resolveUserLabel = (id: string) => (id === "user-dotta" ? "Dotta" : null);
return (
<div className="min-h-screen bg-muted/20 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<header>
<div className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
Open cross-task collaboration
</div>
<h1 className="mt-1 text-xl font-semibold text-foreground">
Open cross-task collaboration — attribution, audit, and denial copy
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Agents may now write to any task they can read. These are the three surfaces that keep
that legible: whose authority a comment rode, what an edit changed, and — when a write
is refused — which boundary fired and what to do instead.
</p>
</header>
<LabSection
index="1 · Attribution chip (plan §3a)"
title="“Fable · for Dotta” on a cross-task agent comment"
description="An agent commenting on a task it is not assigned to names the responsible user whose authority it rode. The assignee's own comments stay unchipped — that is the ordinary case, and chipping every bubble would be noise."
>
<Frame label="Assignee's own comment — no chip">
<AgentCommentBubble
authorName="CodexCoder"
body="Rebased onto master and re-ran the containment suite; all green."
/>
</Frame>
<Frame label="Cross-task comment — chipped">
<AgentCommentBubble
authorName="Fable"
onBehalfOf="Dotta"
body="Dotta asked me to flag that the retry window here overlaps TASK-482. Worth a look before you close this."
/>
</Frame>
<Frame label="Responsible user not in the loaded directory">
<AgentCommentBubble
authorName="Fable"
onBehalfOf="the responsible user"
body="Falls back to a generic label rather than printing a raw user id."
/>
</Frame>
<Frame label="Long user name truncates in the chip">
<AgentCommentBubble
authorName="Fable"
onBehalfOf="Alexandra Konstantinopoulos-Whitfield"
body="The chip caps its width and truncates; the tooltip carries the full name."
/>
</Frame>
</LabSection>
<LabSection
index="2 · Field-edit audit receipt (plan §3b)"
title="Every PATCH says who changed what, and under which authorization"
description="Required for agent and board edits alike. Before/after per field, the responsible user behind the write, and the authorization reason that let it through."
>
<Frame label="Cross-task agent edit">
<ActivityRow actorName="Fable" verb="changed the status from todo to in progress">
<IssueFieldChangeReceipt
event={{
action: "issue.updated",
responsibleUserId: "user-dotta",
details: {
authorizationReason: "allow_visible_issue_write",
changes: {
status: { from: "todo", to: "in_progress" },
priority: { from: "medium", to: "high" },
},
},
}}
resolveAgentLabel={resolveAgentLabel}
resolveUserLabel={resolveUserLabel}
/>
</ActivityRow>
</Frame>
<Frame label="Board (human) edit — audited the same way">
<ActivityRow actorName="Dotta" verb="updated the issue">
<IssueFieldChangeReceipt
event={{
action: "issue.updated",
responsibleUserId: "user-dotta",
details: {
authorizationReason: "allow_board_actor",
changes: {
assigneeAgentId: {
from: "3108ef8e-5ed0-41d9-b561-6b41c41b8545",
to: "6670e11b-91d3-4429-82e0-436b88b51808",
},
description: { from: "Old brief…", to: "New brief…", updated: true },
},
},
}}
resolveAgentLabel={resolveAgentLabel}
resolveUserLabel={resolveUserLabel}
/>
</ActivityRow>
</Frame>
<Frame label="Reassignment, blockers, and work mode in one write">
<ActivityRow actorName="CTO" verb="updated the issue">
<IssueFieldChangeReceipt
event={{
action: "issue.updated",
responsibleUserId: "user-dotta",
details: {
authorizationReason: "allow_visible_issue_write",
changes: {
blockedByIssueIds: { from: [], to: ["TASK-491", "TASK-492"] },
workMode: { from: "planning", to: "standard" },
assigneeAgentId: {
from: null,
to: "3108ef8e-5ed0-41d9-b561-6b41c41b8545",
},
},
},
}}
resolveAgentLabel={resolveAgentLabel}
resolveUserLabel={resolveUserLabel}
/>
</ActivityRow>
</Frame>
<Frame label="Older activity row — no receipt, renders unchanged">
<ActivityRow actorName="CodexCoder" verb="checked out the issue" />
</Frame>
</LabSection>
<LabSection
index="3 · Actionable denial copy (plan §6)"
title="Every wall names the boundary, who can act, and the sanctioned path"
description="A real incident burned a full detour discovering a workaround behind an opaque 403. These are all the ways an issue write can now be refused — the same copy the API error body carries."
columns={1}
>
<Frame label="Before — what the incident actually saw">
<div className="text-xs">
<span className="text-red-600 dark:text-red-400">
403 Forbidden: Issue is outside this actor&apos;s authorization boundary
</span>
<p className="mt-1 text-muted-foreground">
No boundary named, nobody named, no path forward. The workaround (create a child
issue) had to be discovered by trial and error.
</p>
</div>
</Frame>
{ISSUE_WRITE_DENIAL_CODES.map((code) => (
<Frame key={code} label={`After — ${code}`}>
<IssueWriteDenialNotice
code={code}
context={{
actorLabel: "Fable",
assigneeLabel: "CodexCoder",
responsibleUserName: "Dotta",
issueIdentifier: "TASK-482",
cap: 20,
count: 21,
}}
/>
</Frame>
))}
</LabSection>
</div>
</div>
);
}
export default CrossIssueCollaborationUxLab;