feat(ui): NUX rework behind enableConferenceRoomChat experimental flag — capsule onboarding, conference-room chat, unified composer (#8000)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The first-run experience (onboarding wizard) and the chat surfaces
(conference-room/board chat, task threads, composers) are the product's
front door — they decide whether a new operator understands "hire
agents, give them work, review results" in the first five minutes
> - Today those surfaces feel ticket-y and form-like: the wizard is a
static multi-step form that ends in an anticlimactic "Launch" screen,
the task composer and board chat behave differently from each other, and
agent-feed issue quicklooks misbehave (multiple flyouts open at once,
cards jump on hover)
> - We wanted to iterate toward a conversational, team-centric NUX — but
without risking the workflows of everyone already running Paperclip
> - This PR reworks the NUX behind a new default-OFF
`enableConferenceRoomChat` experimental flag: a capsule-motif onboarding
wizard that builds your team as you answer, a conference-room chat
surface, one shared ChatComposer across surfaces, brand-accurate status
chips, and feed-quicklook fixes — with the pre-existing UI
fork-and-frozen as `*Classic` components that flag-OFF users keep
> - The benefit is a complete, testable modern NUX that anyone can opt
into from Settings → Experimental, with zero default behavior change and
a clean path to either graduate or drop the experiment

## Linked Issues or Issue Description

No pre-existing GitHub issue — feature description per
`feature_request.yml`:

- **Problem / motivation:** Paperclip's onboarding wizard and chat
surfaces grew up as separate ticket-centric forms. New users get a
form-filling experience rather than the feeling of standing up a team;
the board chat and task threads use different composers with different
affordances; the agent feed's issue quicklook can stack multiple
popovers and shifts cards on hover.
- **Proposed solution:** A coherent NUX experiment behind one
experimental flag (`enableConferenceRoomChat`, Settings → Experimental,
default OFF): capsule onboarding wizard with an evolving team capsule,
conference-room chat, unified `ChatComposer`, team-centric copy, brand
status chips, quicklook single-flight fix. Flag-OFF users get the exact
pre-experiment UI via frozen `*Classic` forks, verified by an on/off
parity test matrix.
- **Alternatives considered:** (a) incremental unflagged restyling —
rejected: the changes interlock across surfaces and would drip risk into
every release; (b) a separate app shell / route for the new NUX —
rejected: too much divergence, the flag + classic-fork pattern keeps the
diff reviewable and reversible.
- **Roadmap alignment:** `ROADMAP.md` lists **CEO Chat** ("a
lighter-weight way to talk to leadership agents... should still resolve
to real work objects"). This experiment is groundwork in that direction
(conference-room chat resolves to issues/tasks via the same composer
used in task threads) and does not change the core task-and-comments
model.

Related PRs found in the dedup search (same area, none duplicate this
work — they target the classic wizard, which this PR intentionally
leaves intact and mergeable):

- #5385 — Coach-driven onboarding: conversational entry +
agent-companies package import
- #5378 — Onboarding wizard: reusable adapter picker + probe card
- #6636 — ui(onboarding): friendly error surface + retry for the wizard
- #7005 — fix(onboarding): explicitly await first-task wake
- #2616 — fix: restore workspace directory config in onboarding wizard

## What Changed

- **Experimental flag plumbing** — `enableConferenceRoomChat` in shared
types/validators, server instance-settings service + API, Settings →
Experimental card with explicit enable/disable copy
- **Onboarding wizard** — classic wizard forked and frozen
(`OnboardingWizardClassic`); flag-ON variant is a 5-step capsule wizard
with a persistent evolving `AgentCapsule` (gradient/glow motif),
team-centric reframed copy, and a typing-dots intro (hardened with
fake-timer tests)
- **Conference-room chat** — flag-ON board-chat surface with agent
bubble name/icon headers and copy/vote/timestamp action rows
(`AgentBubbleActionRow`)
- **Unified composer** — shared `ChatComposer` adopted across surfaces;
translucent surface + scroll-mask removal; "Agent mode"/"Plan mode"
relabels; no-assignee confirmation `AlertDialog` (new
`ui/alert-dialog.tsx` primitive); `@task` reference picker +
linkification in mentions
- **Agent feed** — single-flight issue-quicklook store (one popover at a
time), flyouts open to the left, removed hover translate-y jitter
- **Status chips** — brand-accurate task status chips behind the flag
(light/dark, 1px borders per paperclip.ing/brand)
- **Tests** — flag on/off parity matrix across IssueDetail,
NewIssueDialog, Sidebar, wizard, gate components; component tests for
all new pieces
- **Merge with `master`** — one conflict in
`ui/src/components/IssueChatThread.tsx`, resolved by keeping master's
new `AssigneeChip`/`HandoffWakeRow`/`RunStatusBadge` components inside
the flag-gated metadata-row chrome (details in commit `21a5642a`);
post-merge fixes: vitest 4 mock typing in `MarkdownEditor.test.tsx`,
flag hook made safe for provider-less mounts (master's new isolated
component tests)
- **Branch hygiene** — internal design wireframes/mockups stripped
before the PR (they live in the Paperclip issue threads)
- No user-facing documentation changes required: the flag is
intentionally experimental and self-described in the Settings card; no
existing docs reference the affected surfaces

## Verification

- `pnpm run typecheck` — green across the workspace (ui, server, shared,
plugins)
- Full UI suite (`vitest run` in `ui/`, clean worktree at this HEAD):
**1593/1595 passing, 223/224 files** — the 2 remaining failures are in
`src/components/artifacts/ArtifactCard.test.tsx` and **fail identically
on pristine `origin/master`** (pre-existing upstream, unrelated to this
branch)
- Full server suite (`vitest run` in `server/`, same clean worktree):
results in PR checks; flag plumbing covered by instance-settings tests
- Targeted post-merge resolution check: `IssueChatThread`,
`IssueChatThreadSystemNotice`, `IssueDetail`, `Sidebar`,
`ConferenceRoomChatGate`, `OnboardingWizardVariant`, `NewIssueDialog`,
`InstanceExperimentalSettings`, `MarkdownEditor` — 172/172 passing
- Manual walkthrough: flag OFF (default) → onboarding wizard, task
thread, board chat, composer all render the classic UI; flag ON via
Settings → Experimental → capsule wizard, conference-room chat, unified
composer, status chips active
- Screenshots: see below

**Flag on/off screenshots** (committed on this branch under
`screenshots/PR-8000-*`):

| Surface | Flag OFF (classic, default) | Flag ON (experimental) |
| --- | --- | --- |
| Settings → Experimental | ![settings
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-settings-experimental-flag-off.png)
| ![settings
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-settings-experimental-flag-on.png)
|
| Task thread | ![thread
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-task-thread-flag-off.png)
| ![thread
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-task-thread-flag-on.png)
|
| Home / nav | ![home
off](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-home-flag-off.png)
| ![home
on](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-home-flag-on.png)
|
| Conference Room (flag-ON only surface) | — | ![conference
room](https://raw.githubusercontent.com/paperclipai/paperclip/9b4f02708383031d3124b68ca3ed9ab437ea3501/screenshots/PR-8000-conference-room-flag-on.png)
|

Capsule onboarding wizard walkthrough screenshots (flag ON) are attached
to the Paperclip design/implementation threads; the wizard requires a
fresh instance so it is captured via the e2e harness
(`tests/e2e/nux-phase4-screenshots.spec.ts`).


## Risks

- **Large surface, but gated:** all new behavior sits behind
`enableConferenceRoomChat`, default OFF; flag-OFF rendering is locked by
frozen `*Classic` forks plus an on/off parity test suite
- **Classic forks are frozen at the fork point (`e3aada1d`):** master
features added to the live thread component after that point (assignee
handoff chips, run status badge, composer mention coach) render in the
flag-ON path; the flag-OFF task thread keeps the fork-point behavior
until the experiment graduates (forks deleted) or is dropped (forks
restored as canonical). Called out for reviewer attention.
- **Merge-conflict resolution in `IssueChatThread.tsx`** (commit
`21a5642a`) deserves reviewer eyes: master's new handoff/run-status
components were kept; the base toast-style no-assignee flow remains
replaced by the AlertDialog flow introduced on this branch
- Schema/server changes are additive (one optional boolean instance
setting); no migrations of existing data

## Model Used

- Claude (Anthropic) via Claude Code running in the Paperclip agent
harness (agent: ClaudeCoder)
- Branch implemented across multiple agent sessions on Claude Opus-class
models with extended thinking + tool use (file edits, shell, Playwright
screenshots); merge/PR session model ID as reported by the harness:
`claude-fable-5` (Claude Code CLI)
- All code was agent-authored and board-reviewed through Paperclip issue
threads (plans, wireframes, confirmations) before merging

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes (none
required — experimental flag, self-documenting Settings card; noted
above)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (run 3 on `8af3041a`: all 16
gates SUCCESS, incl. e2e and all 4 serialized-suite shards)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(re-review verdict: Confidence 5/5, “Safe to merge”; all 4 round-1
findings fixed + confirmed resolved; both summary notes addressed in
`8af3041a`)
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
scotttong
2026-06-11 14:32:55 -07:00
committed by GitHub
parent 1413729a06
commit 6f9801a46b
87 changed files with 17972 additions and 1258 deletions
+309
View File
@@ -0,0 +1,309 @@
// @vitest-environment jsdom
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { act, forwardRef, useImperativeHandle } from "react";
import type { ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BoardChat } from "./BoardChat";
/**
* Regression coverage for the post-wizard Conference Room intro (PAP-134,
* plan: PAP-133 A+B): a fresh mount shows the three-dot typing bubble for
* ~2s, then the CEO welcome, then the suggestion chips ~700ms later. The
* staged reveal must hold while the onboarding wizard overlay is open or
* the tab is hidden, and must fast-forward when the user already replied.
*/
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockGoalsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
listComments: vi.fn(),
listFeedbackVotes: vi.fn(),
}));
const mockDialogState = vi.hoisted(() => ({ onboardingOpen: false }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Acme Robotics", issuePrefix: "PAP" },
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("../context/DialogContext", () => ({
useDialogState: () => ({ onboardingOpen: mockDialogState.onboardingOpen }),
}));
// Heavy children that are irrelevant to the staged intro.
vi.mock("../components/ActivityFeed", () => ({
ActivityFeed: () => <div data-testid="activity-feed" />,
}));
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../components/ChatComposer", () => ({
ChatComposer: forwardRef((_props, ref) => {
useImperativeHandle(ref, () => ({ focus: vi.fn() }));
return <div data-testid="chat-composer" />;
}),
}));
vi.mock("../components/AgentBubbleActionRow", () => ({
AgentBubbleActionRow: () => null,
agentBubbleDateLabel: () => "",
}));
vi.mock("../components/AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: () => null,
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
SheetContent: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
const CEO_AGENT = {
id: "agent-ceo",
name: "Alex",
role: "ceo",
status: "active",
icon: null,
};
const BOARD_ISSUE = { id: "issue-board", title: "Board Operations", status: "in_progress" };
const USER_COMMENT = {
id: "comment-user-1",
body: "Hi Alex!",
authorAgentId: null,
authorUserId: "user-1",
createdAt: "2026-06-10T00:00:00.000Z",
};
function hasTypingDots(container: HTMLElement) {
return container.querySelectorAll(".typing-dots").length > 0;
}
function hasWelcome(container: HTMLElement) {
return (container.textContent ?? "").includes("Welcome to");
}
function hasChips(container: HTMLElement) {
return (container.textContent ?? "").includes("Draft a Company Brief");
}
describe("BoardChat staged typing intro", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("ResizeObserver", ResizeObserverStub);
container = document.createElement("div");
document.body.appendChild(container);
mockDialogState.onboardingOpen = false;
mockAgentsApi.list.mockResolvedValue([CEO_AGENT]);
mockGoalsApi.list.mockResolvedValue([
{ id: "goal-1", title: "Build affordable robots", status: "active" },
]);
mockIssuesApi.list.mockResolvedValue([BOARD_ISSUE]);
mockIssuesApi.listComments.mockResolvedValue([]);
mockIssuesApi.listFeedbackVotes.mockResolvedValue([]);
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
root = null;
container.remove();
sessionStorage.clear();
vi.unstubAllGlobals();
vi.useRealTimers();
vi.clearAllMocks();
// Drop any per-test document.visibilityState override.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (document as any).visibilityState;
});
let queryClient: QueryClient | null = null;
function buildElement() {
// A fresh element every time — rendering an identical element reference
// lets React bail out of re-rendering, which would hide mock-state flips.
return (
<QueryClientProvider client={queryClient!}>
<BoardChat />
</QueryClientProvider>
);
}
async function render() {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
root = createRoot(container);
await act(async () => {
root!.render(buildElement());
});
// Let the agent/goal/issue queries resolve, plus the follow-up render
// that enables the comments query off boardIssueId. react-query batches
// notifications through zero-delay timers, so flush those too.
for (let i = 0; i < 6; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
}
/** Re-render the existing tree so hooks re-read mutated mock state. */
async function rerender() {
await act(async () => {
root!.render(buildElement());
});
await act(async () => {
await Promise.resolve();
});
}
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
}
it("reveals typing dots, then the welcome at 2s, then chips at +700ms", async () => {
await render();
// Fresh mount: dots only.
expect(hasTypingDots(container)).toBe(true);
expect(hasWelcome(container)).toBe(false);
// Just before the reveal: still dots.
await advance(1900);
expect(hasTypingDots(container)).toBe(true);
expect(hasWelcome(container)).toBe(false);
// t=2s: welcome bubble lands, dots leave, chips not yet.
await advance(100);
expect(hasWelcome(container)).toBe(true);
expect(hasTypingDots(container)).toBe(false);
expect(hasChips(container)).toBe(false);
// t=2.7s: chips stage in.
await advance(700);
expect(hasChips(container)).toBe(true);
});
it("skips the staged reveal when a user comment already exists", async () => {
mockIssuesApi.listComments.mockResolvedValue([USER_COMMENT]);
await render();
// Fast-forwarded: no dots, welcome immediately, no timer needed.
expect(hasTypingDots(container)).toBe(false);
expect(hasWelcome(container)).toBe(true);
});
it("holds the dots while the onboarding wizard overlay is open (PAP-134)", async () => {
mockDialogState.onboardingOpen = true;
await render();
// The 2s window must not burn behind the wizard overlay.
await advance(2500);
expect(hasTypingDots(container)).toBe(true);
expect(hasWelcome(container)).toBe(false);
// Wizard closes → reveal timer starts fresh.
mockDialogState.onboardingOpen = false;
await rerender();
await advance(2000);
expect(hasWelcome(container)).toBe(true);
expect(hasTypingDots(container)).toBe(false);
});
it("holds the dots while the document is hidden (PAP-134)", async () => {
let visibility: DocumentVisibilityState = "hidden";
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => visibility,
});
await render();
// The 2s window must not burn while the tab is hidden.
await advance(2500);
expect(hasTypingDots(container)).toBe(true);
expect(hasWelcome(container)).toBe(false);
// Tab becomes visible → reveal timer starts fresh.
visibility = "visible";
await act(async () => {
document.dispatchEvent(new Event("visibilitychange"));
});
await advance(2000);
expect(hasWelcome(container)).toBe(true);
expect(hasTypingDots(container)).toBe(false);
});
});
describe("typing-dots CSS animation guard (PAP-54 failure mode)", () => {
// PAP-54: the .typing-dots CSS block was silently dropped from index.css
// during a theme migration, leaving static markup with no animation. Guard
// the source so the block can't vanish again without failing a test. The
// browser-computed `animationName !== "none"` assertion lives in
// tests/e2e/conference-room-typing-intro.spec.ts.
// Locate ui/src/index.css regardless of whether vitest runs from ui/ or
// the workspace root (import.meta.url is an http URL under jsdom, and the
// css pipeline swallows `?raw` imports — plain fs is the reliable path).
function readIndexCss(): string {
let dir = process.cwd();
for (let depth = 0; depth < 6; depth++) {
for (const candidate of [
path.join(dir, "src/index.css"),
path.join(dir, "ui/src/index.css"),
]) {
if (existsSync(candidate)) return readFileSync(candidate, "utf8");
}
dir = path.dirname(dir);
}
throw new Error("ui/src/index.css not found from " + process.cwd());
}
const css = readIndexCss();
it("keeps the bounce animation wired to .typing-dots span", () => {
const spanRules = [...css.matchAll(/\.typing-dots span\s*\{[^}]*\}/g)].map(
(m) => m[0],
);
expect(spanRules.length).toBeGreaterThan(0);
expect(
spanRules.some((rule) => /animation:\s*typing-bounce/.test(rule)),
).toBe(true);
});
it("keeps the typing-bounce keyframes", () => {
expect(css).toMatch(/@keyframes typing-bounce\s*\{/);
});
});
File diff suppressed because it is too large Load Diff
+100 -29
View File
@@ -112,9 +112,11 @@ import {
AvatarGroup,
AvatarGroupCount,
} from "@/components/ui/avatar";
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { PriorityIcon } from "@/components/PriorityIcon";
import { agentStatusDot, agentStatusDotDefault } from "@/lib/status-colors";
import { EntityRow } from "@/components/EntityRow";
import { EmptyState } from "@/components/EmptyState";
import { MetricCard } from "@/components/MetricCard";
@@ -470,7 +472,7 @@ export function DesignGuide() {
<SubSection title="With icons">
<div className="flex items-center gap-2 flex-wrap">
<Button><Plus /> New Task</Button>
<Button><Plus /> New Issue</Button>
<Button variant="outline"><Upload /> Upload</Button>
<Button variant="destructive"><Trash2 /> Delete</Button>
<Button size="sm"><Plus /> Add</Button>
@@ -518,6 +520,16 @@ export function DesignGuide() {
</div>
</SubSection>
<SubSection title="IssueStatusBadge (brand chip + glyph — PAP-75)">
<div className="flex items-center gap-2 flex-wrap">
{["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"].map(
(s) => (
<IssueStatusBadge key={s} status={s} />
)
)}
</div>
</SubSection>
<SubSection title="StatusIcon (interactive)">
<div className="flex items-center gap-3 flex-wrap">
{["backlog", "todo", "in_progress", "in_review", "done", "cancelled", "blocked"].map(
@@ -550,18 +562,14 @@ export function DesignGuide() {
</div>
</SubSection>
<SubSection title="Agent status (capsule + chip)">
<p className="text-xs text-muted-foreground mb-3 max-w-prose">
The agents section uses a brand heartbeat capsule (8×16) plus a brand
<code className="mx-1">.task-chip</code>. Four states only: idle (gray),
running (blue, pulses), paused (amber), error (red, blinks). Motion
honors <code>prefers-reduced-motion</code>.
</p>
<div className="flex items-center gap-6 flex-wrap">
{(["idle", "running", "paused", "error"] as const).map((label) => (
<SubSection title="Agent status dots">
<div className="flex items-center gap-4 flex-wrap">
{(["running", "active", "paused", "error", "archived"] as const).map((label) => (
<div key={label} className="flex items-center gap-2">
<AgentStatusCapsule status={label} />
<AgentStatusBadge status={label} />
<span className="relative flex h-2.5 w-2.5">
<span className={`inline-flex h-full w-full rounded-full ${agentStatusDot[label] ?? agentStatusDotDefault}`} />
</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
))}
</div>
@@ -598,6 +606,69 @@ export function DesignGuide() {
</SubSection>
</Section>
{/* ============================================================ */}
{/* AGENT CAPSULE */}
{/* ============================================================ */}
<Section title="Agent Capsule">
<p className="text-sm text-muted-foreground max-w-prose">
The brand &quot;capsule is the agent&quot; motif. A single agent reads as a tall
pill that moves through three states as it comes to life. The online fill uses
the live brand agent-gradient tokens (<code className="font-mono">--agent-Na</code> {" "}
<code className="font-mono">--agent-Nb</code>); <code className="font-mono">prefers-reduced-motion</code>{" "}
skips the liquid rise and pulses and renders the final state.
</p>
<SubSection title="States">
<div className="flex items-end gap-10">
<div className="flex flex-col items-center gap-2">
<AgentCapsule state="slot" />
<span className="text-xs text-muted-foreground">slot</span>
</div>
<div className="flex flex-col items-center gap-2">
<AgentCapsule state="configured" />
<span className="text-xs text-muted-foreground">configured</span>
</div>
<div className="flex flex-col items-center gap-2">
<AgentCapsule state="online" gradient={5} />
<span className="text-xs text-muted-foreground">online</span>
</div>
<div className="flex flex-col items-center gap-2">
<AgentCapsule state="online" gradient={5} glow="blue" />
<span className="text-xs text-muted-foreground">online · blue glow</span>
</div>
</div>
</SubSection>
<SubSection title="Sizes">
<div className="flex items-end gap-8">
<div className="flex flex-col items-center gap-2">
<AgentCapsule state="online" size="sm" gradient={1} />
<span className="text-xs text-muted-foreground">sm</span>
</div>
<div className="flex flex-col items-center gap-2">
<AgentCapsule state="online" size="md" gradient={4} />
<span className="text-xs text-muted-foreground">md</span>
</div>
<div className="flex flex-col items-center gap-2">
<AgentCapsule state="online" size="lg" gradient={8} />
<span className="text-xs text-muted-foreground">lg</span>
</div>
<div className="flex flex-col items-center gap-2">
<AgentCapsule state="online" size={{ width: 28, height: 96 }} gradient={6} />
<span className="text-xs text-muted-foreground">custom px</span>
</div>
</div>
</SubSection>
<SubSection title="Gradients">
<div className="flex items-end gap-3 flex-wrap">
{Array.from({ length: AGENT_GRADIENT_COUNT }, (_, i) => (
<div key={i} className="flex flex-col items-center gap-1.5">
<AgentCapsule state="online" size="sm" gradient={i + 1} />
<span className="text-[10px] font-mono text-muted-foreground">{i + 1}</span>
</div>
))}
</div>
</SubSection>
</Section>
{/* ============================================================ */}
{/* FORM ELEMENTS */}
{/* ============================================================ */}
@@ -727,11 +798,11 @@ export function DesignGuide() {
checked={menuChecked}
onCheckedChange={(value) => setMenuChecked(value === true)}
>
Watch task
Watch issue
</DropdownMenuCheckboxItem>
<DropdownMenuItem variant="destructive">
<Trash2 className="h-4 w-4" />
Delete task
Delete issue
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -784,7 +855,7 @@ export function DesignGuide() {
</SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Task Properties</SheetTitle>
<SheetTitle>Issue Properties</SheetTitle>
<SheetDescription>Edit metadata without leaving the current page.</SheetDescription>
</SheetHeader>
<div className="space-y-4 px-4">
@@ -836,7 +907,7 @@ export function DesignGuide() {
</CommandItem>
<CommandItem>
<CircleDot className="h-4 w-4" />
Tasks
Issues
</CommandItem>
</CommandGroup>
<CommandSeparator />
@@ -847,7 +918,7 @@ export function DesignGuide() {
</CommandItem>
<CommandItem>
<Plus className="h-4 w-4" />
Create new task
Create new issue
</CommandItem>
</CommandGroup>
</CommandList>
@@ -870,7 +941,7 @@ export function DesignGuide() {
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Task List</BreadcrumbPage>
<BreadcrumbPage>Issue List</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -899,7 +970,7 @@ export function DesignGuide() {
<SubSection title="Metric Cards">
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
<MetricCard icon={Bot} value={12} label="Active Agents" description="+3 this week" />
<MetricCard icon={CircleDot} value={48} label="Open Tasks" />
<MetricCard icon={CircleDot} value={48} label="Open Issues" />
<MetricCard icon={DollarSign} value="$1,234" label="Monthly Cost" description="Under budget" />
<MetricCard icon={Zap} value="99.9%" label="Uptime" />
</div>
@@ -968,7 +1039,7 @@ export function DesignGuide() {
identifier="PAP-001"
title="Implement authentication flow"
subtitle="Assigned to Agent Alpha"
trailing={<StatusBadge status="in_progress" />}
trailing={<IssueStatusBadge status="in_progress" />}
onClick={() => {}}
/>
<EntityRow
@@ -981,7 +1052,7 @@ export function DesignGuide() {
identifier="PAP-002"
title="Set up CI/CD pipeline"
subtitle="Completed 2 days ago"
trailing={<StatusBadge status="done" />}
trailing={<IssueStatusBadge status="done" />}
onClick={() => {}}
/>
<EntityRow
@@ -993,7 +1064,7 @@ export function DesignGuide() {
}
identifier="PAP-003"
title="Write API documentation"
trailing={<StatusBadge status="todo" />}
trailing={<IssueStatusBadge status="todo" />}
onClick={() => {}}
/>
<EntityRow
@@ -1006,7 +1077,7 @@ export function DesignGuide() {
identifier="PAP-004"
title="Deploy to production"
subtitle="Blocked by PAP-001"
trailing={<StatusBadge status="blocked" />}
trailing={<IssueStatusBadge status="blocked" />}
selected
/>
</div>
@@ -1298,7 +1369,7 @@ export function DesignGuide() {
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground cursor-pointer">
<CircleDot className="h-4 w-4" />
Tasks
Issues
<span className="ml-auto text-xs bg-primary text-primary-foreground rounded-full px-1.5 py-0.5">
12
</span>
@@ -1331,7 +1402,7 @@ export function DesignGuide() {
{/* ============================================================ */}
{/* GROUPED LIST (Issues pattern) */}
{/* ============================================================ */}
<Section title="Grouped List (Tasks pattern)">
<Section title="Grouped List (Issues pattern)">
<div>
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
<StatusIcon status="in_progress" />
@@ -1588,7 +1659,7 @@ export function DesignGuide() {
<div className="border border-border rounded-md divide-y divide-border text-sm">
{[
["Cmd+K / Ctrl+K", "Open Command Palette"],
["C", "New Task (outside inputs)"],
["C", "New Issue (outside inputs)"],
["[", "Toggle Sidebar"],
["]", "Toggle Properties Panel"],
@@ -1604,7 +1675,7 @@ export function DesignGuide() {
</div>
</Section>
<Section title="Task Output Surface">
<Section title="Issue Output Surface">
<SubSection title="Multiple outputs (primary video + 'Also produced')">
<IssueOutputSection workProducts={DESIGN_GUIDE_OUTPUTS} />
</SubSection>
@@ -1613,7 +1684,7 @@ export function DesignGuide() {
</SubSection>
<SubSection title="Empty state">
<p className="text-xs text-muted-foreground">
When a task has produced no artifact work products, the Output section renders nothing
When an issue has produced no artifact work products, the Output section renders nothing
at all (no placeholder card).
</p>
</SubSection>
@@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InstanceExperimentalSettings } from "./InstanceExperimentalSettings";
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
updateExperimental: vi.fn(),
previewIssueGraphLivenessAutoRecovery: vi.fn(),
runIssueGraphLivenessAutoRecovery: vi.fn(),
}));
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
const CONFERENCE_TOGGLE_SELECTOR =
'button[aria-label="Toggle conference room chat experimental setting"]';
describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-137)", () => {
let container: HTMLDivElement;
let root: Root | null = null;
async function renderPage() {
root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<InstanceExperimentalSettings />
</QueryClientProvider>,
);
});
await flushReact();
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableConferenceRoomChat: false,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
});
mockInstanceSettingsApi.updateExperimental.mockResolvedValue({});
});
afterEach(() => {
flushSync(() => {
root?.unmount();
});
root = null;
container.remove();
vi.clearAllMocks();
});
it("renders the card with the approved copy, placed right after Streamlined Left Navigation Bar", async () => {
await renderPage();
expect(container.textContent).toContain("Conference Room Chat");
expect(container.textContent).toContain(
"Adds a Conference Room — one chat where you and your whole team work together — plus the live activity feed and the redesigned onboarding. Also restyles task threads as chat bubbles. Turn off anytime to restore the classic UI.",
);
const headings = [...container.querySelectorAll("section h2")].map((h) => h.textContent);
const streamlinedIndex = headings.indexOf("Streamlined Left Navigation Bar");
const conferenceIndex = headings.indexOf("Conference Room Chat");
expect(streamlinedIndex).toBeGreaterThanOrEqual(0);
expect(conferenceIndex).toBe(streamlinedIndex + 1);
});
it("toggle reflects the loaded flag value", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableConferenceRoomChat: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
});
await renderPage();
const toggle = container.querySelector(CONFERENCE_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("clicking the toggle patches enableConferenceRoomChat on", async () => {
await renderPage();
const toggle = container.querySelector<HTMLButtonElement>(CONFERENCE_TOGGLE_SELECTOR);
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
toggle?.click();
});
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
enableConferenceRoomChat: true,
});
});
it("clicking the toggle patches enableConferenceRoomChat off when currently on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableConferenceRoomChat: true,
issueGraphLivenessAutoRecoveryLookbackHours: 24,
});
await renderPage();
const toggle = container.querySelector<HTMLButtonElement>(CONFERENCE_TOGGLE_SELECTOR);
await act(async () => {
toggle?.click();
});
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
enableConferenceRoomChat: false,
});
});
});
@@ -208,6 +208,7 @@ export function InstanceExperimentalSettings() {
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableStreamlinedLeftNavigation =
experimentalQuery.data?.enableStreamlinedLeftNavigation === true;
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
const enableExperimentalFileViewer =
@@ -349,6 +350,29 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Conference Room Chat</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Adds a Conference Room one chat where you and your whole team work together plus the live activity
feed and the redesigned onboarding. Also restyles task threads as chat bubbles. Turn off anytime to
restore the classic UI.
</p>
</div>
<ToggleSwitch
checked={enableConferenceRoomChat}
onCheckedChange={() =>
toggleMutation.mutate({
enableConferenceRoomChat: !enableConferenceRoomChat,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle conference room chat experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
+120
View File
@@ -252,6 +252,24 @@ vi.mock("../components/IssueChatThread", () => ({
},
}));
// PAP-139/PAP-140: IssueDetail picks the thread variant by the Conference
// Room Chat flag. These suites assert against the NUX thread, so the flag is
// seeded ON (and reset in beforeEach); flag-off parity tests flip it per test.
// The classic fork is stubbed (its real import chain pulls sandpack into
// jsdom) but still captures props so parity tests can inspect them.
const conferenceRoomChatFlag = vi.hoisted(() => ({ enabled: true }));
vi.mock("../hooks/useConferenceRoomChatEnabled", () => ({
useConferenceRoomChatEnabled: () => ({ enabled: conferenceRoomChatFlag.enabled, loaded: true }),
}));
const mockIssueChatThreadClassicRender = vi.hoisted(() => vi.fn());
vi.mock("../components/IssueChatThreadClassic", () => ({
IssueChatThreadClassic: (props: Record<string, unknown>) => {
mockIssueChatThreadClassicRender(props);
return <div data-testid="issue-chat-thread-classic">Classic chat thread</div>;
},
}));
vi.mock("../components/IssueDocumentsSection", () => ({
IssueDocumentsSection: () => <div>Documents</div>,
}));
@@ -941,8 +959,10 @@ describe("IssueDetail", () => {
enableExperimentalFileViewer: false,
});
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
conferenceRoomChatFlag.enabled = true;
mockIssuesListRender.mockClear();
mockIssueChatThreadRender.mockClear();
mockIssueChatThreadClassicRender.mockClear();
mockImageGalleryRender.mockClear();
mockIssueWorkspaceCardRender.mockClear();
});
@@ -1614,7 +1634,107 @@ describe("IssueDetail", () => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
issueWorkMode: "planning",
});
expect(container.textContent).toContain("Plan mode");
});
// PAP-140 flag-off parity: with the Conference Room Chat flag off, the task
// thread surfaces must render master's behavior (classic fork, master copy,
// master mention set).
it("renders the frozen classic thread fork when the Conference Room Chat flag is off", async () => {
conferenceRoomChatFlag.enabled = false;
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.querySelector('[data-testid="issue-chat-thread-classic"]')).not.toBeNull();
expect(container.querySelector('[data-testid="issue-chat-thread"]')).toBeNull();
expect(mockIssueChatThreadRender).not.toHaveBeenCalled();
});
it("falls back to master's Planning chip copy when the flag is off", async () => {
conferenceRoomChatFlag.enabled = false;
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Planning");
expect(container.textContent).not.toContain("Plan mode");
});
it("passes @task mention options to the thread only when the flag is on", async () => {
const mentionPoolIssue = {
...createIssue(),
id: "issue-mention-1",
identifier: "PAP-9",
title: "Mentionable task",
};
mockIssuesApi.list.mockImplementation(
(_companyId: string, filters?: { sortField?: string }) =>
Promise.resolve(filters?.sortField === "updated" ? [mentionPoolIssue] : []),
);
mockIssuesApi.get.mockResolvedValue(createIssue());
// Flag ON: the mention pool query runs and issue options reach the thread.
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].mentions).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "issue", issueIdentifier: "PAP-9" })]),
);
});
// Flag OFF: no mention-pool query, no issue options — master's mention set.
conferenceRoomChatFlag.enabled = false;
await act(async () => {
root.unmount();
});
container.remove();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient.clear();
mockIssuesApi.list.mockClear();
mockIssueChatThreadClassicRender.mockClear();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const classicMentions = mockIssueChatThreadClassicRender.mock.calls.at(-1)?.[0]
.mentions as Array<{ kind?: string }> | undefined;
expect(classicMentions?.some((option) => option.kind === "issue")).toBe(false);
expect(mockIssuesApi.list).not.toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ sortField: "updated" }),
);
});
it("forwards composer work mode changes to the issues API", async () => {
+24 -4
View File
@@ -68,6 +68,8 @@ import {
type IssueChatComposerHandle,
type IssueChatRunFinalizationAction,
} from "../components/IssueChatThread";
import { IssueChatThreadClassic } from "../components/IssueChatThreadClassic";
import { useConferenceRoomChatEnabled } from "../hooks/useConferenceRoomChatEnabled";
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
@@ -800,6 +802,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onResumeFromBacklog,
resumeFromBacklogPending,
}: IssueDetailChatTabProps) {
// Conference Room Chat experimental flag (PAP-136/PAP-139): ON renders the
// NUX thread (bubbles, metadata rows, composer chrome); OFF renders the
// frozen master fork so the task thread looks exactly like master.
const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled();
const ThreadComponent = conferenceRoomChatEnabled ? IssueChatThread : IssueChatThreadClassic;
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
queryFn: () => activityApi.forIssue(issueId),
@@ -950,7 +957,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
</Button>
</div>
) : null}
<IssueChatThread
<ThreadComponent
composerRef={composerRef}
comments={commentsWithRunMeta}
interactions={interactions}
@@ -1478,6 +1485,18 @@ export function IssueDetail() {
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
// Bounded pool of recently-updated issues to back the `@task` reference picker
// (PAP-95f). The picker filters this list client-side by identifier/title.
// Gated on the Conference Room Chat flag (PAP-139): flag off keeps master's
// mention set (no task options, no extra query).
const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled();
const { data: mentionIssues = [] } = useQuery({
queryKey: resolvedCompanyId ? queryKeys.issues.mentionPool(resolvedCompanyId) : ["issues", "mention-pool", "pending"],
queryFn: () => issuesApi.list(resolvedCompanyId!, { limit: 100, sortField: "updated", sortDir: "desc" }),
enabled: !!resolvedCompanyId && conferenceRoomChatEnabled,
staleTime: 60_000,
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(resolvedCompanyId ?? "pending"),
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
@@ -1610,8 +1629,9 @@ export function IssueDetail() {
agents,
projects: orderedProjects,
members: companyMembers?.users,
issues: conferenceRoomChatEnabled ? mentionIssues : undefined,
});
}, [agents, companyMembers?.users, orderedProjects]);
}, [agents, companyMembers?.users, orderedProjects, mentionIssues, conferenceRoomChatEnabled]);
const resolvedProject = useMemo(
() => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null),
@@ -3642,9 +3662,9 @@ export function IssueDetail() {
{issue.workMode === "planning" ? (
<span
className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
title="This task is in planning mode."
title={conferenceRoomChatEnabled ? "This task is in plan mode." : "This task is in planning mode."}
>
Planning
{conferenceRoomChatEnabled ? "Plan mode" : "Planning"}
</span>
) : null}
+2 -2
View File
@@ -7,7 +7,7 @@ import { userProfilesApi } from "../api/userProfiles";
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { StatusBadge } from "../components/StatusBadge";
import { IssueStatusBadge } from "../components/StatusBadge";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
@@ -315,7 +315,7 @@ export function UserProfile() {
<span className="font-mono text-xs text-muted-foreground">{issue.identifier ?? issue.id.slice(0, 8)}</span>
<span className="truncate text-sm">{issue.title}</span>
<span className="flex items-center gap-3 sm:justify-end">
<StatusBadge status={issue.status} />
<IssueStatusBadge status={issue.status} />
<span className="text-xs tabular-nums text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
</span>
</Link>