Skip to content
Open
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
31 changes: 31 additions & 0 deletions apps/desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const execFileAsync = promisify(execFile);
export const COMPOSER_INPUT = '.maka-composer-editor [contenteditable="true"]';
export const PARENT_REMOVAL_PARENT_NAME = '待删除的父任务';
export const PARENT_REMOVAL_CHILD_NAME = '应归档的子任务';
/** Directory basename, and so the Project name the workspace picker lists. */
export const NEW_TASK_PROJECT_NAME = 'new-task-project';

/**
* Wait for Runtime's authoritative Skill projection, not merely for the
Expand Down Expand Up @@ -270,6 +272,20 @@ async function seedE2eGitReviewProject(
await seedCurrentProject(workspaceRoot, projectRoot);
}

/**
* One registered Project and nothing else, so the workspace picker under the
* new-task composer offers two selectable targets: this Project and the Host's
* implicit "no project". The new-task draft slot is keyed by (profile, host,
* project), so moving between them is what re-keys it (#3408). The directory is
* plain — its basename becomes the Project name the picker menu shows.
*/
async function seedE2eNewTaskProject(userDataDir: string): Promise<void> {
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
const projectRoot = path.join(userDataDir, NEW_TASK_PROJECT_NAME);
await mkdir(projectRoot, { recursive: true });
await seedCurrentProject(workspaceRoot, projectRoot);
}

async function seedCurrentProject(workspaceRoot: string, projectRoot: string): Promise<void> {
const storageRoot = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' });
const catalog = createProjectCatalog(workspaceRoot);
Expand Down Expand Up @@ -304,6 +320,7 @@ async function withE2eWindow(
invocableSkills,
gitReviewExtraFiles,
parentRemovalSessions,
newTaskProject,
}: {
seed: boolean;
readinessSelector: string;
Expand All @@ -318,6 +335,7 @@ async function withE2eWindow(
invocableSkills?: boolean;
gitReviewExtraFiles?: number;
parentRemovalSessions?: boolean;
newTaskProject?: boolean;
},
use: (page: Page, context: { userDataDir: string }) => Promise<void>,
): Promise<void> {
Expand All @@ -336,6 +354,7 @@ async function withE2eWindow(
if (gitReviewExtraFiles !== undefined) {
await seedE2eGitReviewProject(userDataDir, gitReviewExtraFiles);
}
if (newTaskProject) await seedE2eNewTaskProject(userDataDir);
// Legacy E2E specs assert Chinese labels and should not inherit the CI
// host locale. E2e-fixture workspaces use the explicit renderer override.
if (locale && !e2eFixtureScenario) await seedE2eLocale(userDataDir, locale);
Expand Down Expand Up @@ -405,6 +424,7 @@ export const test = base.extend<{
promptRailWindow: Page;
promptRailMotionWindow: Page;
requestHeaderRowWindow: Page;
newTaskTargetWindow: Page;
}>({
// Seeded: a pre-staged connection clears onboarding so the composer is ready.
window: async ({}, use) => {
Expand Down Expand Up @@ -452,6 +472,17 @@ export const test = base.extend<{
},
// A real project with several sessions. Shown because the contract under
// test is native focus order across independently interactive row controls.
// Seeded connection so the composer is ready, plus one registered Project so
// the workspace picker under it has a second target to move to.
newTaskTargetWindow: async ({}, use) => {
await withE2eWindow({
seed: true,
readinessSelector: COMPOSER_INPUT,
locale: 'zh',
newTaskProject: true,
showWindow: true,
}, use);
},
projectSidebarWindow: async ({}, use) => {
await withE2eWindow({
seed: false,
Expand Down
63 changes: 63 additions & 0 deletions apps/desktop/e2e/new-task-draft-target.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Page } from '@playwright/test';
import { COMPOSER_INPUT, NEW_TASK_PROJECT_NAME, expect, test } from './fixtures';

/**
* #3408, in the real window: the new-task draft slot is keyed by (profile,
* host, project), and the workspace picker that changes the project part sits
* directly under the composer — so "type, then pick where it runs" re-keyed the
* slot mid-typing and swapped the text out for the new target's empty one.
*
* `chat-composer-region-draft-handoff.test.ts` pins the handoff at the
* component. This pins the wiring the user actually touches: that the picker is
* what re-keys the composer, and that the draft survives it.
*/
const DRAFT = 'draft written before choosing a project';

/**
* Read the composer only after the click's render has committed AND its passive
* effects have flushed. The draft swap runs in an effect after the picker's own
* re-render, so a read taken between the two sees the text still on screen and
* passes against broken code — which is exactly what an earlier version of this
* spec did. `newTaskTargetWindow` is shown for the same reason: a hidden
* window's compositor is throttled to ~1fps, which stretched that gap from
* 0.1ms to seconds and made every assertion here vacuous.
*/
async function settle(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}

test('the new-task draft follows the Project chosen under the composer', async ({
newTaskTargetWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const picker = page.locator('button.maka-workspace-picker');

// Wait for the seeded Project to be the resolved target before typing: until
// the catalog settles the draft key is the unresolved one, whose handoff is a
// different path and was never broken.
await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME));

await composer.click();
await page.keyboard.type(DRAFT);
await expect(composer).toHaveText(DRAFT);

await picker.click();
await page.getByRole('menuitem', { name: '无项目', exact: true }).click();
// The picker's label is the selected target, so this asserts the click moved
// the selection. Without it the draft assertion below would still pass if the
// menu item stopped selecting anything at all.
await expect(picker).toHaveAttribute('aria-label', /无项目/);
await settle(page);
await expect(composer).toHaveText(DRAFT);

await picker.click();
await page.getByRole('menuitem', { name: NEW_TASK_PROJECT_NAME, exact: true }).click();
await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME));
await settle(page);
await expect(composer).toHaveText(DRAFT);
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,15 @@ afterEach(async () => {
Object.assign(globalThis, originalGlobals);
});

test('hands off only the unresolved new-task draft while a Session is open', async () => {
/**
* Mounts the region on a linkedom document and returns its composer handle
* plus a `render(activeId, newTaskDraftKey)` that re-renders with new props —
* the two inputs the draft handoff is keyed on.
*/
async function mountRegion(): Promise<{
composer: { current: ComposerHandle | null };
render(activeId: string | undefined, newTaskDraftKey: string): Promise<void>;
}> {
const { document, window } = parseHTML('<div id="root"></div>');
const storage = new Map<string, string>();
Object.assign(document, {
Expand Down Expand Up @@ -65,8 +73,6 @@ test('hands off only the unresolved new-task draft while a Session is open', asy
const root = createRoot(container);
mountedRoot = root;
const composer = createRef<ComposerHandle>();
markNewTaskReloadIntent();
writeNewTaskReloadDraft(UNRESOLVED_NEW_TASK_DRAFT_KEY, 'new task draft');

const render = async (activeId: string | undefined, newTaskDraftKey: string) => {
await act(async () => {
Expand Down Expand Up @@ -99,6 +105,14 @@ test('hands off only the unresolved new-task draft while a Session is open', asy
});
};

return { composer, render };
}

test('hands off only the unresolved new-task draft while a Session is open', async () => {
const { composer, render } = await mountRegion();
markNewTaskReloadIntent();
writeNewTaskReloadDraft(UNRESOLVED_NEW_TASK_DRAFT_KEY, 'new task draft');

await render('session-1', UNRESOLVED_NEW_TASK_DRAFT_KEY);
await act(() => composer.current?.setText('session draft'));

Expand All @@ -107,3 +121,67 @@ test('hands off only the unresolved new-task draft while a Session is open', asy

assert.equal(composer.current?.getText(), 'new task draft');
});

test('carries the visible new-task draft when the target Project changes', async () => {
const { composer, render } = await mountRegion();

await render(undefined, 'new-task:local:project-1');
await act(() => composer.current?.setText('draft in flight'));

await render(undefined, 'new-task:local:project-2');
assert.equal(composer.current?.getText(), 'draft in flight');

// …and it keeps following the target rather than leaving copies behind: an
// edit made under project-2 is what project-1 shows on the way back, not the
// text that was carried away from it.
await act(() => composer.current?.setText('draft in flight, edited'));
await render(undefined, 'new-task:local:project-1');
assert.equal(composer.current?.getText(), 'draft in flight, edited');
});

test('does not resurrect a sent new-task draft from a target passed through', async () => {
const { composer, render } = await mountRegion();

await render(undefined, 'new-task:local:project-1');
await act(() => composer.current?.setText('sent text'));
// Out to project-2 and back, so both slots have now held this text.
await render(undefined, 'new-task:local:project-2');
await render(undefined, 'new-task:local:project-1');
// …and the send clears the slot it was submitted from, as Composer does.
await act(() => composer.current?.clearDraft('new-task:local:project-1'));
assert.equal(composer.current?.getText(), '');

await render(undefined, 'new-task:local:project-2');
assert.equal(composer.current?.getText(), '');
});

test('restores a reload draft when its own target is selected later', async () => {
const { composer, render } = await mountRegion();
markNewTaskReloadIntent();
writeNewTaskReloadDraft('new-task:local:project-1', 'draft that survived a reload');

// Startup settles on a different target than the reload draft belongs to, so
// that draft stays put rather than being pasted into project-2.
await render(undefined, UNRESOLVED_NEW_TASK_DRAFT_KEY);
await render(undefined, 'new-task:local:project-2');
assert.equal(composer.current?.getText(), '');

await render(undefined, 'new-task:local:project-1');
assert.equal(composer.current?.getText(), 'draft that survived a reload');
});

test('leaves a Session draft alone when the new-task target changes behind it', async () => {
const { composer, render } = await mountRegion();

await render(undefined, 'new-task:local:project-1');
await act(() => composer.current?.setText('new task draft'));

await render('session-1', 'new-task:local:project-1');
await act(() => composer.current?.setText('session draft'));

await render('session-1', 'new-task:local:project-2');
assert.equal(composer.current?.getText(), 'session draft');

await render(undefined, 'new-task:local:project-2');
assert.equal(composer.current?.getText(), 'new task draft');
});
Loading