Files
PaperClipAI/tests/e2e/planning-mode-visual-verification.spec.ts
DottaandPaperclip 59015846ae fix(chat): keep dismissed task questions in the feed (#15229)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents ask users questions in task chats and Agent Chat.
> - Agent Chat keeps unanswered questions as compact entries in the
feed.
> - Regular task chats still show a pending composer badge after
dismissal.
> - A page reload can also open a dismissed question form again.
> - This pull request applies the same feed behavior to both chat types
and saves dismissal per person and task.
> - Users can continue the chat and return to the original question
later.

## Linked Issues or Issue Description

**What happened?**

Dismissing a question in a regular task chat leaves a pending composer
badge. The question can return after a page reload. The earlier feed
behavior only applied to Agent Chat.

**Expected behavior**

A dismissed question stays in the feed. Its form and pending badge leave
the composer. Reload preserves dismissal. Opening the feed entry
restores the original question and draft answer.

**Steps to reproduce**

1. Open a regular task chat with a pending question.
2. Select an option, then dismiss the form.
3. Reload the page. Check that the composer stays clear.
4. Open the question in the feed. Check that the draft is restored.
5. Submit the answer. Check that the answered receipt appears.

**Paperclip version or commit**

Reproduced on master at `a386a599983519eb1d399f8b770bfccdb2a74762`.

**Deployment mode**

Browser UI in local and authenticated instances. This change does not
depend on the agent adapter.

Related work: Refs #14613, which added the Agent Chat feed behavior.
Refs #9141, which validates real answers on the server. Refs #11434,
which tracks comment-driven changes to interaction state. This PR
changes question presentation only.

## What Changed

- Show compact unanswered question entries in regular task chats.
- Exclude durable questions from composer pending counts and navigation.
- Save dismissal in local storage per person and task. Merge the latest
saved IDs so dismissals from another tab survive reload. A new question
can still open its form.
- Keep the original question pending and answerable. Keep approval and
permission controls.
- Run the question-history regressions in both chat modes. Add reload,
new-question, user/task scope, and stale-tab coverage.
- Share the real-component Storybook fixture. Add a regular task test
drive and an interactive dismissal/answer scenario.
- Update the planning-mode browser test to check a dismissed question in
the feed after reload and on mobile.
- Update the design rules, implementation spec, and preview
instructions.

## Verification

- 329 focused thread, composer, and interaction-card tests pass.
- `pnpm -r typecheck` passes. The UI typecheck also passes after the
review fix.
- `pnpm build` passes for the full repository.
- UI build, Storybook build, and token gates pass. The UI build and
token gates were rerun after the review fix.
- Greptile gives final commit `98f71f221` a 5/5 score. The current-head
check passes, and there are no unresolved review threads.
- All 56 current-head checks are terminal: 54 pass and two conditional
Storybook jobs skip. The CI run includes the full test shards, build,
typecheck, browser tests, aggregate verification gate, and package
canary.
- The stale-tab regression fails in both chat modes before the review
fix and passes after it.
- `pnpm exec playwright test --config tests/e2e/playwright.config.ts
tests/e2e/planning-mode-visual-verification.spec.ts` passes against a
throwaway local server. It checks dismissal, reload, task navigation,
and desktop/mobile planning controls.
- The duplicate local `pnpm test:run` attempt was stopped after the full
CI test suites passed. It did not complete locally.
- Manual browser test: select Green, dismiss, reload, reopen, submit the
saved answer, and inspect the answered receipt. Also send a new message
while the unanswered question remains in the feed. The test drive uses
real UI components with fixture response callbacks.
- To repeat the browser test, run `pnpm storybook`. Open **Chat &
Comments → Task Chat Unanswered Questions → Test Drive**.

## Risks

- Dismissal is a browser-local preference. It does not sync to another
browser or device. Clearing local storage removes it.
- When local storage is unavailable, dismissal lasts for the mounted
thread only.
- Unanswered questions can accumulate in the feed. They stay pending
until answered or resolved through the existing API.
- No database migration, API change, or change to approval permissions.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, with xhigh reasoning, repository editing,
code execution, and browser control. The context-window size is not
exposed in this session.

## 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>
2026-10-05 10:55:38 -05:00

204 lines
8.4 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { mockOnboardingLocalAiConnection } from "./helpers/onboarding-ai-connection";
import {
expectLandsOnFirstTaskWithoutDashboardBounce,
instrumentNavLog,
} from "./helpers/onboarding-landing";
/** The name the CEO role fills in — see AGENT_ROLE_LABELS. */
const AGENT_NAME = "CEO";
const TASK_TITLE = "Paperclip onboarding";
/** Wait for the saved question before checking its dismissed presentation. */
async function expectOpeningQuestionDismissed(page: import("@playwright/test").Page) {
await expect(page.getByTestId("task-chat-unanswered-question")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("task-chat-composer-takeover")).toHaveCount(0);
await expect(page.getByTestId("task-chat-pending-input-indicator")).toHaveCount(0);
await expect(page.getByTestId("task-chat-composer-mode")).toBeVisible({ timeout: 30_000 });
}
/** The opening question arrives after the interactions fetch. Dismiss it once. */
async function dismissOpeningCard(page: import("@playwright/test").Page) {
const takeover = page.getByTestId("task-chat-composer-takeover");
await expect(takeover).toBeVisible({ timeout: 30_000 });
await takeover.getByRole("button", { name: "Cancel", exact: true }).click();
await expectOpeningQuestionDismissed(page);
}
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";
await instrumentNavLog(page);
await mockOnboardingLocalAiConnection(page);
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(),
});
});
await page.goto("/onboarding");
const startBtn = page.getByRole("button", { name: /Start Onboarding|New Organization|Add Agent/ });
if (await startBtn.count()) await startBtn.first().click();
const createCard = page.getByRole("button", { name: /Build a new organization/ });
if (await createCard.count()) await createCard.first().click();
await expect(page.getByRole("heading", { name: "What is the name of your organization?" })).toBeVisible({ timeout: 15_000 });
await page.locator('input[placeholder="e.g. Northwind Labs"]').fill(companyName);
await page.getByRole("button", { name: /^Continue/ }).click();
// Naming the company creates it and goes straight to the agent step.
// The agent step asks for a name and nothing else; the name is what gates
// "Next", and the hire is filed under the neutral `general` role.
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
await page.locator("#onboarding-agent-name").fill(AGENT_NAME);
await page.getByRole("button", { name: /^Next$/ }).click();
// The connect step arrives with no source selected — the tile row is a
// question, not a confirmation — so its CTA stays disabled until one is
// pressed. It reads "Connect", not "Next": the button starts the sign-in
// where there is one to start. The test simulates successful local account
// connection, then exercises the real first-task creation flow.
//
// Waited on for enabled rather than visible: it is already on screen, and
// clicking a disabled button raises nothing and does nothing.
const source = page.getByRole("radio").first();
await source.waitFor({ timeout: 30_000 });
await source.click();
const connectNext = page.getByRole("button", { name: /^Connect$/ });
await expect(connectNext).toBeEnabled({ timeout: 30_000 });
await connectNext.click();
// The review step names the agent rather than the step.
await expect(
page.getByRole("heading", { name: "Let's get started..." }),
).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Get started/ }).click();
// The wizard now drops the user straight onto the first task's detail page,
// and must not bounce through the dashboard (PAP-404).
await expectLandsOnFirstTaskWithoutDashboardBounce(page);
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();
const company = companies.find((c: { name: string }) => c.name === companyName);
expect(company).toBeTruthy();
const issueRes = await page.request.get(`${baseOrigin}/api/companies/${company.id}/issues`);
expect(issueRes.ok()).toBe(true);
const issues = await issueRes.json();
const planningSeedIssue = issues.find(
(candidate: { id: string; identifier?: string; title: string }) =>
candidate.title === TASK_TITLE,
);
expect(planningSeedIssue).toBeTruthy();
const issue = planningSeedIssue;
const issueIdentifier = issue.identifier ?? issue.id;
const issuePath = `/${company.issuePrefix ?? company.id}/issues/${issueIdentifier}`;
const companyPrefix = company.issuePrefix ?? company.id;
const issueLinkSelector = `a[href$="/issues/${issueIdentifier}"]`;
const setMode = async (mode: "standard" | "planning") => {
const patchRes = await page.request.patch(`${baseOrigin}/api/issues/${issue.id}`, {
data: { workMode: mode },
});
expect(patchRes.ok()).toBe(true);
await expect
.poll(async () => {
const currentRes = await page.request.get(`${baseOrigin}/api/issues/${issue.id}`);
expect(currentRes.ok()).toBe(true);
const current = await currentRes.json();
return current.workMode;
}, { timeout: 10_000 })
.toBe(mode);
};
await setMode("planning");
await page.goto(issuePath);
await dismissOpeningCard(page);
await page.reload();
await expectOpeningQuestionDismissed(page);
await expect(page.getByText("Plan mode").first()).toBeVisible();
const desktopPlanningToggle = page.getByTestId("task-chat-composer-mode");
await expect(desktopPlanningToggle).toBeVisible();
await expect(desktopPlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
await page.screenshot({
path: `${screenshotDir}/desktop-planning-detail-${timestamp}.png`,
fullPage: true,
});
await page.goto(`/${companyPrefix}/issues`);
await expect(page.locator(issueLinkSelector)).toBeVisible();
await expect(page.locator(issueLinkSelector)).not.toContainText("Plan mode");
await page.screenshot({
path: `${screenshotDir}/desktop-planning-row-${timestamp}.png`,
fullPage: true,
});
await page.goto(issuePath);
await expectOpeningQuestionDismissed(page);
await page.getByTestId("task-chat-composer-mode").click();
await expect(page.getByTestId("task-chat-composer-mode")).toHaveCount(0);
await expect(page.getByTestId("task-chat-composer-add")).toBeVisible();
await page.screenshot({
path: `${screenshotDir}/desktop-standard-toggle-${timestamp}.png`,
fullPage: true,
});
await setMode("planning");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(issuePath);
await expectOpeningQuestionDismissed(page);
await expect(page.getByText("Plan mode").first()).toBeVisible();
const mobilePlanningToggle = page.getByTestId("task-chat-composer-mode");
await expect(mobilePlanningToggle).toBeVisible();
await expect(mobilePlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
await page.screenshot({
path: `${screenshotDir}/mobile-planning-detail-${timestamp}.png`,
fullPage: true,
});
await page.goto(`/${companyPrefix}/issues`);
await expect(page.locator(issueLinkSelector)).toBeVisible();
await expect(page.locator(issueLinkSelector)).not.toContainText("Plan mode");
await page.screenshot({
path: `${screenshotDir}/mobile-planning-row-${timestamp}.png`,
fullPage: true,
});
});