mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
Adds a second primary path beside the preset scenario explorer. `#/chat` opens a blank thread on a freshly minted mock tenant — one company, one agent, one blank issue — and drives real Codex through real runnerd. The scenario explorer and its deterministic deep links are unchanged. The clean room is live-only by construction. The route pins `mode=live` and drops `shot`/`at`, the package server has no scripted path for it and refuses any non-live projection, and a failure to start runnerd or Codex is reported with a retry rather than replaced by a fixture. `createPhase7CleanRoomSessionInput` seeds the minimum a runner session needs and fails closed if anything else is present, so "starts blank" is enforced at the seam rather than assumed. The exposure profile is fixed and inspectable while the calls stay conversation-driven; three grants (`governance:approvals:decide`, `workspace:control`, `test:generic_api_request`) stay withheld so a typed denial is reachable in a chat nobody scripted. `Reset` and `New chat` retire the previous session's authority, drop its workspace, and mint new mock identities; the old session id answers 404 afterwards. The projection now publishes the real-API block as a `network-guard-<sessionId>` control-plane record, so "no request reached a real Paperclip API" is inspectable in the drawer on both surfaces. Also fixes two things the clean room surfaced: the unanchored `sk-` credential heuristic matched mock ids of the form `task-cleanroom-<token>`, and a hash change left one committed frame reporting `settled` for the surface just navigated away from. The 24-image screenshot matrix is re-recorded: the surface rail carrying the clean-room entry is a real, always-visible element, and it reproduces byte-for-byte against Chromium 151.0.7922.34. Verified: test:phase7 159/159; test:browser:issue-thread 58/58 with axe clean; check:phase7:ui 24/24 byte-identical; docs:validate 93 files, 34 OKF concepts; smoke:phase7:cleanroom all 15 assertions true against real Codex 0.132.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
|
|
import type { Phase7IssueThreadSnapshot } from "../../../src/issue-thread/types";
|
|
import { Chip, PriorityIcon, StatusBadge } from "./primitives";
|
|
|
|
/**
|
|
* Sticky issue header (contract §1 and §2). The three identity chips are never
|
|
* hidden by scroll, mode is data rather than styling, and on mobile Stop stays
|
|
* outside the overflow menu whenever a turn is active.
|
|
*/
|
|
|
|
export interface IssueHeaderProps {
|
|
snapshot: Phase7IssueThreadSnapshot;
|
|
scenarios: string[];
|
|
/** `chat` is the clean room: no preset scenario, no recording to replay. */
|
|
surface?: "issue" | "chat";
|
|
/** Clean-room tenant token, rendered so identity rotation is visible. */
|
|
cleanRoomToken?: string | null;
|
|
onNewChat?: () => void;
|
|
evidenceOpen: boolean;
|
|
denialCount: number;
|
|
segment: "thread" | "evidence";
|
|
onToggleEvidence: () => void;
|
|
onSelectScenario: (scenario: string) => void;
|
|
onReplay: () => void;
|
|
onReset: () => void;
|
|
onStop: () => void;
|
|
onSelectSegment: (segment: "thread" | "evidence") => void;
|
|
}
|
|
|
|
export function IssueHeader(props: IssueHeaderProps) {
|
|
const {
|
|
snapshot,
|
|
scenarios,
|
|
surface = "issue",
|
|
cleanRoomToken = null,
|
|
onNewChat,
|
|
evidenceOpen,
|
|
denialCount,
|
|
segment,
|
|
onToggleEvidence,
|
|
onSelectScenario,
|
|
onReplay,
|
|
onReset,
|
|
onStop,
|
|
onSelectSegment,
|
|
} = props;
|
|
const chat = surface === "chat";
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
const turnActive = snapshot.composer.state === "streaming" || snapshot.composer.state === "sending";
|
|
|
|
useEffect(() => {
|
|
if (!menuOpen) return;
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") setMenuOpen(false);
|
|
}
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => document.removeEventListener("keydown", onKeyDown);
|
|
}, [menuOpen]);
|
|
|
|
return (
|
|
<header className="pit-header" data-session-mode={snapshot.mode}>
|
|
<div className="pit-header-title-row">
|
|
<span className="pit-identifier">{snapshot.issue.identifier}</span>
|
|
<h1 className="pit-title">{snapshot.issue.title}</h1>
|
|
<StatusBadge status={snapshot.issue.status} />
|
|
<PriorityIcon priority={snapshot.issue.priority} />
|
|
{snapshot.issue.assignee !== null ? (
|
|
<span className="pit-assignee">{snapshot.issue.assignee}</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="pit-chip-row" data-testid="identity-chips">
|
|
<Chip tone={snapshot.mode === "live" ? "live" : undefined} testId="agent-chip">
|
|
{snapshot.identity.agentLabel}
|
|
{snapshot.identity.replaySource !== null
|
|
? ` · ${snapshot.identity.replaySource} source`
|
|
: ""}
|
|
</Chip>
|
|
<Chip testId="runner-chip">
|
|
<span
|
|
className="pit-process-dot"
|
|
data-attached={snapshot.identity.runnerAttached}
|
|
aria-hidden="true"
|
|
/>
|
|
{snapshot.identity.runnerLabel}
|
|
<span className="pit-visually-hidden">
|
|
{snapshot.identity.runnerAttached ? "attached" : "detached"}
|
|
</span>
|
|
</Chip>
|
|
<Chip tone="mock" title={snapshot.identity.controlPlaneTooltip} testId="control-plane-chip">
|
|
{snapshot.identity.controlPlaneLabel}
|
|
</Chip>
|
|
{chat && cleanRoomToken !== null ? (
|
|
<Chip tone="accent" testId="clean-room-identity">
|
|
Clean room {cleanRoomToken}
|
|
</Chip>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="pit-header-controls">
|
|
<span className="pit-run-state">{snapshot.issue.runState}</span>
|
|
|
|
{chat ? (
|
|
<button
|
|
type="button"
|
|
className="pit-button"
|
|
onClick={onNewChat}
|
|
data-testid="new-chat-button"
|
|
>
|
|
New chat
|
|
</button>
|
|
) : (
|
|
<>
|
|
<label className="pit-visually-hidden" htmlFor="scenario-picker">
|
|
Scenario
|
|
</label>
|
|
<select
|
|
className="pit-select pit-desktop-only"
|
|
id="scenario-picker"
|
|
value={snapshot.issue.fixtureProfile}
|
|
onChange={(event) => onSelectScenario(event.target.value)}
|
|
data-testid="scenario-picker"
|
|
>
|
|
{scenarios.map((scenario) => (
|
|
<option key={scenario} value={scenario}>
|
|
{scenario}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
<button
|
|
type="button"
|
|
className="pit-button pit-desktop-only"
|
|
onClick={onReplay}
|
|
data-testid="replay-button"
|
|
>
|
|
Replay
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="pit-button pit-desktop-only"
|
|
data-variant="destructive"
|
|
onClick={onReset}
|
|
data-testid="reset-button"
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="pit-button"
|
|
data-variant="destructive"
|
|
onClick={onStop}
|
|
disabled={!turnActive}
|
|
data-testid="stop-button"
|
|
>
|
|
Stop
|
|
</button>
|
|
|
|
<div className="pit-menu pit-mobile-only" ref={menuRef}>
|
|
<button
|
|
type="button"
|
|
className="pit-button"
|
|
aria-expanded={menuOpen}
|
|
aria-haspopup="menu"
|
|
onClick={() => setMenuOpen((current) => !current)}
|
|
data-testid="overflow-menu-button"
|
|
>
|
|
<span aria-hidden="true">⋯</span>
|
|
<span className="pit-visually-hidden">More actions</span>
|
|
</button>
|
|
{menuOpen ? (
|
|
<div className="pit-menu-list" role="menu" data-testid="overflow-menu">
|
|
{chat ? null : (
|
|
<>
|
|
<label className="pit-menu-item" htmlFor="scenario-picker-mobile">
|
|
Scenario
|
|
<select
|
|
className="pit-select"
|
|
id="scenario-picker-mobile"
|
|
value={snapshot.issue.fixtureProfile}
|
|
onChange={(event) => {
|
|
setMenuOpen(false);
|
|
onSelectScenario(event.target.value);
|
|
}}
|
|
>
|
|
{scenarios.map((scenario) => (
|
|
<option key={scenario} value={scenario}>
|
|
{scenario}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="pit-menu-item"
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
onReplay();
|
|
}}
|
|
>
|
|
Replay
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="pit-menu-item"
|
|
onClick={() => {
|
|
setMenuOpen(false);
|
|
onReset();
|
|
}}
|
|
>
|
|
{chat ? "Reset chat" : "Reset scenario"}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="pit-button pit-desktop-only"
|
|
aria-expanded={evidenceOpen}
|
|
onClick={onToggleEvidence}
|
|
data-testid="evidence-toggle"
|
|
>
|
|
Evidence
|
|
{denialCount > 0 ? ` (${denialCount})` : ""}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="pit-segmented" role="tablist" aria-label="Thread or evidence">
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
className="pit-segment"
|
|
aria-selected={segment === "thread"}
|
|
onClick={() => onSelectSegment("thread")}
|
|
data-testid="segment-thread"
|
|
>
|
|
Thread
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
className="pit-segment"
|
|
aria-selected={segment === "evidence"}
|
|
onClick={() => onSelectSegment("evidence")}
|
|
data-testid="segment-evidence"
|
|
>
|
|
Evidence
|
|
{denialCount > 0 ? (
|
|
<span className="pit-segment-badge" data-testid="segment-denial-badge">
|
|
{denialCount}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|