[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:
parent
b4a7efa8d2
commit
841742fc1a
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, " ")}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue