[codex] Graduate experimental conference room defaults (#8628)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI has been graduating experimental conference-room task
experiences into the default issue and onboarding flows
> - Several task UI improvements were still coupled to the Conference
Room Chat experimental flag even though they are useful outside chat
itself
> - That coupling meant disabling chat also reverted unrelated defaults
such as work-mode labels, task status colors, team creation copy, and
the graduated issue thread
> - This pull request keeps chat-specific gating scoped to chat while
making the graduated task UI the default experience
> - The benefit is that operators can use the newer task workflows
without needing to enable the separate chat experiment

## Linked Issues or Issue Description

No public GitHub issue exists for this exact change.

### Problem or motivation

The Conference Room Chat experimental flag was controlling unrelated
task UI defaults, which made non-chat workflows regress when chat was
disabled.

### Proposed solution

Remove that flag from task-thread, work-mode, onboarding, status, and
team-creation presentation paths while leaving chat-specific behavior
separately gated.

### Alternatives considered

Keeping the flag as a broad umbrella until chat graduates would avoid a
behavior change, but it keeps unrelated UI improvements hidden behind
the wrong capability switch.

### Roadmap alignment

Checked `ROADMAP.md`; this is focused graduation/polish for existing UI
surfaces rather than a new roadmap-level core feature.

Related search:

- Searched open PRs and issues for `conference room chat experimental
flag`: no matches.
- Searched open PRs and issues for `graduated issue thread`: no matches.

## What Changed

- Removes Conference Room Chat flag branching from task-thread
rendering, work-mode labels, task status colors, and team creation copy.
- Makes the onboarding completion path create/reuse an onboarding
project, create the first assigned task, and send the user to the
dashboard instead of chat.
- Deletes the legacy onboarding wizard and classic task-thread files now
that the graduated flow is the default.
- Updates focused UI tests and affected E2E specs for the default task
experience.
- Adds a user-visible onboarding error if restored state is missing the
company or agent required for launch.

## Verification

- `pnpm run preflight:workspace-links && pnpm exec vitest run
ui/src/components/NewIssueDialog.test.tsx
ui/src/components/OnboardingWizardVariant.test.tsx
ui/src/components/RunChatSurface.test.tsx
ui/src/components/SidebarCompanyMenu.test.tsx
ui/src/components/StatusBadge.test.tsx ui/src/lib/agent-order.test.ts
ui/src/lib/onboarding-launch.test.ts ui/src/lib/work-mode-meta.test.ts
ui/src/pages/IssueDetail.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `npx playwright test --config tests/e2e/playwright.config.ts --list
tests/e2e/conference-room-typing-intro.spec.ts
tests/e2e/planning-mode-visual-verification.spec.ts`
- Attempted targeted Playwright execution locally, but this host is
missing Chromium system libraries (`libatk1.0-0t64`, `libatspi2.0-0t64`,
`libxcomposite1`, `libxdamage1`, `libxfixes3`, `libxrandr2`, `libgbm1`,
`libasound2t64`). CI runs the specs in the proper Actions environment.

## Risks

- Medium UI behavior risk: this intentionally changes the default
experience for users who have not enabled Conference Room Chat.
- Medium onboarding risk: completion now creates/reuses a project and
creates the first task instead of only navigating.
- Low migration risk: no database schema or migration changes are
included.
- The PR avoids `pnpm-lock.yaml` and `.github/workflows` changes.

## Model Used

OpenAI Codex, GPT-5-class coding model, tool-enabled local repository
workflow with shell, git, and test execution.

## 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
- [x] All Paperclip CI gates are green
- [x] 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: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-06-25 11:52:36 -05:00 committed by GitHub
parent b4a7efa8d2
commit 841742fc1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 333 additions and 8327 deletions

View File

@ -1,35 +1,22 @@
import { test, expect } from "@playwright/test";
/**
* E2E: post-wizard Conference Room typing intro (PAP-134, plan PAP-133).
* E2E: post-wizard onboarding launch.
*
* Completing the onboarding wizard must land the user in the Conference Room
* with a staged intro: three-dot typing bubble first (~2s), then the CEO
* welcome message, then the suggestion chips. This is the regression spec
* from the PAP-133 investigation, checked in so the intro can't silently
* vanish again (it already did once PAP-54 dropped the dots CSS during a
* theme migration).
*
* The wizard is driven end-to-end against real endpoints with two
* deterministic intercepts so no live LLM/CLI is needed:
* - the adapter env-test returns an instant pass, and
* - the team-lead hire is re-issued server-side as a REAL hire with an
* inert `http` adapter (dead URL, heartbeat disabled), so a real CEO
* agent exists for the welcome bubble but no agent process ever runs.
* Completing the onboarding wizard now creates the first assigned task and
* lands the user on the company dashboard. The chat intro still has unit
* coverage in BoardChat tests; the wizard handoff no longer routes there.
*/
const COMPANY_NAME = `E2E-TypingIntro-${Date.now()}`;
const MISSION = "Verify the typing-dots intro survives the wizard handoff.";
const MISSION = "Verify the dashboard launch survives the wizard handoff.";
const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan";
test.describe("Conference Room typing intro after onboarding wizard", () => {
test("shows typing dots first, then welcome, then chips", async ({
test.describe("Dashboard launch after onboarding wizard", () => {
test("creates the first task and opens the dashboard", async ({
page,
baseURL,
}) => {
// The dots animation is intentionally disabled under reduced motion —
// pin the e2e run to full motion so the animation guard is deterministic.
await page.emulateMedia({ reducedMotion: "no-preference" });
// Intercept env-test → instant pass (avoid running a real CLI check).
await page.route("**/test-environment", (route) =>
route.fulfill({
@ -65,13 +52,6 @@ test.describe("Conference Room typing intro after onboarding wizard", () => {
});
});
// New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
// experimental flag on for this throwaway instance before driving them.
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: true },
});
expect(flagRes.ok()).toBe(true);
await page.goto("/onboarding");
// Launcher card path (existing companies) — enter the wizard if the
@ -102,41 +82,24 @@ test.describe("Conference Room typing intro after onboarding wizard", () => {
// Step 4: adapter (claude_local default); heartbeat is intercepted.
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
// Step 5: review → Get started hands off to the Conference Room.
// Step 5: review → Get started creates the first task and opens dashboard.
const getStarted = page.getByRole("button", { name: /Get started/ });
await getStarted.waitFor({ timeout: 20_000 });
await getStarted.click();
// Dots-first: the typing bubble must be on screen before the welcome.
const dots = page.locator(".typing-dots");
await expect(dots).toBeVisible({ timeout: 5_000 });
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
// Atomic snapshot — dots and welcome state read in one evaluation so a
// slow assertion can't race the 2s reveal timer.
const snapshot = await page.evaluate(() => ({
dots: document.querySelectorAll(".typing-dots").length,
welcomeVisible: document.body.textContent?.includes("Welcome to") ?? false,
}));
expect(snapshot.dots).toBeGreaterThan(0);
expect(snapshot.welcomeVisible).toBe(false);
const companiesRes = await page.request.get("/api/companies");
expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json();
const company = companies.find((candidate: { name: string }) => candidate.name === COMPANY_NAME);
expect(company).toBeTruthy();
// Animation-presence guard (PAP-54 failure mode): the dots must carry a
// real computed animation, not silently render as static circles after
// the CSS block gets dropped in a refactor.
const animationName = await dots
.locator("span")
.first()
.evaluate((el) => getComputedStyle(el).animationName);
expect(animationName).not.toBe("none");
expect(animationName).toBeTruthy();
// Staged reveal completes: welcome bubble (~2s) then chips (~+700ms).
await expect(page.getByText(/Welcome to/).first()).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByRole("button", { name: "Draft a Company Brief" }),
).toBeVisible({ timeout: 5_000 });
await expect(dots).toHaveCount(0);
const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
expect(issuesRes.ok()).toBe(true);
const issues = await issuesRes.json();
const firstTask = issues.find((candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE);
expect(firstTask).toBeTruthy();
await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
});
});

View File

@ -1,80 +1,74 @@
import { expect, test } from "@playwright/test";
const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false";
const AGENT_NAME = "CEO";
const TASK_TITLE = "PAP-3413 planning mode evidence";
const AGENT_NAME = "Chief of staff";
const TASK_TITLE = "Hire your first engineer and create a hiring plan";
test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const timestamp = Date.now();
const companyName = `PAP-3413-${timestamp}`;
const screenshotDir = "test-results/planning-mode";
// This spec captures the CLASSIC (flag-off) wizard + composer; pin the
// experimental flag off in case an earlier spec on this shared instance
// turned it on (the NUX specs do).
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: false },
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ status: "pass", checks: [] }),
}),
);
await page.route("**/agent-hires", async (route) => {
const req = route.request();
const body = JSON.parse(req.postData() || "{}");
const auth = req.headers().authorization;
const real = await fetch(new URL(req.url()).toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(auth ? { Authorization: auth } : {}),
},
body: JSON.stringify({
name: body.name,
role: body.role,
adapterType: "http",
adapterConfig: { url: "http://127.0.0.1:1/dead" },
runtimeConfig: { heartbeat: { enabled: false } },
}),
});
await route.fulfill({
status: real.status,
contentType: "application/json",
body: await real.text(),
});
});
expect(flagRes.ok()).toBe(true);
await page.goto("/onboarding");
await expect(page.locator("h3", { hasText: "Name your company" })).toBeVisible({ timeout: 5_000 });
const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ });
if (await startBtn.count()) await startBtn.first().click();
const createCard = page.getByRole("button", { name: /Build a new team/ });
if (await createCard.count()) await createCard.first().click();
await expect(page.getByRole("heading", { name: "Name your team" })).toBeVisible({ timeout: 15_000 });
await page.locator('input[placeholder="Acme Corp"]').fill(companyName);
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("button", { name: /^Next/ }).click();
await expect(page.locator("h3", { hasText: "Create your first agent" })).toBeVisible({ timeout: 30_000 });
await expect(page.locator('input[placeholder="CEO"]')).toHaveValue(AGENT_NAME);
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByRole("heading", { name: "Define your mission" })).toBeVisible({ timeout: 30_000 });
await page
.getByPlaceholder("What is your team trying to achieve?")
.fill("Capture planning mode visual evidence for the graduated task UI.");
await page.getByRole("button", { name: /Confirm mission/ }).click();
await expect(page.locator("h3", { hasText: "Give it something to do" })).toBeVisible({ timeout: 30_000 });
const baseUrl = page.url().split("/").slice(0, 3).join("/");
await page.waitForSelector('input[placeholder="Chief of staff"]', { timeout: 30_000 });
await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME);
if (SKIP_LLM) {
const companiesAfterAgentRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesAfterAgentRes.ok()).toBe(true);
const companiesAfterAgent = await companiesAfterAgentRes.json();
const companyAfterAgent = companiesAfterAgent.find((c: { name: string }) => c.name === companyName);
expect(companyAfterAgent).toBeTruthy();
await page.getByRole("button", { name: /^Next/ }).click();
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
const agentsAfterCreateRes = await page.request.get(`${baseUrl}/api/companies/${companyAfterAgent.id}/agents`);
expect(agentsAfterCreateRes.ok()).toBe(true);
const agentsAfterCreate = await agentsAfterCreateRes.json();
const ceoAgentAfterCreate = agentsAfterCreate.find((a: { name: string }) => a.name === AGENT_NAME);
expect(ceoAgentAfterCreate).toBeTruthy();
await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Get started/ }).click();
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
const disableWakeRes = await page.request.patch(
`${baseUrl}/api/agents/${ceoAgentAfterCreate.id}?companyId=${encodeURIComponent(companyAfterAgent.id)}`,
{
data: {
runtimeConfig: {
heartbeat: {
enabled: false,
intervalSec: 300,
wakeOnDemand: false,
cooldownSec: 10,
maxConcurrentRuns: 5,
},
},
},
},
);
expect(disableWakeRes.ok()).toBe(true);
}
const taskTitleInput = page.locator('input[placeholder="e.g. Research competitor pricing"]');
await taskTitleInput.clear();
await taskTitleInput.fill(TASK_TITLE);
await page.getByRole("button", { name: "Next" }).click();
await expect(page.locator("h3", { hasText: "Ready to launch" })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Create & Open Task" }).click();
await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 });
const openedIssueUrl = page.url();
const openedIssueIdentifier = openedIssueUrl.split("/").filter(Boolean).pop();
const baseOrigin = new URL(openedIssueUrl).origin;
const baseOrigin = new URL(page.url()).origin;
const companyRes = await page.request.get(`${baseOrigin}/api/companies`);
expect(companyRes.ok()).toBe(true);
const companies = await companyRes.json();
@ -85,7 +79,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const issues = await issueRes.json();
const planningSeedIssue = issues.find(
(candidate: { id: string; identifier?: string; title: string }) =>
candidate.identifier === openedIssueIdentifier || candidate.id === openedIssueIdentifier || candidate.title === TASK_TITLE,
candidate.title === TASK_TITLE,
);
expect(planningSeedIssue).toBeTruthy();
@ -113,7 +107,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await setMode("planning");
await page.goto(issuePath);
await expect(page.getByText("Planning").first()).toBeVisible();
await expect(page.getByText("Plan mode").first()).toBeVisible();
await expect(page.getByTestId("issue-chat-composer")).toHaveAttribute("data-pending-work-mode", "planning");
const desktopPlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle");
await expect(desktopPlanningToggle).toBeVisible();
@ -127,7 +121,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await page.goto(`/${companyPrefix}/issues`);
await expect(page.locator(issueLinkSelector)).toBeVisible();
await expect(page.locator(issueLinkSelector)).not.toContainText("Planning");
await expect(page.locator(issueLinkSelector)).not.toContainText("Plan mode");
await page.screenshot({
path: `${screenshotDir}/desktop-planning-row-${timestamp}.png`,
fullPage: true,
@ -147,7 +141,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await setMode("planning");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(issuePath);
await expect(page.getByText("Planning").first()).toBeVisible();
await expect(page.getByText("Plan mode").first()).toBeVisible();
const mobilePlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle");
await expect(mobilePlanningToggle).toBeVisible();
await expect(mobilePlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
@ -159,7 +153,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await page.goto(`/${companyPrefix}/issues`);
await expect(page.locator(issueLinkSelector)).toBeVisible();
await expect(page.locator(issueLinkSelector)).not.toContainText("Planning");
await expect(page.locator(issueLinkSelector)).not.toContainText("Plan mode");
await page.screenshot({
path: `${screenshotDir}/mobile-planning-row-${timestamp}.png`,
fullPage: true,

View File

@ -3796,8 +3796,8 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
);
}
const workModeOptions = workModeMetaList(true);
const pendingWorkModeMeta = workModeMetaFor(pendingWorkMode, true);
const workModeOptions = workModeMetaList();
const pendingWorkModeMeta = workModeMetaFor(pendingWorkMode);
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
@ -3809,7 +3809,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
const isPeriod = evt.code === "Period" || evt.key === ".";
if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return;
evt.preventDefault();
setPendingWorkMode((current) => nextWorkMode(current, true));
setPendingWorkMode((current) => nextWorkMode(current));
}
return (
@ -3962,7 +3962,7 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
aria-expanded={workModeMenuOpen}
aria-pressed={pendingWorkMode !== "standard"}
aria-keyshortcuts="Meta+Period Control+Period"
title={titleForPendingWorkMode(pendingWorkMode, true)}
title={titleForPendingWorkMode(pendingWorkMode)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-semibold transition-colors",
pendingWorkModeMeta.classes.chip,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1134,9 +1134,7 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
// PAP-139/PAP-140: work-mode labels and status hues branch on the Conference
// Room Chat experimental flag — OFF (default) must match master exactly.
describe("Conference Room Chat flag parity (PAP-140)", () => {
describe("graduated work-mode labels and status hues", () => {
function workModeOption(value: string) {
return container.querySelector(`[data-issue-work-mode="${value}"]`);
}
@ -1153,29 +1151,7 @@ describe("NewIssueDialog", () => {
return button?.querySelector("svg")?.getAttribute("class") ?? "";
}
it("uses master's work-mode labels and status hues when the flag is off (default)", async () => {
const { root } = renderDialog(container);
await flush();
expect(workModeOption("standard")?.textContent).toContain("Standard");
expect(workModeOption("standard")?.textContent).not.toContain("Agent mode");
expect(workModeOption("ask")?.textContent).toContain("Ask");
expect(workModeOption("planning")?.textContent).toContain("Planning");
expect(workModeOption("planning")?.textContent).not.toContain("Plan mode");
// Master palette: todo → blue, in_progress → yellow.
expect(statusOptionIconClass("Todo", "Executable — assignee will be woken")).toContain("text-blue-600");
expect(statusOptionIconClass("In Progress")).toContain("text-yellow-600");
act(() => root.unmount());
});
it("uses NUX work-mode labels and brand status hues when the flag is on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableConferenceRoomChat: true,
enableIsolatedWorkspaces: false,
});
it("uses agent-mode labels and brand status hues by default", async () => {
const { root } = renderDialog(container);
await waitForAssertion(() => {
expect(workModeOption("standard")?.textContent).toContain("Agent mode");
@ -1185,7 +1161,6 @@ describe("NewIssueDialog", () => {
expect(workModeOption("ask")?.textContent).toContain("Ask mode");
expect(workModeOption("planning")?.textContent).toContain("Plan mode");
// PAP-75 brand palette: todo → amber, in_progress → blue.
expect(statusOptionIconClass("Todo", "Executable — assignee will be woken")).toContain("text-amber-600");
expect(statusOptionIconClass("In Progress")).toContain("text-blue-600");

View File

@ -65,8 +65,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { cn } from "../lib/utils";
import { extractProviderIdWithFallback } from "../lib/model-utils";
import { issueStatusText, issueStatusTextClassic, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
import { useConferenceRoomChatEnabled } from "../hooks/useConferenceRoomChatEnabled";
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { AgentIcon } from "./AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
@ -214,12 +213,8 @@ function formatFileSize(file: File) {
return `${(file.size / (1024 * 1024)).toFixed(1)} MB`;
}
// PAP-75 brand hues ship behind the Conference Room Chat flag (PAP-139); OFF
// keeps master's palette (`issueStatusTextClassic`).
function buildStatusOptions(
conferenceRoomChat: boolean,
): ReadonlyArray<{ value: string; label: string; color: string; description?: string }> {
const palette = conferenceRoomChat ? issueStatusText : issueStatusTextClassic;
function buildStatusOptions(): ReadonlyArray<{ value: string; label: string; color: string; description?: string }> {
const palette = issueStatusText;
return [
{
value: "backlog",
@ -405,10 +400,8 @@ function issueExecutionWorkspaceModeForExistingWorkspace(mode: string | null | u
export function NewIssueDialog() {
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
const { companies, selectedCompanyId, selectedCompany } = useCompany();
// Conference Room Chat flag (PAP-139): selects work-mode labels + status hues.
const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled();
const workModeOptions = useMemo(() => workModeMetaList(conferenceRoomChatEnabled), [conferenceRoomChatEnabled]);
const statuses = useMemo(() => buildStatusOptions(conferenceRoomChatEnabled), [conferenceRoomChatEnabled]);
const workModeOptions = useMemo(() => workModeMetaList(), []);
const statuses = useMemo(() => buildStatusOptions(), []);
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [title, setTitle] = useState("");
@ -1039,7 +1032,7 @@ export function NewIssueDialog() {
function handleKeyDown(e: React.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.code === "Period") {
e.preventDefault();
setWorkMode((current) => nextWorkMode(current, conferenceRoomChatEnabled));
setWorkMode((current) => nextWorkMode(current));
return;
}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
@ -1245,7 +1238,7 @@ export function NewIssueDialog() {
},
[assigneeAdapterModels],
);
const currentWorkMode = workModeMetaFor(workMode, conferenceRoomChatEnabled);
const currentWorkMode = workModeMetaFor(workMode);
const CurrentWorkModeIcon = currentWorkMode.icon;
return (

View File

@ -8,6 +8,8 @@ import { companiesApi } from "../api/companies";
import { goalsApi } from "../api/goals";
import { agentsApi } from "../api/agents";
import { approvalsApi } from "../api/approvals";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { queryKeys } from "../lib/queryKeys";
import { Dialog, DialogPortal } from "@/components/ui/dialog";
import {
@ -30,6 +32,12 @@ import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
import { composeCeoInstructions } from "../lib/ceo-instructions";
import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId,
selectReusableOnboardingProject,
} from "../lib/onboarding-launch";
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
@ -73,6 +81,14 @@ function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: s
}
const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
- hire a founding engineer
- write a hiring plan
- break the roadmap into concrete tasks and start delegating work`;
const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
"Onboarding state is incomplete. Please restart onboarding and try again.";
function loadSavedState(): Record<string, unknown> | null {
try {
@ -170,6 +186,15 @@ export function OnboardingWizard() {
string | null
>((saved?.createdCompanyPrefix as string) ?? null);
const [createdAgentId, setCreatedAgentId] = useState<string | null>((saved?.createdAgentId as string) ?? null);
const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState<string | null>(
(saved?.createdCompanyGoalId as string) ?? null
);
const [createdProjectId, setCreatedProjectId] = useState<string | null>(
(saved?.createdProjectId as string) ?? null
);
const [createdIssueRef, setCreatedIssueRef] = useState<string | null>(
(saved?.createdIssueRef as string) ?? null
);
// Reset the route-dismissed flag when navigating to a different path.
useEffect(() => {
@ -208,6 +233,7 @@ export function OnboardingWizard() {
step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
};
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(state));
@ -215,6 +241,7 @@ export function OnboardingWizard() {
effectiveOnboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed,
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
createdCompanyId, createdCompanyPrefix, createdAgentId,
createdCompanyGoalId, createdProjectId, createdIssueRef,
onboardingPath, growWorkflows, growPainPoints, growAutomate,
]);
@ -363,6 +390,9 @@ export function OnboardingWizard() {
setCreatedCompanyId(null);
setCreatedCompanyPrefix(null);
setCreatedAgentId(null);
setCreatedCompanyGoalId(null);
setCreatedProjectId(null);
setCreatedIssueRef(null);
}
function handleClose() {
@ -375,11 +405,67 @@ export function OnboardingWizard() {
setRouteDismissed(true);
}
function handleLaunchToChat() {
const prefix = createdCompanyPrefix;
reset();
closeOnboarding();
navigate(prefix ? `/${prefix}/board-chat` : "/dashboard");
async function handleLaunchToDashboard() {
if (!createdCompanyId || !createdAgentId) {
setError(INCOMPLETE_ONBOARDING_STATE_MESSAGE);
return;
}
setLoading(true);
setError(null);
try {
let goalId = createdCompanyGoalId;
if (!goalId) {
const goals = await goalsApi.list(createdCompanyId);
goalId = selectDefaultCompanyGoalId(goals);
setCreatedCompanyGoalId(goalId);
}
let projectId = createdProjectId;
if (!projectId) {
const projects = await projectsApi.list(createdCompanyId);
const existingOnboardingProject = selectReusableOnboardingProject(projects);
if (existingOnboardingProject) {
projectId = existingOnboardingProject.id;
} else {
const project = await projectsApi.create(
createdCompanyId,
buildOnboardingProjectPayload(goalId)
);
projectId = project.id;
queryClient.invalidateQueries({
queryKey: queryKeys.projects.list(createdCompanyId)
});
}
setCreatedProjectId(projectId);
}
if (!createdIssueRef) {
const issue = await issuesApi.create(
createdCompanyId,
buildOnboardingIssuePayload({
title: DEFAULT_TASK_TITLE,
description: DEFAULT_TASK_DESCRIPTION,
assigneeAgentId: createdAgentId,
projectId,
goalId
})
);
setCreatedIssueRef(issue.identifier ?? issue.id);
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(createdCompanyId)
});
}
const prefix = createdCompanyPrefix;
setSelectedCompanyId(createdCompanyId);
reset();
closeOnboarding();
navigate(prefix ? `/${prefix}/dashboard` : "/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to launch first task");
} finally {
setLoading(false);
}
}
function buildAdapterConfig(): Record<string, unknown> {
@ -467,7 +553,7 @@ export function OnboardingWizard() {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
const parsedGoal = parseOnboardingGoalInput(companyGoal);
await goalsApi.create(company.id, {
const goal = await goalsApi.create(company.id, {
title: parsedGoal.title,
...(parsedGoal.description
? { description: parsedGoal.description }
@ -475,6 +561,7 @@ export function OnboardingWizard() {
level: "company",
status: "active"
});
setCreatedCompanyGoalId(goal.id);
queryClient.invalidateQueries({
queryKey: queryKeys.goals.list(company.id)
});
@ -651,12 +738,15 @@ export function OnboardingWizard() {
else if (step === 2 && companyName.trim() && companyGoal.trim()) handleConfirmMission();
else if (step === 3 && agentName.trim()) setStep(4);
else if (step === 4 && agentName.trim()) handleGiveHeartbeat();
else if (step === 5) handleLaunchToChat();
else if (step === 5) handleLaunchToDashboard();
}
}
if (!effectiveOnboardingOpen) return null;
const launchStateIncomplete = step === 5 && (!createdCompanyId || !createdAgentId);
const visibleError = error ?? (launchStateIncomplete ? INCOMPLETE_ONBOARDING_STATE_MESSAGE : null);
return (
<Dialog
open={effectiveOnboardingOpen}
@ -1528,15 +1618,15 @@ export function OnboardingWizard() {
</p>
)}
<p className="text-xs text-muted-foreground text-center">
Start a conversation with {agentName} to discuss strategy and plan who to bring on.
We'll create the first task for {agentName} and take you to the dashboard.
</p>
</div>
)}
{/* Error */}
{error && (
{visibleError && (
<div className="mt-3">
<p className="text-xs text-destructive">{error}</p>
<p className="text-xs text-destructive">{visibleError}</p>
</div>
)}
@ -1608,9 +1698,17 @@ export function OnboardingWizard() {
</Button>
)}
{step === 5 && (
<Button size="sm" onClick={handleLaunchToChat}>
<ArrowRight className="h-3.5 w-3.5 mr-1" />
Get started
<Button
size="sm"
onClick={handleLaunchToDashboard}
disabled={loading || launchStateIncomplete}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Launching..." : "Get started"}
</Button>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@
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 { OnboardingWizardVariant } from "./OnboardingWizardVariant";
@ -14,41 +13,19 @@ vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
// The variant wrapper only cares about *which* wizard renders, so both heavy
// wizard components are stubbed out.
vi.mock("./OnboardingWizard", () => ({
OnboardingWizard: () => <div data-testid="wizard-capsule" />,
}));
vi.mock("./OnboardingWizardClassic", () => ({
OnboardingWizardClassic: () => <div data-testid="wizard-classic" />,
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
describe("OnboardingWizardVariant (PAP-138)", () => {
let container: HTMLDivElement;
let root: Root | null = null;
async function renderVariant() {
function renderVariant() {
root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<OnboardingWizardVariant />
</QueryClientProvider>,
);
root!.render(<OnboardingWizardVariant />);
});
await flushReact();
}
beforeEach(() => {
@ -65,35 +42,11 @@ describe("OnboardingWizardVariant (PAP-138)", () => {
vi.clearAllMocks();
});
it("renders the classic wizard when the flag is off (default)", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableConferenceRoomChat: false });
await renderVariant();
expect(container.querySelector('[data-testid="wizard-classic"]')).not.toBeNull();
expect(container.querySelector('[data-testid="wizard-capsule"]')).toBeNull();
});
it("renders the capsule wizard when the flag is on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableConferenceRoomChat: true });
await renderVariant();
it("renders the capsule wizard without reading the chat flag", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({});
renderVariant();
expect(container.querySelector('[data-testid="wizard-capsule"]')).not.toBeNull();
expect(container.querySelector('[data-testid="wizard-classic"]')).toBeNull();
});
it("renders the classic wizard when the settings payload omits the flag", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({});
await renderVariant();
expect(container.querySelector('[data-testid="wizard-classic"]')).not.toBeNull();
expect(container.querySelector('[data-testid="wizard-capsule"]')).toBeNull();
});
it("renders neither wizard while the flag is still loading (no cross-variant flash)", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
await renderVariant();
expect(container.querySelector('[data-testid="wizard-classic"]')).toBeNull();
expect(container.querySelector('[data-testid="wizard-capsule"]')).toBeNull();
expect(mockInstanceSettingsApi.getExperimental).not.toHaveBeenCalled();
});
});

View File

@ -1,20 +1,10 @@
import { useConferenceRoomChatEnabled } from "@/hooks/useConferenceRoomChatEnabled";
import { OnboardingWizard } from "./OnboardingWizard";
import { OnboardingWizardClassic } from "./OnboardingWizardClassic";
/**
* Variant selector for the onboarding wizard (PAP-136 / PAP-138, plan §3
* Tier B).
*
* Flag off (the default) renders `OnboardingWizardClassic` the
* fork-and-freeze of master's wizard so the experience stays
* pixel-identical to master. Flag on renders the new capsule wizard. While
* the flag query is still in flight nothing renders (same `loaded` pattern
* as `ConferenceRoomChatGate`) so a flag-on user never sees the classic
* wizard flash in first.
* Default onboarding wizard. Conference-room chat is now the only surface left
* behind `enableConferenceRoomChat`; onboarding stays available without that
* experimental flag.
*/
export function OnboardingWizardVariant() {
const { enabled, loaded } = useConferenceRoomChatEnabled();
if (!loaded) return null;
return enabled ? <OnboardingWizard /> : <OnboardingWizardClassic />;
return <OnboardingWizard />;
}

View File

@ -1,30 +1,15 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { LiveRunForIssue } from "../api/heartbeats";
import { RunChatSurface } from "./RunChatSurface";
/**
* PAP-139: RunChatSurface (embedded run chat in ActiveAgentsPanel /
* LiveRunWidget) selects the thread variant by the Conference Room Chat
* experimental flag NUX thread when ON, the frozen master fork when OFF.
*/
const conferenceRoomChatFlag = vi.hoisted(() => ({ enabled: true }));
vi.mock("../hooks/useConferenceRoomChatEnabled", () => ({
useConferenceRoomChatEnabled: () => ({ enabled: conferenceRoomChatFlag.enabled, loaded: true }),
}));
vi.mock("./IssueChatThread", () => ({
IssueChatThread: () => <div data-testid="nux-thread">NUX thread</div>,
}));
vi.mock("./IssueChatThreadClassic", () => ({
IssueChatThreadClassic: () => <div data-testid="classic-thread">Classic thread</div>,
}));
const run: LiveRunForIssue = {
id: "run-1",
status: "running",
@ -35,17 +20,21 @@ const run: LiveRunForIssue = {
finishedAt: null,
} as LiveRunForIssue;
function act(callback: () => void) {
flushSync(callback);
}
async function renderSurface() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
act(() => {
root.render(<RunChatSurface run={run} transcript={[]} hasOutput={false} />);
});
return {
container,
cleanup: async () => {
await act(async () => {
cleanup: () => {
act(() => {
root.unmount();
});
container.remove();
@ -54,23 +43,13 @@ async function renderSurface() {
}
afterEach(() => {
conferenceRoomChatFlag.enabled = true;
document.body.innerHTML = "";
});
describe("RunChatSurface thread variant selection (PAP-139)", () => {
it("renders the NUX thread when the Conference Room Chat flag is on", async () => {
describe("RunChatSurface thread presentation", () => {
it("renders the graduated issue thread without a chat-flag branch", async () => {
const { container, cleanup } = await renderSurface();
expect(container.querySelector('[data-testid="nux-thread"]')).not.toBeNull();
expect(container.querySelector('[data-testid="classic-thread"]')).toBeNull();
await cleanup();
});
it("renders the frozen master fork when the flag is off", async () => {
conferenceRoomChatFlag.enabled = false;
const { container, cleanup } = await renderSurface();
expect(container.querySelector('[data-testid="classic-thread"]')).not.toBeNull();
expect(container.querySelector('[data-testid="nux-thread"]')).toBeNull();
await cleanup();
});
});

View File

@ -2,8 +2,6 @@ import { memo, useMemo } from "react";
import type { TranscriptEntry } from "../adapters";
import type { LiveRunForIssue } from "../api/heartbeats";
import { IssueChatThread } from "./IssueChatThread";
import { IssueChatThreadClassic } from "./IssueChatThreadClassic";
import { useConferenceRoomChatEnabled } from "../hooks/useConferenceRoomChatEnabled";
import type { IssueChatLinkedRun } from "../lib/issue-chat-messages";
const EMPTY_COMMENTS: [] = [];
@ -50,13 +48,8 @@ export const RunChatSurface = memo(function RunChatSurface({
() => new Map([[run.id, transcript as readonly TranscriptEntry[]]]),
[run.id, transcript],
);
// Conference Room Chat experimental flag (PAP-136/PAP-139): OFF renders the
// frozen master fork so embedded run chat looks exactly like master.
const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled();
const ThreadComponent = conferenceRoomChatEnabled ? IssueChatThread : IssueChatThreadClassic;
return (
<ThreadComponent
<IssueChatThread
comments={EMPTY_COMMENTS}
linkedRuns={linkedRuns}
timelineEvents={EMPTY_TIMELINE_EVENTS}

View File

@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -24,14 +25,6 @@ const mockSidebarPreferencesApi = vi.hoisted(() => ({
updateCompanyOrder: vi.fn(),
}));
// Team-centric copy ("Create new team...") ships behind the Conference Room
// Chat experimental flag (PAP-139). This suite was written against the NUX
// copy, so the flag is seeded ON; one test flips it OFF for master's copy.
const conferenceRoomChatFlag = vi.hoisted(() => ({ enabled: true }));
vi.mock("@/hooks/useConferenceRoomChatEnabled", () => ({
useConferenceRoomChatEnabled: () => ({ enabled: conferenceRoomChatFlag.enabled, loaded: true }),
}));
vi.mock("@/api/auth", () => ({
authApi: mockAuthApi,
}));
@ -41,7 +34,7 @@ vi.mock("@/api/sidebarPreferences", () => ({
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => (
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
useLocation: () => mockLocation,
@ -106,11 +99,13 @@ vi.mock("../context/SidebarContext", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(callback);
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
describe("SidebarCompanyMenu", () => {
@ -143,17 +138,15 @@ describe("SidebarCompanyMenu", () => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
conferenceRoomChatFlag.enabled = true;
});
it("keeps master's 'Add company...' copy when the Conference Room Chat flag is off (PAP-139)", async () => {
conferenceRoomChatFlag.enabled = false;
it("uses team-centric create copy without the chat flag", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
@ -165,16 +158,16 @@ describe("SidebarCompanyMenu", () => {
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
expect(trigger).not.toBeNull();
await act(async () => {
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Add company...");
expect(document.body.textContent).not.toContain("Create new team...");
expect(document.body.textContent).toContain("Create new team...");
expect(document.body.textContent).not.toContain("Add company...");
await act(async () => {
act(() => {
root.unmount();
});
});
@ -185,7 +178,7 @@ describe("SidebarCompanyMenu", () => {
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
@ -200,7 +193,7 @@ describe("SidebarCompanyMenu", () => {
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
expect(trigger).not.toBeNull();
await act(async () => {
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@ -219,14 +212,14 @@ describe("SidebarCompanyMenu", () => {
.find((element) => element.textContent?.includes("Sign out"));
expect(signOutButton).toBeTruthy();
await act(async () => {
act(() => {
signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAuthApi.signOut).toHaveBeenCalledTimes(1);
await act(async () => {
act(() => {
root.unmount();
});
});
@ -237,7 +230,7 @@ describe("SidebarCompanyMenu", () => {
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
@ -250,7 +243,7 @@ describe("SidebarCompanyMenu", () => {
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
expect(trigger).not.toBeNull();
await act(async () => {
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@ -260,7 +253,7 @@ describe("SidebarCompanyMenu", () => {
.find((element) => element.textContent === "Edit");
expect(editButton).toBeTruthy();
await act(async () => {
act(() => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -274,7 +267,7 @@ describe("SidebarCompanyMenu", () => {
.find((element) => element.textContent?.includes("Strata"));
expect(strataItem).toBeTruthy();
await act(async () => {
act(() => {
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -282,7 +275,7 @@ describe("SidebarCompanyMenu", () => {
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
await act(async () => {
act(() => {
root.unmount();
});
});
@ -294,7 +287,7 @@ describe("SidebarCompanyMenu", () => {
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<SidebarCompanyMenu />
@ -307,7 +300,7 @@ describe("SidebarCompanyMenu", () => {
const trigger = container.querySelector('button[aria-label="Open Acme Labs workspace switcher"]');
expect(trigger).not.toBeNull();
await act(async () => {
act(() => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@ -317,7 +310,7 @@ describe("SidebarCompanyMenu", () => {
.find((element) => element.textContent?.includes("Strata"));
expect(strataItem).toBeTruthy();
await act(async () => {
act(() => {
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -325,7 +318,7 @@ describe("SidebarCompanyMenu", () => {
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-2");
expect(mockNavigate).toHaveBeenCalledWith("/STR/dashboard");
await act(async () => {
act(() => {
root.unmount();
});
});

View File

@ -33,7 +33,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCompany } from "@/context/CompanyContext";
import { useConferenceRoomChatEnabled } from "@/hooks/useConferenceRoomChatEnabled";
import { useDialogActions } from "@/context/DialogContext";
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
import { queryKeys } from "@/lib/queryKeys";
@ -134,9 +133,6 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
const [isEditingOrder, setIsEditingOrder] = useState(false);
const queryClient = useQueryClient();
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
// Team-centric copy (PAP-67) ships behind the Conference Room Chat flag
// (PAP-139); OFF keeps master's "Add company...".
const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled();
const { openOnboarding } = useDialogActions();
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
const rail = collapsed && !peeking;
@ -295,7 +291,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
disabled={isEditingOrder}
>
<Plus className="size-4" />
<span>{conferenceRoomChatEnabled ? "Create new team..." : "Add company..."}</span>
<span>Create new team...</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild disabled={isEditingOrder}>

View File

@ -1,21 +1,9 @@
// @vitest-environment node
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { AgentStatusBadge, IssueStatusBadge, StatusBadge } from "./StatusBadge";
import { agentStatusVar, statusBadgeClassic, taskStatusVar } from "../lib/status-colors";
// The generic StatusBadge (runs/goals/approvals) keeps the PAP-75 brand palette
// behind the Conference Room Chat flag (PAP-139). Seeded ON; the suite below
// flips it OFF. The task/agent status chips no longer depend on this flag.
const conferenceRoomChatFlag = vi.hoisted(() => ({ enabled: true }));
vi.mock("../hooks/useConferenceRoomChatEnabled", () => ({
useConferenceRoomChatEnabled: () => ({ enabled: conferenceRoomChatFlag.enabled, loaded: true }),
}));
afterEach(() => {
conferenceRoomChatFlag.enabled = true;
});
import { agentStatusVar, taskStatusVar } from "../lib/status-colors";
/**
* Issue/task status chips carry the unified glyph and are recolored from the
@ -53,8 +41,7 @@ describe("IssueStatusBadge", () => {
expect(renderToStaticMarkup(<IssueStatusBadge status="mystery" />)).toContain("var(--status-task-backlog)");
});
it("is independent of the Conference Room Chat flag", () => {
conferenceRoomChatFlag.enabled = false;
it("renders task chips without depending on the chat flag", () => {
const html = renderToStaticMarkup(<IssueStatusBadge status="todo" />);
expect(html).toContain("status-chip");
expect(html).toContain('viewBox="0 0 24 24"');
@ -77,18 +64,8 @@ describe("AgentStatusBadge", () => {
});
});
/** The generic badge still honors the PAP-139 Conference Room Chat palette. */
describe("StatusBadge — Conference Room Chat flag palettes (PAP-139)", () => {
it("keeps master's blue todo / yellow in_progress palette when the flag is OFF", () => {
conferenceRoomChatFlag.enabled = false;
expect(renderToStaticMarkup(<StatusBadge status="todo" />)).toContain("bg-blue-100");
expect(renderToStaticMarkup(<StatusBadge status="in_progress" />)).toContain("bg-yellow-100");
expect(renderToStaticMarkup(<StatusBadge status="in_progress" />)).toContain(
statusBadgeClassic.in_progress!.split(" ")[0],
);
});
it("uses the brand hues when the flag is ON", () => {
describe("StatusBadge", () => {
it("uses the graduated brand hues", () => {
expect(renderToStaticMarkup(<StatusBadge status="todo" />)).toContain("bg-amber-100");
expect(renderToStaticMarkup(<StatusBadge status="in_progress" />)).toContain("bg-blue-100");
});

View File

@ -2,7 +2,6 @@ import type { CSSProperties } from "react";
import { cn } from "../lib/utils";
import {
statusBadge,
statusBadgeClassic,
statusBadgeDefault,
agentStatusMotion,
agentStatusVar,
@ -10,7 +9,6 @@ import {
taskStatusVar,
taskStatusVarDefault,
} from "../lib/status-colors";
import { useConferenceRoomChatEnabled } from "../hooks/useConferenceRoomChatEnabled";
import { StatusGlyph } from "./StatusGlyph";
/** Inline `--sc` local var pointing a status helper at a base-hue CSS var. */
@ -25,18 +23,14 @@ function sentenceCaseStatus(status: string): string {
}
/**
* Generic status badge for runs / goals / approvals (not task status). Keeps
* the PAP-75 brand palette behind the Conference Room Chat flag (PAP-139); flag
* OFF keeps master's palette. Non-issue entries are identical in both records.
* Generic status badge for runs / goals / approvals (not task status).
*/
export function StatusBadge({ status }: { status: string }) {
const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled();
const palette = conferenceRoomChatEnabled ? statusBadge : statusBadgeClassic;
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap shrink-0",
palette[status] ?? statusBadgeDefault
statusBadge[status] ?? statusBadgeDefault
)}
>
{status.replace(/_/g, " ")}

View File

@ -8,7 +8,6 @@ import {
writeAgentOrder,
type AgentSidebarOrderOptions,
} from "../lib/agent-order";
import { useConferenceRoomChatEnabled } from "./useConferenceRoomChatEnabled";
type UseAgentOrderParams = {
agents: Agent[];
@ -34,12 +33,9 @@ function buildOrderIds(agents: Agent[], orderedIds: string[], options: AgentSide
}
export function useAgentOrder({ agents, companyId, userId }: UseAgentOrderParams) {
// Leadership-first sidebar ordering (PAP-52) ships behind the Conference
// Room Chat flag (PAP-139); OFF keeps master's alphabetical sibling order.
const { enabled: conferenceRoomChatEnabled } = useConferenceRoomChatEnabled();
const sortOptions = useMemo<AgentSidebarOrderOptions>(
() => ({ leadershipFirst: conferenceRoomChatEnabled }),
[conferenceRoomChatEnabled],
() => ({ leadershipFirst: true }),
[],
);
const storageKey = useMemo(() => {
if (!companyId) return null;

View File

@ -22,7 +22,7 @@ describe("sortAgentsByDefaultSidebarOrder", () => {
expect(sorted.map((a) => a.id)).toEqual(["ceo", "ada", "board"]);
});
it("keeps master's plain alphabetical order by default (Conference Room Chat flag off)", () => {
it("keeps plain alphabetical order unless leadership-first is requested", () => {
const agents = [
makeAgent({ id: "board", name: "Board", role: "general" }),
makeAgent({ id: "ceo", name: "CEO", role: "ceo" }),

View File

@ -93,9 +93,7 @@ export function writeAgentSortMode(storageKey: string, sortMode: AgentSidebarSor
// Leadership roles surface at the top of each sibling group so the company's
// lead (typically the freshly-hired CEO) is visible without scrolling the
// sidebar (PAP-52). Anything outside this list falls back to alphabetical.
// Opt-in via `leadershipFirst` — gated on the Conference Room Chat experimental
// flag (PAP-139); the default keeps master's plain alphabetical sibling order.
// sidebar. Anything outside this list falls back to alphabetical.
const ROLE_SORT_PRIORITY: Record<string, number> = {
ceo: 0,
cto: 1,

View File

@ -3,6 +3,7 @@ import {
buildOnboardingIssuePayload,
buildOnboardingProjectPayload,
selectDefaultCompanyGoalId,
selectReusableOnboardingProject,
} from "./onboarding-launch";
describe("selectDefaultCompanyGoalId", () => {
@ -82,6 +83,22 @@ describe("selectDefaultCompanyGoalId", () => {
});
describe("onboarding launch payloads", () => {
it("reuses a non-cancelled Onboarding project by name", () => {
expect(
selectReusableOnboardingProject([
{ id: "cancelled", name: "Onboarding", status: "cancelled" },
{ id: "active", name: " onboarding ", status: "in_progress" },
]),
).toEqual({ id: "active", name: " onboarding ", status: "in_progress" });
expect(
selectReusableOnboardingProject([
{ id: "cancelled", name: "Onboarding", status: "cancelled" },
{ id: "other", name: "Roadmap", status: "in_progress" },
]),
).toBeNull();
});
it("links the onboarding project and first issue to the selected goal", () => {
expect(buildOnboardingProjectPayload("goal-1")).toEqual({
name: "Onboarding",

View File

@ -1,4 +1,4 @@
import type { Goal } from "@paperclipai/shared";
import type { Goal, Project } from "@paperclipai/shared";
export const ONBOARDING_PROJECT_NAME = "Onboarding";
@ -32,6 +32,18 @@ export function buildOnboardingProjectPayload(goalId: string | null) {
};
}
export function selectReusableOnboardingProject<T extends Pick<Project, "name" | "status">>(
projects: T[],
): T | null {
return (
projects.find(
(project) =>
project.status !== "cancelled" &&
project.name.trim().toLowerCase() === ONBOARDING_PROJECT_NAME.toLowerCase(),
) ?? null
);
}
export function buildOnboardingIssuePayload(input: {
title: string;
description: string;

View File

@ -12,11 +12,9 @@
// PAP-75 brand mapping ("blue = liveness"): todo → amber (queued), in_progress
// → blue (live). See `issueStatusColor` below for the canonical chip palette.
//
// The brand mapping ships behind the "Conference Room Chat" experimental flag
// (PAP-136/PAP-139): each record below also has a `*Classic` variant pinning
// master's hues (todo → blue, in_progress → yellow). Consumers (StatusIcon,
// StatusBadge, NewIssueDialog) select the palette by flag; delete the Classic
// variants when the flag graduates or dies.
// The brand mapping is the default status palette. Chat-specific gating stays
// isolated to the Conference Room route/nav/API and does not control task
// status presentation.
/** StatusIcon circle: text + border classes */
export const issueStatusIcon: Record<string, string> = {
@ -29,13 +27,6 @@ export const issueStatusIcon: Record<string, string> = {
blocked: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
};
/** Master hues for StatusIcon (Conference Room Chat flag OFF). */
export const issueStatusIconClassic: Record<string, string> = {
...issueStatusIcon,
todo: "text-blue-600 border-blue-600 dark:text-blue-400 dark:border-blue-400",
in_progress: "text-yellow-600 border-yellow-600 dark:text-yellow-400 dark:border-yellow-400",
};
export const issueStatusIconDefault = "text-muted-foreground border-muted-foreground";
/** Text-only color for issue statuses (dropdowns, labels) */
@ -49,13 +40,6 @@ export const issueStatusText: Record<string, string> = {
blocked: "text-red-600 dark:text-red-400",
};
/** Master hues for text-only issue statuses (Conference Room Chat flag OFF). */
export const issueStatusTextClassic: Record<string, string> = {
...issueStatusText,
todo: "text-blue-600 dark:text-blue-400",
in_progress: "text-yellow-600 dark:text-yellow-400",
};
export const issueStatusTextDefault = "text-muted-foreground";
// ---------------------------------------------------------------------------
@ -104,13 +88,6 @@ export const statusBadge: Record<string, string> = {
cancelled: "bg-muted text-muted-foreground",
};
/** Master hues for StatusBadge issue entries (Conference Room Chat flag OFF). */
export const statusBadgeClassic: Record<string, string> = {
...statusBadge,
todo: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
in_progress: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300",
};
export const statusBadgeDefault = "bg-muted text-muted-foreground";
// ---------------------------------------------------------------------------

View File

@ -4,18 +4,18 @@ import { nextWorkMode, titleForPendingWorkMode, workModeMetaList } from "./work-
describe("work mode metadata", () => {
it("orders issue work modes as agent, planning, then ask", () => {
expect(workModeMetaList(false).map((mode) => mode.value)).toEqual(["standard", "planning", "ask"]);
expect(workModeMetaList(true).map((mode) => mode.shortLabel)).toEqual(["Agent", "Plan", "Ask"]);
expect(workModeMetaList().map((mode) => mode.value)).toEqual(["standard", "planning", "ask"]);
expect(workModeMetaList().map((mode) => mode.shortLabel)).toEqual(["Agent", "Plan", "Ask"]);
});
it("cycles issue work modes as agent, planning, ask, then agent", () => {
expect(nextWorkMode("standard", true)).toBe("planning");
expect(nextWorkMode("planning", true)).toBe("ask");
expect(nextWorkMode("ask", true)).toBe("standard");
expect(nextWorkMode("standard")).toBe("planning");
expect(nextWorkMode("planning")).toBe("ask");
expect(nextWorkMode("ask")).toBe("standard");
});
it("matches standard mode tooltip copy to the active surface", () => {
expect(titleForPendingWorkMode("standard", false)).toBe("Standard mode for this submission. Click to change.");
expect(titleForPendingWorkMode("standard", true)).toBe("Agent mode for this submission. Click to change.");
it("uses graduated tooltip copy", () => {
expect(titleForPendingWorkMode("standard")).toBe("Agent mode for this submission. Click to change.");
expect(titleForPendingWorkMode("planning")).toBe("Plan mode is on for this submission. Click to change.");
});
});

View File

@ -42,27 +42,27 @@ export function isIssueWorkMode(value: unknown): value is IssueWorkMode {
return value === "standard" || value === "ask" || value === "planning";
}
export function workModeMetaList(conferenceRoomChat: boolean): WorkModeMeta[] {
export function workModeMetaList(): WorkModeMeta[] {
return [
{
value: "standard",
label: conferenceRoomChat ? "Agent mode" : "Standard",
shortLabel: conferenceRoomChat ? "Agent" : "Standard",
label: "Agent mode",
shortLabel: "Agent",
icon: Hammer,
tone: "neutral",
classes: STANDARD_CLASSES,
},
{
value: "planning",
label: conferenceRoomChat ? "Plan mode" : "Planning",
shortLabel: conferenceRoomChat ? "Plan" : "Planning",
label: "Plan mode",
shortLabel: "Plan",
icon: ClipboardList,
tone: "planning",
classes: PLANNING_CLASSES,
},
{
value: "ask",
label: conferenceRoomChat ? "Ask mode" : "Ask",
label: "Ask mode",
shortLabel: "Ask",
icon: MessageCircleQuestion,
tone: "ask",
@ -71,23 +71,23 @@ export function workModeMetaList(conferenceRoomChat: boolean): WorkModeMeta[] {
];
}
export function workModeMetaFor(mode: IssueWorkMode, conferenceRoomChat: boolean): WorkModeMeta {
const modes = workModeMetaList(conferenceRoomChat);
export function workModeMetaFor(mode: IssueWorkMode): WorkModeMeta {
const modes = workModeMetaList();
return modes.find((meta) => meta.value === mode) ?? modes[0]!;
}
export function nextWorkMode(mode: IssueWorkMode, conferenceRoomChat: boolean): IssueWorkMode {
const modes = workModeMetaList(conferenceRoomChat);
export function nextWorkMode(mode: IssueWorkMode): IssueWorkMode {
const modes = workModeMetaList();
const index = modes.findIndex((meta) => meta.value === mode);
return modes[(index + 1) % modes.length]?.value ?? "standard";
}
export function titleForPendingWorkMode(mode: IssueWorkMode, conferenceRoomChat: boolean): string {
export function titleForPendingWorkMode(mode: IssueWorkMode): string {
if (mode === "ask") {
return "Ask mode for this submission. Click to change. The assignee will answer in this thread; no implementation work.";
}
if (mode === "planning") {
return `${conferenceRoomChat ? "Plan" : "Planning"} mode is on for this submission. Click to change.`;
return "Plan mode is on for this submission. Click to change.";
}
return `${conferenceRoomChat ? "Agent" : "Standard"} mode for this submission. Click to change.`;
return "Agent mode for this submission. Click to change.";
}

View File

@ -252,24 +252,6 @@ 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>,
}));
@ -960,10 +942,8 @@ describe("IssueDetail", () => {
enableExternalObjects: false,
});
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
conferenceRoomChatFlag.enabled = true;
mockIssuesListRender.mockClear();
mockIssueChatThreadRender.mockClear();
mockIssueChatThreadClassicRender.mockClear();
mockImageGalleryRender.mockClear();
mockIssueWorkspaceCardRender.mockClear();
});
@ -1717,11 +1697,7 @@ describe("IssueDetail", () => {
}
});
// 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;
it("renders the graduated task thread without the chat flag", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
@ -1733,13 +1709,11 @@ describe("IssueDetail", () => {
});
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();
expect(container.querySelector('[data-testid="issue-chat-thread"]')).not.toBeNull();
expect(mockIssueChatThreadRender).toHaveBeenCalled();
});
it("falls back to master's Planning chip copy when the flag is off", async () => {
conferenceRoomChatFlag.enabled = false;
it("uses graduated Plan mode chip copy", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
await act(async () => {
@ -1751,11 +1725,11 @@ describe("IssueDetail", () => {
});
await flushReact();
expect(container.textContent).toContain("Planning");
expect(container.textContent).not.toContain("Plan mode");
expect(container.textContent).toContain("Plan mode");
expect(container.textContent).not.toContain("Planning");
});
it("passes @task mention options to the thread only when the flag is on", async () => {
it("passes @task mention options to the thread by default", async () => {
const mentionPoolIssue = {
...createIssue(),
id: "issue-mention-1",
@ -1768,7 +1742,6 @@ describe("IssueDetail", () => {
);
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}>
@ -1784,34 +1757,7 @@ describe("IssueDetail", () => {
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(
expect(mockIssuesApi.list).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ sortField: "updated" }),
);

View File

@ -68,8 +68,6 @@ import {
type IssueChatComposerHandle,
type IssueChatRunFinalizationAction,
} from "../components/IssueChatThread";
import { IssueChatThreadClassic } from "../components/IssueChatThreadClassic";
import { useConferenceRoomChatEnabled } from "../hooks/useConferenceRoomChatEnabled";
import { workModeMetaFor } from "../lib/work-mode-meta";
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
@ -809,11 +807,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
resumeFromBacklogPending,
externalReferences,
}: 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 ThreadComponent = IssueChatThread;
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
queryFn: () => activityApi.forIssue(issueId),
@ -1500,15 +1494,12 @@ 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();
// Bounded pool of recently-updated issues to back the `@task` reference picker.
// The picker filters this list client-side by identifier/title.
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,
enabled: !!resolvedCompanyId,
staleTime: 60_000,
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(resolvedCompanyId ?? "pending"),
});
@ -1644,9 +1635,9 @@ export function IssueDetail() {
agents,
projects: orderedProjects,
members: companyMembers?.users,
issues: conferenceRoomChatEnabled ? mentionIssues : undefined,
issues: mentionIssues,
});
}, [agents, companyMembers?.users, orderedProjects, mentionIssues, conferenceRoomChatEnabled]);
}, [agents, companyMembers?.users, orderedProjects, mentionIssues]);
const resolvedProject = useMemo(
() => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null),
@ -3729,7 +3720,7 @@ export function IssueDetail() {
) : null}
{issue.workMode === "ask" || issue.workMode === "planning" ? (() => {
const workModeMeta = workModeMetaFor(issue.workMode, conferenceRoomChatEnabled);
const workModeMeta = workModeMetaFor(issue.workMode);
const WorkModeIcon = workModeMeta.icon;
return (
<span