Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions propr-ui/e2e/planner-fresh-generate.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { expect, test, type Page } from '@playwright/test';
import { capture, fixture } from './planner-studio.fixture';
import { installWindowActions } from './native-chrome.fixture';

const draftId = 'fresh-plan';
const prompt = 'Add a health endpoint to the API';
const serverName = 'Fresh draft named by the server';
const task = { id: 'task-1', title: 'Add a GET /health endpoint with a readiness check', body: '## Context\nOne bounded task.', implementation: '' };

const layouts = [
{ name: 'phone-320', viewport: { width: 320, height: 640 } },
{ name: 'phone-390', viewport: { width: 390, height: 844 } },
{ name: 'desktop', viewport: { width: 1440, height: 900 } },
// The installed app's minimum window, with the native shell's titlebar.
{ name: 'native-minimum', viewport: { width: 880, height: 620 }, native: true },
];

/** Installs the native bridge the packaged renderer sees, with a Linux frame. */
async function installNativeShell(page: Page) {
await page.addInitScript(() => {
const profile = { id: 'fresh-plan-profile', name: 'Preview workspace', kind: 'remote' as const, baseUrl: 'https://fixture.example.test' };
window.__PROPR_DESKTOP__ = {
isDesktop: true, platform: 'linux',
app: { onDeepLink: () => () => undefined },
profiles: { list: async () => [profile], getActiveId: async () => profile.id, setActiveId: async () => undefined, save: async () => undefined, remove: async () => undefined },
connection: { probe: async () => ({ status: 'ready' }) },
authentication: { authenticate: async () => undefined },
discovery: { supported: false, discover: async () => [] },
localSetup: { supported: false, setup: async () => profile },
externalBrowser: { open: async () => undefined },
} as unknown as typeof window.__PROPR_DESKTOP__;
});
await installWindowActions(page, { platform: 'linux' });
}

/**
* A server for one fresh draft. Like the real one, POST /generate claims the run only after preparing the repository,
* which takes longer than the page's own read of the new draft: that read answers with the pre-generation draft
* before the acknowledgement arrives. Completion is up to the test.
*
* Reads carry a server-assigned name the create response lacks, so the document title shows when the page has applied
* a read; the acknowledgement is held until then rather than for a fixed time.
*/
async function routeFreshDraft(page: Page, options: { holdAcknowledgement?: Promise<void> } = {}) {
const server = { creations: 0, generations: 0, reads: 0, readAppliedBeforeAck: false, complete: () => {} };
const draft: Record<string, unknown> = {
draft_id: draftId, repository: 'integry/propr', initial_prompt: '', status: 'draft', attachments: [], chat_history: [], plan_json: [],
created_at: '2026-10-22T22:00:00Z', context_config: { baseBranch: 'main' },
};
server.complete = () => Object.assign(draft, { status: 'review', plan_json: [task], name: 'Health endpoint', generation_trace: { runId: 'run-1', steps: [] } });
await page.route('**/api/planner/drafts', route => {
if (route.request().method() !== 'POST') return route.fallback();
server.creations++;
draft.initial_prompt = route.request().postDataJSON().prompt;
return route.fulfill({ json: draft });
});
await page.route(`**/api/planner/drafts/${draftId}`, async route => {
if (route.request().method() === 'PUT') {
Object.assign(draft, route.request().postDataJSON());
return route.fulfill({ json: draft });
}
server.reads++;
await route.fulfill({ json: { ...draft, name: draft.name ?? serverName } });
});
await page.route(`**/api/planner/drafts/${draftId}/repository-info`, route => route.fulfill({ json: { defaultBranch: 'main', branches: ['main'] } }));
await page.route('**/api/github/repos/integry/propr/branches', route => route.fulfill({ json: { defaultBranch: 'main', branches: ['main'] } }));
await page.route('**/api/planner/preview', route => route.fulfill({ json: {
success: true, stats: { totalTokens: 0, costEstimate: 0, contextLength: 0, fileCount: 0 }, smartSelection: [], warnings: [],
} }));
await page.route('**/api/planner/generate', async route => {
server.generations++;
expect(route.request().postDataJSON().draftId).toBe(draftId);
if (options.holdAcknowledgement) await options.holdAcknowledgement;
// Barrier: the page has rendered the pre-generation read. On timeout the acknowledgement still goes out and the test's assertion fails.
else server.readAppliedBeforeAck = await page.waitForFunction(name => document.title.includes(name), serverName, { timeout: 10_000 })
.then(() => true, () => false);
Object.assign(draft, { status: 'generating', generation_trace: { runId: 'run-1', steps: [{ name: 'relevance', status: 'in_progress' }] } });
await route.fulfill({ json: { success: true, runId: 'run-1', status: 'generating', message: 'started' } });
});
return server;
}

for (const layout of layouts) {
test(`a fresh plan generated before autosave shows Generating, then its Review (${layout.name})`, async ({ page }) => {
await page.setViewportSize(layout.viewport);
if (layout.native) await installNativeShell(page);
await fixture(page);
const server = await routeFreshDraft(page);
await page.goto('/plans');
await page.evaluate(() => { window.history.pushState(null, '', '/studio/new'); window.dispatchEvent(new PopStateEvent('popstate')); });

const composer = page.getByPlaceholder(/Describe the feature/);
await expect(page.getByTestId('branch-chip')).toHaveAttribute('data-status', 'resolved');
await composer.fill(prompt);
const generate = page.getByRole('button', { name: /Generate Plan/ });
await expect(generate).toBeEnabled();
// Inside the autosave debounce: Generate creates the draft itself.
await generate.click();

await expect(page.getByText('Generating', { exact: true })).toBeVisible();
expect(server.generations).toBe(1);
expect(server.readAppliedBeforeAck).toBe(true);
await expect(page).toHaveURL(new RegExp(`/studio/${draftId}$`));
await expect(composer).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await capture(page, `fresh-plan-generating-${layout.name}`);

server.complete();
// With the socket unavailable, the generating view's HTTP fallback picks up completion.
await expect(page.getByText(task.title).first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: /Generate Plan/ })).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await capture(page, `fresh-plan-review-${layout.name}`);
expect(server.creations).toBe(1);
expect(server.generations).toBe(1);

// /studio/new was replaced: back returns to the plans list and forward reopens the finished plan.
await page.goBack();
await expect(page).toHaveURL(/\/plans$/);
await page.goForward();
await expect(page).toHaveURL(new RegExp(`/studio/${draftId}$`));
await expect(page.getByText(task.title).first()).toBeVisible();
expect(server.generations).toBe(1);
});
}

for (const layout of layouts) {
test(`leaving a fresh plan before its generation is acknowledged keeps the user where they went (${layout.name})`, async ({ page }) => {
await page.setViewportSize(layout.viewport);
if (layout.native) await installNativeShell(page);
await fixture(page);
let acknowledge!: () => void;
const server = await routeFreshDraft(page, { holdAcknowledgement: new Promise<void>(resolve => { acknowledge = resolve; }) });
await page.goto('/plans');
await page.evaluate(() => { window.history.pushState(null, '', '/studio/new'); window.dispatchEvent(new PopStateEvent('popstate')); });

await expect(page.getByTestId('branch-chip')).toHaveAttribute('data-status', 'resolved');
await page.getByPlaceholder(/Describe the feature/).fill(prompt);
const generate = page.getByRole('button', { name: /Generate Plan/ });
await expect(generate).toBeEnabled();
await generate.click();
await expect.poll(() => server.generations).toBe(1);
await expect(page).toHaveURL(new RegExp(`/studio/${draftId}$`));

await page.goBack();
await expect(page).toHaveURL(/\/plans$/);
const acknowledged = page.waitForResponse('**/api/planner/generate');
acknowledge();
await acknowledged;
// Give a late navigation every chance to run before asserting it did not.
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 500)));
await expect(page).toHaveURL(/\/plans$/);
await expect(page.getByPlaceholder(/Describe the feature/)).toHaveCount(0);

// The run was not cancelled: reopening the draft shows its progress and then its Review, without generating again.
await page.goForward();
await expect(page).toHaveURL(new RegExp(`/studio/${draftId}$`));
await expect(page.getByText('Generating', { exact: true })).toBeVisible();
server.complete();
await expect(page.getByText(task.title).first()).toBeVisible({ timeout: 15_000 });
expect(server.creations).toBe(1);
expect(server.generations).toBe(1);
});
}
4 changes: 2 additions & 2 deletions propr-ui/src/components/TaskPlanner/SetupWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface SetupWizardProps {
onGenerateComplete: () => void;
onDraftCreated?: (draftId: string) => void;
onDraftCreatedInPlace?: (draft: PlannerDraft) => void;
onGenerationStarted?: (runId: string) => void;
onGenerationStarted?: (runId: string, draftId: string, snapshot?: PlannerDraft) => void;
onDraftMetadataPersisted?: (update: PromptPersistedUpdate) => void;
}

Expand Down Expand Up @@ -389,7 +389,7 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ draft, onGenerateCompl
// Generate shares autosave's single-flight creation so one click cannot persist a second draft
const handleCreateDraftAndGenerate = useDraftCreation({
selectedRepo: repoLoader.selectedRepo, config, localFiles: fileHandling.localFiles,
ensureDraftCreated, uploadPendingLocalFiles, flushPrompt, navigate, setError, setIsCreating
ensureDraftCreated, uploadPendingLocalFiles, flushPrompt, onGenerationStarted, navigate, setError, setIsCreating
});
const autoResize = useAutoResize(textareaRef);
const handleRepoChangeInEditMode = useRepoChangeInEditMode({ draft, config, locationTodoIds: locationState?.todoIds, navigate, onDraftCreated, setError, setIsCreating });
Expand Down
32 changes: 31 additions & 1 deletion propr-ui/src/components/TaskPlanner/setupWizardHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,41 @@ describe('useGenerationHandlers', () => {
});
await act(async () => firstStart);

expect(onGenerationStarted).toHaveBeenCalledWith('generation-run-2');
expect(onGenerationStarted).toHaveBeenCalledWith('generation-run-2', makeDraft().draft_id, expect.objectContaining({ draft_id: makeDraft().draft_id }));
expect(startPolling).toHaveBeenCalledWith('generation-run-2');
expect(result.current.isStartingGeneration).toBe(false);
});

it('drops an acknowledgement that arrives after the wizard switched to another draft', async () => {
const generationResponse = createDeferred<Awaited<ReturnType<typeof generatePlan>>>();
mockGeneratePlan.mockImplementationOnce(() => generationResponse.promise);
const startPolling = vi.fn();
const onGenerationStarted = vi.fn();
const { result, rerender } = renderHook(({ draft }: { draft: PlannerDraft }) => useGenerationHandlers({
draft,
config: baseConfig,
branchError: null,
contextHelpers: { isContextStale: false, clearCountdown: vi.fn(), fetchPreview: vi.fn() },
startPolling,
stopPolling: vi.fn(),
onGenerationStarted,
setError: vi.fn(),
setGenerationError: vi.fn(),
}), { initialProps: { draft: makeDraft() as never } });

let start!: Promise<void>;
act(() => { start = result.current.handleGenerateForExistingDraft(); });
await waitFor(() => expect(mockGeneratePlan).toHaveBeenCalledWith('draft-1', expect.anything()));

rerender({ draft: { ...makeDraft(), draft_id: 'draft-2' } as never });
generationResponse.resolve({ success: true, status: 'generating', message: 'Plan generation started', runId: 'generation-run-a' });
await act(async () => start);

expect(onGenerationStarted).not.toHaveBeenCalled();
expect(startPolling).not.toHaveBeenCalled();
expect(result.current.isStartingGeneration).toBe(false);
});

it('does not generate when the required context preview fails', async () => {
const startPolling = vi.fn();
const setError = vi.fn();
Expand Down
8 changes: 6 additions & 2 deletions propr-ui/src/components/TaskPlanner/setupWizardHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface PlannerConfig { prompt: string; baseBranch: string; granularity

export interface PromptPersistedUpdate { draftId: string; initial_prompt: string; name: string; }
interface RepoInfoState { isLoading: boolean; error: string | null; }
interface GenerationHandlersParams { draft: PlannerDraft | undefined; config: PlannerConfig; branchError: string | null; flushPrompt?: (draftId: string, prompt: string) => Promise<void>; contextHelpers: { isContextStale: boolean; clearCountdown: () => void; fetchPreview: () => Promise<boolean> }; startPolling: (runId?: string) => void; stopPolling: () => void; onGenerationStarted?: (runId: string) => void; setError: React.Dispatch<React.SetStateAction<string | null>>; setGenerationError: (error: string | null) => void; }
interface GenerationHandlersParams { draft: PlannerDraft | undefined; config: PlannerConfig; branchError: string | null; flushPrompt?: (draftId: string, prompt: string) => Promise<void>; contextHelpers: { isContextStale: boolean; clearCountdown: () => void; fetchPreview: () => Promise<boolean> }; startPolling: (runId?: string) => void; stopPolling: () => void; onGenerationStarted?: (runId: string, draftId: string, snapshot?: PlannerDraft) => void; setError: React.Dispatch<React.SetStateAction<string | null>>; setGenerationError: (error: string | null) => void; }
interface GenerateDisabledParams { isNewMode: boolean; isCreating: boolean; selectedRepo: string; promptTrimmed: string; reposLoading: boolean; isGenerating: boolean; branchError: string | null; repoInfoLoading: boolean; repoError: string | null; baseBranch: string; }

type PlannerConfigSetter = React.Dispatch<React.SetStateAction<PlannerConfig>>;
Expand Down Expand Up @@ -217,6 +217,9 @@ export function useGenerationHandlers({ draft, config, branchError, flushPrompt
const isStartingGenerationRef = useRef(false);
const contextHelpersRef = useRef(contextHelpers);
contextHelpersRef.current = contextHelpers;
// The wizard survives /studio/:a → /studio/:b, so an acknowledgement can arrive for a draft no longer shown.
const currentDraftIdRef = useRef(draft?.draft_id);
currentDraftIdRef.current = draft?.draft_id;
const handleGenerateForExistingDraft = useCallback(async () => {
if (!draft || isStartingGenerationRef.current) return;
if (branchError) return void setError('Please fix the branch name before generating');
Expand All @@ -236,7 +239,8 @@ export function useGenerationHandlers({ draft, config, branchError, flushPrompt
}
}
const generation = await generatePlan(draft.draft_id, buildGenerationPayload(config));
onGenerationStarted?.(generation.runId);
if (currentDraftIdRef.current !== draft.draft_id) return;
onGenerationStarted?.(generation.runId, draft.draft_id, draft);
startPolling(generation.runId);
} catch (err) {
stopPolling();
Expand Down
38 changes: 35 additions & 3 deletions propr-ui/src/components/TaskPlanner/useDraftCreation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ function renderNewPlan(initialProps: HarnessProps) {
const navigate = vi.fn();
const setError = vi.fn();
const setIsCreating = vi.fn();
const onDraftCreatedInPlace = vi.fn();
// As PlanStudioPage does: swap the visible path without a new history entry.
const onDraftCreatedInPlace = vi.fn((draft: PlannerDraft) => window.history.replaceState(window.history.state, '', `/studio/${draft.draft_id}`));
const onDraftCreated = vi.fn();
const onGenerationStarted = vi.fn();
const flushPrompt = vi.fn((draftId: string, prompt: string) => updateDraft(draftId, { initial_prompt: prompt, name: prompt }));
const hook = renderHook(({ config, localFiles }: HarnessProps) => {
const autosave = useAutoDraftCreation({
Expand All @@ -31,11 +33,11 @@ function renderNewPlan(initialProps: HarnessProps) {
});
const generate = useDraftCreation({
selectedRepo: 'integry/propr', config, localFiles, ensureDraftCreated: autosave.ensureDraftCreated,
uploadPendingLocalFiles: autosave.uploadPendingLocalFiles, flushPrompt, navigate, setError, setIsCreating,
uploadPendingLocalFiles: autosave.uploadPendingLocalFiles, flushPrompt, onGenerationStarted, navigate, setError, setIsCreating,
});
return { autosave, generate };
}, { initialProps });
return { ...hook, navigate, setError, setIsCreating, onDraftCreatedInPlace, onDraftCreated };
return { ...hook, navigate, setError, setIsCreating, onDraftCreatedInPlace, onDraftCreated, onGenerationStarted };
}

const config = (overrides: Partial<PlannerConfig> = {}): PlannerConfig => ({ ...baseConfig, prompt: 'Test prompt', baseBranch: 'develop', ...overrides });
Expand All @@ -44,6 +46,7 @@ describe('useDraftCreation', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
window.history.replaceState({ key: 'studio', idx: 0 }, '', '/studio/new');
mockUpdateDraft.mockResolvedValue(undefined as never);
mockUploadAttachment.mockImplementation(async (_draftId, file) => ({ id: `attachment-${file.name}`, originalName: file.name, tokenEstimate: 1 }));
mockGeneratePlan.mockResolvedValue({ success: true, status: 'generating', message: 'started', runId: 'run-1' });
Expand Down Expand Up @@ -171,4 +174,33 @@ describe('useDraftCreation', () => {
expect(mockCreateDraft).toHaveBeenCalledTimes(1);
expect(mockGeneratePlan).toHaveBeenCalledTimes(1);
});

// The run belongs to the server once acknowledged; leaving must neither cancel it nor let it pull the user back.
it.each([
['unmounts (back to the plans list)', (view: ReturnType<typeof renderNewPlan>) => view.unmount()],
// Still mounted: the router can keep rendering the studio while the next route's chunk loads.
['stays mounted while the browser moves to another entry', () => window.history.pushState({ key: 'plans', idx: 1 }, '', '/plans')],
])('drops the acknowledgement when the wizard %s while generation is pending', async (_label, leave) => {
mockCreateDraft.mockResolvedValue(makeDraft());
const generation = createDeferred<Awaited<ReturnType<typeof generatePlan>>>();
mockGeneratePlan.mockReturnValueOnce(generation.promise);
const view = renderNewPlan({ config: config(), localFiles: [] });

let start!: Promise<void>;
act(() => { start = view.result.current.generate(); });
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(mockGeneratePlan).toHaveBeenCalledTimes(1);
leave(view);
await act(async () => {
generation.resolve({ success: true, status: 'generating', message: 'started', runId: 'run-1' });
await start;
await vi.advanceTimersByTimeAsync(5000);
});

expect(view.onGenerationStarted).not.toHaveBeenCalled();
expect(view.navigate).not.toHaveBeenCalled();
expect(view.setError).not.toHaveBeenCalledWith(expect.any(String));
expect(mockCreateDraft).toHaveBeenCalledTimes(1);
expect(mockGeneratePlan).toHaveBeenCalledTimes(1);
});
});
Loading
Loading