Unverified 提交 7bed92f7 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Allow selecting problems (#1568)

Fixes #672 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Add selectable problem rows with Select all/Clear all and Fix N selected, and update tests to cover selection behavior. > > - **UI (Problems panel)**: > - Add checkbox selection for each problem row (`ProblemItem`) with row click-to-toggle, `data-testid="problem-row"`, and accessibility attributes. > - Introduce selection state in `_Problems` with auto-select-all on report load; provide Select all / Clear all controls. > - Change Fix button to operate on selected problems only, showing dynamic label `Fix N problem(s)` and disabled when none selected. > - Wire `RecheckButton` to clear selection before rechecking; minor hover style tweaks; add `Checkbox` component. > - **E2E Tests**: > - New test: selecting specific problems and fixing only selected; add snapshots for prompt content. > - Update manual edit tests (React/Vite, Next.js) to assert Fix button enabled/disabled and counts; remove old ARIA snapshots. > - Minor import addition for `Timeout` and related expectations. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8324e26f9d2d265e7e0d1f1b7538e2a8db40f674. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
上级 517ce513
import { test, testSkipIfWindows } from "./helpers/test_helper"; import { test, testSkipIfWindows, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
...@@ -83,6 +83,72 @@ export default App; ...@@ -83,6 +83,72 @@ export default App;
await po.snapshotMessages({ replaceDumpPath: true }); await po.snapshotMessages({ replaceDumpPath: true });
}); });
testSkipIfWindows(
"problems - select specific problems and fix",
async ({ po }) => {
await po.setUp();
await po.importApp(MINIMAL_APP);
// Create multiple TS errors in one file
const appPath = await po.getCurrentAppPath();
const badFilePath = path.join(appPath, "src", "bad-file.tsx");
fs.writeFileSync(
badFilePath,
`const App = () => <div>Minimal imported app</div>;
nonExistentFunction1();
nonExistentFunction2();
nonExistentFunction3();
export default App;
`,
);
await po.ensurePnpmInstall();
// Trigger creation of problems and open problems panel
// await po.sendPrompt("tc=create-ts-errors");
await po.selectPreviewMode("problems");
await po.clickRecheckProblems();
// Initially, all selected: button shows Fix X problems and Clear all is visible
const fixButton = po.page.getByTestId("fix-all-button");
await expect(fixButton).toBeVisible();
await expect(fixButton).toContainText(/Fix \d+ problems/);
// Click first two rows to toggle off (deselect)
const rows = po.page.getByTestId("problem-row");
const rowCount = await rows.count();
expect(rowCount).toBeGreaterThan(2);
await rows.nth(0).click();
await rows.nth(1).click();
// Button should update to reflect remaining selected
await expect(fixButton).toContainText(/Fix 1 problem/);
// Clear all should switch to Select all when none selected
// Deselect remaining rows
for (let i = 2; i < rowCount; i++) {
await rows.nth(i).click();
}
const selectButton = po.page.getByRole("button", {
name: /Select all/,
});
await expect(selectButton).toHaveText("Select all");
// Select all, then fix selected
await selectButton.click();
// Unselect the second row
await rows.nth(1).click();
await expect(fixButton).toContainText(/Fix 2 problems/);
await fixButton.click();
await po.waitForChatCompletion();
await po.snapshotServerDump("last-message");
await po.snapshotMessages({ replaceDumpPath: true });
},
);
testSkipIfWindows("problems - manual edit (react/vite)", async ({ po }) => { testSkipIfWindows("problems - manual edit (react/vite)", async ({ po }) => {
await po.setUp({ enableAutoFixProblems: true }); await po.setUp({ enableAutoFixProblems: true });
await po.sendPrompt("tc=1"); await po.sendPrompt("tc=1");
...@@ -101,13 +167,15 @@ export default App; ...@@ -101,13 +167,15 @@ export default App;
await po.clickTogglePreviewPanel(); await po.clickTogglePreviewPanel();
await po.selectPreviewMode("problems"); await po.selectPreviewMode("problems");
await po.clickRecheckProblems(); const fixButton = po.page.getByTestId("fix-all-button");
await po.snapshotProblemsPane(); await expect(fixButton).toBeEnabled({ timeout: Timeout.LONG });
await expect(fixButton).toContainText(/Fix 1 problem/);
fs.unlinkSync(badFilePath); fs.unlinkSync(badFilePath);
await po.clickRecheckProblems(); await po.clickRecheckProblems();
await po.snapshotProblemsPane(); await expect(fixButton).toBeDisabled({ timeout: Timeout.LONG });
await expect(fixButton).toContainText(/Fix 0 problems/);
}); });
testSkipIfWindows("problems - manual edit (next.js)", async ({ po }) => { testSkipIfWindows("problems - manual edit (next.js)", async ({ po }) => {
...@@ -129,11 +197,13 @@ testSkipIfWindows("problems - manual edit (next.js)", async ({ po }) => { ...@@ -129,11 +197,13 @@ testSkipIfWindows("problems - manual edit (next.js)", async ({ po }) => {
await po.clickTogglePreviewPanel(); await po.clickTogglePreviewPanel();
await po.selectPreviewMode("problems"); await po.selectPreviewMode("problems");
await po.clickRecheckProblems(); const fixButton = po.page.getByTestId("fix-all-button");
await po.snapshotProblemsPane(); await expect(fixButton).toBeEnabled({ timeout: Timeout.LONG });
await expect(fixButton).toContainText(/Fix 1 problem/);
fs.unlinkSync(badFilePath); fs.unlinkSync(badFilePath);
await po.clickRecheckProblems(); await po.clickRecheckProblems();
await po.snapshotProblemsPane(); await expect(fixButton).toBeDisabled({ timeout: Timeout.LONG });
await expect(fixButton).toContainText(/Fix 0 problems/);
}); });
- img
- text: 1 error
- button "Run checks":
- img
- button "Fix All":
- img
- img
- img
- text: src/bad-file.tsx 2:3
- paragraph: Cannot find name 'nonExistentFunction'.
\ No newline at end of file
- paragraph: No problems found
- img
- button "Run checks":
- img
\ No newline at end of file
- img
- text: 1 error
- button "Run checks":
- img
- button "Fix All":
- img
- img
- img
- text: src/bad-file.tsx 2:1
- paragraph: Cannot find name 'nonExistentFunction'.
\ No newline at end of file
- paragraph: No problems found
- img
- button "Run checks":
- img
\ No newline at end of file
- paragraph: "Fix these 2 TypeScript compile-time errors:"
- list:
- listitem: src/bad-file.tsx:2:1 - Cannot find name 'nonExistentFunction1'. (TS2304)
- code: const App = () => <div>Minimal imported app</div>; nonExistentFunction1(); // <-- TypeScript compiler error here nonExistentFunction2();
- list:
- listitem: src/bad-file.tsx:4:1 - Cannot find name 'nonExistentFunction3'. (TS2304)
- code: nonExistentFunction2(); nonExistentFunction3(); // <-- TypeScript compiler error here
- paragraph: Please fix all errors in a concise way.
- img
- text: bad-file.ts
- button "Edit":
- img
- img
- text: "src/bad-file.ts Summary: Fix 2 errors and introduce a new error."
- paragraph: "[[dyad-dump-path=*]]"
- button:
- img
- img
- text: less than a minute ago
- button "Retry":
- img
\ No newline at end of file
===
role: user
message: Fix these 2 TypeScript compile-time errors:
1. src/bad-file.tsx:2:1 - Cannot find name 'nonExistentFunction1'. (TS2304)
```
const App = () => <div>Minimal imported app</div>;
nonExistentFunction1(); // <-- TypeScript compiler error here
nonExistentFunction2();
```
2. src/bad-file.tsx:4:1 - Cannot find name 'nonExistentFunction3'. (TS2304)
```
nonExistentFunction2();
nonExistentFunction3(); // <-- TypeScript compiler error here
```
Please fix all errors in a concise way.
\ No newline at end of file
import { useState } from "react"; import { useEffect, useState } from "react";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { selectedAppIdAtom } from "@/atoms/appAtoms";
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Problem, ProblemReport } from "@/ipc/ipc_types"; import { Problem, ProblemReport } from "@/ipc/ipc_types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { useCheckProblems } from "@/hooks/useCheckProblems"; import { useCheckProblems } from "@/hooks/useCheckProblems";
...@@ -20,11 +21,26 @@ import { showError } from "@/lib/toast"; ...@@ -20,11 +21,26 @@ import { showError } from "@/lib/toast";
interface ProblemItemProps { interface ProblemItemProps {
problem: Problem; problem: Problem;
checked: boolean;
onToggle: () => void;
} }
const ProblemItem = ({ problem }: ProblemItemProps) => { const ProblemItem = ({ problem, checked, onToggle }: ProblemItemProps) => {
return ( return (
<div className="flex items-start gap-3 p-3 border-b border-border hover:bg-[var(--background-darkest)] transition-colors"> <div
role="checkbox"
aria-checked={checked}
onClick={onToggle}
className="cursor-pointer flex items-start gap-3 p-3 border-b border-border hover:bg-[var(--background-darker)] dark:hover:bg-[var(--background-lightest)] transition-colors"
data-testid="problem-row"
>
<Checkbox
checked={checked}
onCheckedChange={onToggle}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
aria-label="Select problem"
/>
<div className="flex-shrink-0 mt-0.5"> <div className="flex-shrink-0 mt-0.5">
<XCircle size={16} className="text-red-500" /> <XCircle size={16} className="text-red-500" />
</div> </div>
...@@ -56,6 +72,7 @@ interface RecheckButtonProps { ...@@ -56,6 +72,7 @@ interface RecheckButtonProps {
| "ghost" | "ghost"
| "link"; | "link";
className?: string; className?: string;
onBeforeRecheck?: () => void;
} }
const RecheckButton = ({ const RecheckButton = ({
...@@ -63,11 +80,15 @@ const RecheckButton = ({ ...@@ -63,11 +80,15 @@ const RecheckButton = ({
size = "sm", size = "sm",
variant = "outline", variant = "outline",
className = "h-7 px-3 text-xs", className = "h-7 px-3 text-xs",
onBeforeRecheck,
}: RecheckButtonProps) => { }: RecheckButtonProps) => {
const { checkProblems, isChecking } = useCheckProblems(appId); const { checkProblems, isChecking } = useCheckProblems(appId);
const [showingFeedback, setShowingFeedback] = useState(false); const [showingFeedback, setShowingFeedback] = useState(false);
const handleRecheck = async () => { const handleRecheck = async () => {
if (onBeforeRecheck) {
onBeforeRecheck();
}
setShowingFeedback(true); setShowingFeedback(true);
const res = await checkProblems(); const res = await checkProblems();
...@@ -102,23 +123,24 @@ const RecheckButton = ({ ...@@ -102,23 +123,24 @@ const RecheckButton = ({
interface ProblemsSummaryProps { interface ProblemsSummaryProps {
problemReport: ProblemReport; problemReport: ProblemReport;
appId: number; appId: number;
selectedCount: number;
onClearAll: () => void;
onFixSelected: () => void;
onSelectAll: () => void;
} }
const ProblemsSummary = ({ problemReport, appId }: ProblemsSummaryProps) => { const ProblemsSummary = ({
const { streamMessage } = useStreamChat(); problemReport,
appId,
selectedCount,
onClearAll,
onFixSelected,
onSelectAll,
}: ProblemsSummaryProps) => {
const { problems } = problemReport; const { problems } = problemReport;
const totalErrors = problems.length; const totalErrors = problems.length;
const [selectedChatId] = useAtom(selectedChatIdAtom);
const handleFixAll = () => { // Keep stream hook mounted; actual fix action is provided via onFixSelected
if (!selectedChatId) {
return;
}
streamMessage({
prompt: createProblemFixPrompt(problemReport),
chatId: selectedChatId,
});
};
if (problems.length === 0) { if (problems.length === 0) {
return ( return (
...@@ -148,16 +170,36 @@ const ProblemsSummary = ({ problemReport, appId }: ProblemsSummaryProps) => { ...@@ -148,16 +170,36 @@ const ProblemsSummary = ({ problemReport, appId }: ProblemsSummaryProps) => {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RecheckButton appId={appId} /> <RecheckButton appId={appId} onBeforeRecheck={onClearAll} />
{selectedCount === 0 ? (
<Button
size="sm"
variant="outline"
onClick={onSelectAll}
className="h-7 px-3 text-xs"
>
Select all
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={onClearAll}
className="h-7 px-3 text-xs"
>
Clear all
</Button>
)}
<Button <Button
size="sm" size="sm"
variant="default" variant="default"
onClick={handleFixAll} onClick={onFixSelected}
className="h-7 px-3 text-xs" className="h-7 px-3 text-xs"
data-testid="fix-all-button" data-testid="fix-all-button"
disabled={selectedCount === 0}
> >
<Wrench size={14} className="mr-1" /> <Wrench size={14} className="mr-1" />
Fix All {`Fix ${selectedCount} ${selectedCount === 1 ? "problem" : "problems"}`}
</Button> </Button>
</div> </div>
</div> </div>
...@@ -175,6 +217,20 @@ export function Problems() { ...@@ -175,6 +217,20 @@ export function Problems() {
export function _Problems() { export function _Problems() {
const selectedAppId = useAtomValue(selectedAppIdAtom); const selectedAppId = useAtomValue(selectedAppIdAtom);
const { problemReport } = useCheckProblems(selectedAppId); const { problemReport } = useCheckProblems(selectedAppId);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const problemKey = (p: Problem) =>
`${p.file}:${p.line}:${p.column}:${p.code}`;
const { streamMessage } = useStreamChat();
const [selectedChatId] = useAtom(selectedChatIdAtom);
// Whenever the problems pane is shown or the report updates, select all problems
useEffect(() => {
if (problemReport?.problems?.length) {
setSelectedKeys(new Set(problemReport.problems.map(problemKey)));
} else {
setSelectedKeys(new Set());
}
}, [problemReport]);
if (!selectedAppId) { if (!selectedAppId) {
return ( return (
...@@ -200,21 +256,65 @@ export function _Problems() { ...@@ -200,21 +256,65 @@ export function _Problems() {
<p className="text-sm text-muted-foreground max-w-md mb-4"> <p className="text-sm text-muted-foreground max-w-md mb-4">
Run checks to scan your app for TypeScript errors and other problems. Run checks to scan your app for TypeScript errors and other problems.
</p> </p>
<RecheckButton appId={selectedAppId} /> <RecheckButton
appId={selectedAppId}
onBeforeRecheck={() => setSelectedKeys(new Set())}
/>
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<ProblemsSummary problemReport={problemReport} appId={selectedAppId} /> <ProblemsSummary
problemReport={problemReport}
appId={selectedAppId}
selectedCount={
[...selectedKeys].filter((key) =>
problemReport.problems.some((p) => problemKey(p) === key),
).length
}
onClearAll={() => setSelectedKeys(new Set())}
onSelectAll={() =>
setSelectedKeys(
new Set(problemReport.problems.map((p) => problemKey(p))),
)
}
onFixSelected={() => {
if (!selectedChatId) return;
const selectedProblems = problemReport.problems.filter((p) =>
selectedKeys.has(problemKey(p)),
);
const subsetReport: ProblemReport = { problems: selectedProblems };
streamMessage({
prompt: createProblemFixPrompt(subsetReport),
chatId: selectedChatId,
});
}}
/>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{problemReport.problems.map((problem, index) => ( {problemReport.problems.map((problem) => {
<ProblemItem const selKey = problemKey(problem);
key={`${problem.file}-${problem.line}-${problem.column}-${index}`} const checked = selectedKeys.has(selKey);
problem={problem} return (
/> <ProblemItem
))} key={selKey}
problem={problem}
checked={checked}
onToggle={() => {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(selKey)) {
next.delete(selKey);
} else {
next.add(selKey);
}
return next;
});
}}
/>
);
})}
</div> </div>
</div> </div>
); );
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论