You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/22 12:03:44 UTC
(maka) branch main updated: fix(desktop): keep the new-task composer's contents when the target changes (#3432)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 7167d780d fix(desktop): keep the new-task composer's contents when the target changes (#3432)
7167d780d is described below
commit 7167d780d87155e804b551c4123b4558bf380767
Author: HikariLan <i...@hikarilan.life>
AuthorDate: Sat Aug 22 20:03:40 2026 +0800
fix(desktop): keep the new-task composer's contents when the target changes (#3432)
* fix(desktop): keep the new-task composer's contents when the target changes
Draft text, staged attachments and staged quotes are all keyed by (profileId,
hostId, projectId) since #3122, and the workspace picker that changes the
project part sits directly under the composer — so choosing a Project re-keyed
all three mid-composition and what the user was writing dropped out of view.
Carry them to the target the user selects. The buckets stay keyed per target,
so #3122's Host-scoped new-task state is unchanged; they move with the
selection instead of staying behind under the key the user navigated away from.
The move is unconditional: the target arrived at holds what was brought to it
and nothing else. Carrying only into an empty target would leave copies under
every key the composer passed through, and one would resurface later — send the
task, come back to an empty composer, pick another target, and the text just
sent would reappear as that target's own draft.
The carry keys on the new-task target rather than on the composer's active key,
so a Session keeps its own draft and attachments when the target moves behind
it. Leaving the unresolved slot keeps its existing reload-lease guard: that
transition is startup settling, not a choice, and its draft may belong to one
specific target.
Fixes #3408
Generated-by: Claude Code (Claude Opus 5)
Co-Authored-By: Claude Opus 5 (1M context) <no...@anthropic.com>
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
* fix(desktop): give the new-task composer's staged content one owner
Review found two ways pending content could escape the owner an in-flight
operation had captured.
Staged files and quotes no longer follow the workspace picker at all. They are
in-memory intent, not Host state — nothing persists them and nothing restores
them per target — so they go back to one key that never moves, as they were
before #3122. `send()` captures the key it submitted from and clears exactly
that key once it resolves, so a key that followed the picker left the files it
had already delivered staged under the new target, ready to be sent a second
time. A key that cannot go stale needs no re-keying rule to keep it honest,
which removes rekeyPending and the carry hook with it.
The draft text keeps its target-scoped key, because its reload lease belongs to
one specific target. ChatComposerRegion therefore defers its carry while a send
is settling: the submission owns the text it submitted until the completion
clears the key it captured, and text typed after that send is carried once it
does.
pickAttachments resolves its owner after the native dialog closes rather than
capturing it before opening. The surface can change while a dialog is up, and
files the user just chose belong in the composer they are looking at, not in a
bucket they have left where the files are invisible but still sendable.
Generated-by: Claude Code (Claude Opus 5)
Co-Authored-By: Claude Opus 5 (1M context) <no...@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <no...@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
---
apps/desktop/e2e/fixtures.ts | 31 ++++
apps/desktop/e2e/new-task-draft-target.spec.ts | 86 +++++++++++
.../chat-composer-region-draft-handoff.test.ts | 131 +++++++++++++++-
.../main/__tests__/new-task-staged-content.test.ts | 164 +++++++++++++++++++++
.../src/renderer/app-shell-pending-attachments.ts | 18 +++
apps/desktop/src/renderer/app-shell.tsx | 31 +++-
apps/desktop/src/renderer/chat-composer-region.tsx | 62 ++++++--
.../renderer/use-app-shell-composer-attachments.ts | 16 +-
.../src/renderer/use-app-shell-composer-quotes.ts | 2 +-
9 files changed, 517 insertions(+), 24 deletions(-)
diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts
index 48af56b4e..6168f5127 100644
--- a/apps/desktop/e2e/fixtures.ts
+++ b/apps/desktop/e2e/fixtures.ts
@@ -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
@@ -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);
@@ -304,6 +320,7 @@ async function withE2eWindow(
invocableSkills,
gitReviewExtraFiles,
parentRemovalSessions,
+ newTaskProject,
}: {
seed: boolean;
readinessSelector: string;
@@ -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> {
@@ -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);
@@ -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) => {
@@ -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,
diff --git a/apps/desktop/e2e/new-task-draft-target.spec.ts b/apps/desktop/e2e/new-task-draft-target.spec.ts
new file mode 100644
index 000000000..e2e80ee8c
--- /dev/null
+++ b/apps/desktop/e2e/new-task-draft-target.spec.ts
@@ -0,0 +1,86 @@
+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);
+});
+
+test('a staged attachment survives the Project chosen under the composer', async ({
+ newTaskTargetWindow: page,
+}) => {
+ const picker = page.locator('button.maka-workspace-picker');
+ const chip = page.locator('.maka-composer-attachment-token');
+ await expect(picker).toHaveAttribute('aria-label', new RegExp(NEW_TASK_PROJECT_NAME));
+
+ await page.locator('.maka-composer').first().evaluate((form) => {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['staged'], 'dropped-notes.txt', { type: 'text/plain' }));
+ form.dispatchEvent(
+ new DragEvent('drop', { dataTransfer: transfer, bubbles: true, cancelable: true }),
+ );
+ });
+ await expect(chip).toHaveCount(1);
+
+ await picker.click();
+ await page.getByRole('menuitem', { name: '无项目', exact: true }).click();
+ await expect(picker).toHaveAttribute('aria-label', /无项目/);
+ await settle(page);
+ await expect(chip).toHaveCount(1);
+});
diff --git a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
index 8d49a8e45..18f0b9356 100644
--- a/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
+++ b/apps/desktop/src/main/__tests__/chat-composer-region-draft-handoff.test.ts
@@ -34,7 +34,19 @@ 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,
+ newTaskSendPending?: boolean,
+ ): Promise<void>;
+}> {
const { document, window } = parseHTML('<div id="root"></div>');
const storage = new Map<string, string>();
Object.assign(document, {
@@ -65,10 +77,12 @@ 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) => {
+ const render = async (
+ activeId: string | undefined,
+ newTaskDraftKey: string,
+ newTaskSendPending = false,
+ ) => {
await act(async () => {
root.render(
createElement(
@@ -83,6 +97,7 @@ test('hands off only the unresolved new-task draft while a Session is open', asy
activeInteraction: undefined,
activeId,
newTaskDraftKey,
+ newTaskSendPending,
stopPendingBySession: {},
respondToSandboxBoundary: () => {},
activeSandboxBoundary: undefined,
@@ -99,6 +114,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'));
@@ -107,3 +130,103 @@ 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('a send in flight owns its text even if the target changes under it', async () => {
+ const { composer, render } = await mountRegion();
+
+ await render(undefined, 'new-task:local:project-1');
+ await act(() => composer.current?.setText('submitted text'));
+
+ // The picker stays live while a send settles, so the target can move between
+ // `sendCurrent` capturing the key it submitted from and clearing it. Carrying
+ // the text out to project-2 here would leave the sent message in the composer
+ // ready to be sent a second time, and the completion would clear an empty
+ // project-1 instead.
+ await render(undefined, 'new-task:local:project-2', true);
+ // The composer's own completion: it clears the exact key it submitted from.
+ await act(() => composer.current?.clearDraft('new-task:local:project-1'));
+ await render(undefined, 'new-task:local:project-2', false);
+
+ assert.equal(composer.current?.getText(), '');
+ assert.equal(composer.current?.getDraft('new-task:local:project-1'), '');
+ assert.equal(composer.current?.getDraft('new-task:local:project-2'), '');
+});
+
+test('a send in flight still hands over text typed after it was submitted', async () => {
+ const { composer, render } = await mountRegion();
+
+ await render(undefined, 'new-task:local:project-1');
+ await act(() => composer.current?.setText('submitted text'));
+
+ // The next message, begun while the send is still resolving. It is not the
+ // submission's to clear, so it follows the target once the send settles.
+ await act(() => composer.current?.setText('the next message'));
+ await render(undefined, 'new-task:local:project-2', true);
+ await render(undefined, 'new-task:local:project-2', false);
+
+ assert.equal(composer.current?.getText(), 'the next message');
+});
+
+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');
+});
diff --git a/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
new file mode 100644
index 000000000..fad038468
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/new-task-staged-content.test.ts
@@ -0,0 +1,164 @@
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+import { act, createElement } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { parseHTML } from 'linkedom';
+import { LocaleProvider } from '@maka/ui';
+import { NEW_TASK_PENDING_KEY } from '../../renderer/app-shell-pending-attachments.js';
+import { useAppShellComposerAttachments } from '../../renderer/use-app-shell-composer-attachments.js';
+import { useAppShellComposerQuotes } from '../../renderer/use-app-shell-composer-quotes.js';
+
+/**
+ * #3408 for what the composer STAGES. The draft text is covered by
+ * `chat-composer-region-draft-handoff.test.ts`.
+ *
+ * Staged files and quotes bucket by the composer's staging key, which AppShell
+ * derives as `activeId ?? NEW_TASK_PENDING_KEY`. Two owners, both stable: a
+ * Session, or the one new-task bucket. What is pinned here is that nothing
+ * moves a bucket out from under an operation that is still running against it.
+ */
+
+const originalGlobals = {
+ document: globalThis.document,
+ window: globalThis.window,
+ HTMLElement: globalThis.HTMLElement,
+ Event: globalThis.Event,
+ Node: globalThis.Node,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
+ .IS_REACT_ACT_ENVIRONMENT,
+};
+
+let mountedRoot: Root | undefined;
+
+afterEach(async () => {
+ if (mountedRoot) await act(() => mountedRoot?.unmount());
+ mountedRoot = undefined;
+ Object.assign(globalThis, originalGlobals);
+});
+
+async function mountProbe<T>(useHook: (options: { draftKey: string }) => T): Promise<{
+ latest(): T;
+ render(draftKey: string): Promise<void>;
+}> {
+ const { document, window } = parseHTML('<div id="root"></div>');
+ Object.assign(globalThis, {
+ document,
+ window,
+ HTMLElement: window.HTMLElement,
+ Event: window.Event,
+ Node: window.Node,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+ mountedRoot = root;
+
+ let latest: T | undefined;
+ function Probe(props: { draftKey: string }) {
+ latest = useHook(props);
+ return null;
+ }
+
+ const render = async (draftKey: string) => {
+ await act(async () => {
+ root.render(
+ createElement(LocaleProvider, {
+ locale: 'en',
+ children: createElement(Probe, { draftKey }),
+ }),
+ );
+ });
+ };
+
+ return {
+ latest: () => {
+ assert.ok(latest);
+ return latest;
+ },
+ render,
+ };
+}
+
+function stubFilePicker(): {
+ resolve(files: { approvalId: string; name: string; mimeType: string; size: number }[]): void;
+} {
+ let release: (files: never[]) => void = () => {};
+ const chosen = new Promise<{ ok: true; files: unknown[] }>((resolveChosen) => {
+ release = (files) => resolveChosen({ ok: true, files });
+ });
+ Object.assign(globalThis.window as unknown as Record<string, unknown>, {
+ maka: { attachments: { pickFiles: () => chosen } },
+ });
+ return { resolve: (files) => release(files as never[]) };
+}
+
+function textFile(name: string): File {
+ return { name, type: 'text/plain', size: 12 } as unknown as File;
+}
+
+test('a Session keeps its own staged quotes, and the new-task bucket keeps its own', async () => {
+ const probe = await mountProbe(useAppShellComposerQuotes);
+
+ await probe.render(NEW_TASK_PENDING_KEY);
+ await act(() => probe.latest().addQuote({ text: 'quoted for a new task' }));
+
+ await probe.render('session-1');
+ assert.equal(probe.latest().pendingQuotes.length, 0);
+ await act(() => probe.latest().addQuote({ text: 'quoted for the Session' }));
+ assert.deepEqual(
+ probe.latest().pendingQuotes.map((quote) => quote.text),
+ ['quoted for the Session'],
+ );
+
+ await probe.render(NEW_TASK_PENDING_KEY);
+ assert.deepEqual(
+ probe.latest().pendingQuotes.map((quote) => quote.text),
+ ['quoted for a new task'],
+ );
+});
+
+test('a completing send clears the attachments it submitted', async () => {
+ const probe = await mountProbe((options) =>
+ useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),
+ );
+
+ await probe.render(NEW_TASK_PENDING_KEY);
+ await act(() => probe.latest().attachFilePaths([textFile('notes.txt')]));
+ const submitted = probe.latest().pendingAttachments;
+ assert.equal(submitted.length, 1);
+
+ // AppShell reads `pendingAttachments`, awaits the send, and only then calls
+ // this — through the callback it captured before awaiting. The staging key
+ // has to be the same one on both sides of that await, or the send leaves what
+ // it already delivered staged in the composer, ready to be sent again.
+ const clearAfterSend = probe.latest().clearSubmittedAttachments;
+ await probe.render(NEW_TASK_PENDING_KEY);
+ await act(() => clearAfterSend(submitted));
+
+ assert.equal(probe.latest().pendingAttachments.length, 0);
+});
+
+test('files chosen in the native dialog land in the composer now on screen', async () => {
+ const probe = await mountProbe((options) =>
+ useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }),
+ );
+ await probe.render(NEW_TASK_PENDING_KEY);
+ const picker = stubFilePicker();
+
+ const picking = probe.latest().pickAttachments();
+ // The dialog is modal to its own window, not to the app: the surface behind
+ // it can change before the user finishes choosing.
+ await probe.render('session-1');
+ await act(async () => {
+ picker.resolve([
+ { approvalId: 'approval-1', name: 'chosen.txt', mimeType: 'text/plain', size: 9 },
+ ]);
+ await picking;
+ });
+
+ assert.deepEqual(
+ probe.latest().pendingAttachments.map((item) => item.displayName),
+ ['chosen.txt'],
+ );
+});
diff --git a/apps/desktop/src/renderer/app-shell-pending-attachments.ts b/apps/desktop/src/renderer/app-shell-pending-attachments.ts
index e57b33f29..a53fafa4d 100644
--- a/apps/desktop/src/renderer/app-shell-pending-attachments.ts
+++ b/apps/desktop/src/renderer/app-shell-pending-attachments.ts
@@ -1,5 +1,23 @@
export type PendingByKey<T> = Record<string, T[]>;
+/**
+ * The bucket the session-less composer stages into, whatever the workspace
+ * picker points at (#3408).
+ *
+ * The rest of the new-task surface is keyed by (profileId, hostId, projectId)
+ * since #3122, and staged files and quotes were keyed with it. But they are
+ * in-memory intent, not Host state: nothing persists them, and nothing restores
+ * them per target. Keying them by the target only made them drop out of the
+ * composer when the picker moved.
+ *
+ * A key that never moves is also the only owner an in-flight submission can
+ * safely have. `send()` captures the key it submitted from and clears that key
+ * when it resolves, so a key that followed the picker would leave the files it
+ * just sent staged under the new target, ready to be sent a second time. This
+ * one cannot go stale, so no re-keying rule is needed to keep it honest.
+ */
+export const NEW_TASK_PENDING_KEY = 'new-task';
+
export function selectPending<T>(map: PendingByKey<T>, key: string): T[] {
return map[key] ?? [];
}
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index b824e63bb..db8c168dc 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -89,6 +89,7 @@ import {
} from './quote-companion-panel-state';
import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent';
import { useNewTaskChoice } from './use-new-task-choice';
+import { NEW_TASK_PENDING_KEY } from './app-shell-pending-attachments';
import { sideChatTitleFromPrompt } from './side-chat-command';
import { parseDesktopSlashCommand } from './desktop-slash-command';
import {
@@ -406,7 +407,10 @@ function AppShellContent({
const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot);
const newTask = useNewTaskTarget({ toastApi, uiLocale });
const currentNewTaskDraftKey = newTaskDraftKey(newTask.target);
- const attachmentDraftKey = activeId ?? currentNewTaskDraftKey;
+ // Staged files and quotes do NOT take the target-scoped key: they belong to
+ // the composer the user is looking at, and an in-flight send needs an owner
+ // that cannot move under it. See NEW_TASK_PENDING_KEY.
+ const attachmentDraftKey = activeId ?? NEW_TASK_PENDING_KEY;
const {
pendingAttachments,
pickAttachments,
@@ -423,6 +427,8 @@ function AppShellContent({
removeQuote,
clearQuotes,
} = useAppShellComposerQuotes({ draftKey: attachmentDraftKey });
+ // Held for the whole of sendOwningItsTarget; see ChatComposerRegion.
+ const [newTaskSendPending, setNewTaskSendPending] = useState(false);
// What a new chat will start with, held the way the Session holds it: a
// Plan toggle and one orchestration value, not one fused choice.
const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false);
@@ -2227,6 +2233,26 @@ function AppShellContent({
});
}
+ /**
+ * The send the composer calls, wrapped so the new-task target cannot move
+ * out from under it (#3408). `sendCurrent` captures the draft key it
+ * submitted from and clears exactly that key once this resolves; the picker
+ * stays live throughout, and the catalog can settle on its own. Holding the
+ * flag for the whole call gives the submission one owner, and
+ * ChatComposerRegion defers its carry until it drops.
+ */
+ async function sendOwningItsTarget(
+ text: string,
+ metadata?: ComposerSendMetadata,
+ ): Promise<boolean | void> {
+ setNewTaskSendPending(true);
+ try {
+ return await sendWithAttachments(text, metadata);
+ } finally {
+ setNewTaskSendPending(false);
+ }
+ }
+
async function sendWithAttachments(
text: string,
metadata?: ComposerSendMetadata,
@@ -3229,6 +3255,7 @@ function AppShellContent({
activeInteraction={activeInteraction}
activeId={activeId}
newTaskDraftKey={currentNewTaskDraftKey}
+ newTaskSendPending={newTaskSendPending}
stopPendingBySession={stopPendingBySession}
activeSandboxBoundary={activeSandboxBoundary}
respondToSandboxBoundary={respondToSandboxBoundary}
@@ -3249,7 +3276,7 @@ function AppShellContent({
// "Maka 继续中…". Both are mutually exclusive with activeStreamingLive.
processing={showProcessingIndicator && !activeStreamingLive}
continuing={showContinuingIndicator && !activeStreamingLive}
- onSend={sendWithAttachments}
+ onSend={sendOwningItsTarget}
onStop={stop}
revisionNotice={
revisionDraft && activeId === revisionDraft.draftSessionId
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 710b9329c..a68c4a008 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -55,6 +55,8 @@ interface ChatComposerRegionProps extends Omit<ComponentProps<typeof Composer>,
activeInteraction: ComposerInteraction | undefined;
activeId: string | undefined;
newTaskDraftKey: string;
+ /** True from the moment a new-task send starts until it has settled. */
+ newTaskSendPending: boolean;
stopPendingBySession: Record<string, boolean>;
respondToSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['onRespond'];
activeSandboxBoundary: ComponentProps<typeof SandboxBoundaryPrompt>['request'] | undefined;
@@ -71,6 +73,7 @@ export function ChatComposerRegion({
activeInteraction,
activeId,
newTaskDraftKey,
+ newTaskSendPending,
stopPendingBySession,
respondToSandboxBoundary,
activeSandboxBoundary,
@@ -83,28 +86,59 @@ export function ChatComposerRegion({
const previousNewTaskDraftKey = useRef(newTaskDraftKey);
useLayoutEffect(() => {
const previous = previousNewTaskDraftKey.current;
+ // A submission owns the text it submitted until it settles. `sendCurrent`
+ // captures the key it sent from and clears exactly that key when the send
+ // resolves, so carrying the text to a target chosen mid-flight would leave
+ // the sent message sitting in the composer under the new one, ready to be
+ // sent twice. Leave `previous` where it is rather than dropping the change:
+ // the effect re-runs when the send settles, and carries then — from the
+ // slot the completion has already cleared, so nothing sent comes with it,
+ // and anything typed after the send does.
+ if (newTaskSendPending) return;
previousNewTaskDraftKey.current = newTaskDraftKey;
- if (previous !== UNRESOLVED_NEW_TASK_DRAFT_KEY || previous === newTaskDraftKey) return;
+ if (previous === newTaskDraftKey) return;
const composer = composerRef.current;
if (!composer) return;
- const reloadIntent = readNewTaskReloadIntent();
- const reloadTarget = reloadIntent?.draftKey;
- const canCarryUnresolvedDraft =
- !reloadTarget ||
- reloadTarget === UNRESOLVED_NEW_TASK_DRAFT_KEY ||
- reloadTarget === newTaskDraftKey;
- if (!canCarryUnresolvedDraft) return;
// The catalog may settle after the user has opened an existing Session.
- // Read the unresolved new-task slot itself instead of whichever draft is
- // currently visible, so Session text can never become a new-task draft.
- const current = composer.getDraft(UNRESOLVED_NEW_TASK_DRAFT_KEY);
+ // Read the slot the key is LEAVING instead of whichever draft is currently
+ // visible, so Session text can never become a new-task draft. With no
+ // Session open that slot IS the active one, so this is the visible text.
+ const carried = composer.getDraft(previous);
+ // Leaving the UNRESOLVED slot is startup settling, not a choice the user
+ // made: its draft may have been persisted for one specific target by a
+ // reload, and must not be pasted into a different one. Every other change
+ // is the user picking a different target for the task they are already
+ // writing — the workspace picker sits directly under the composer, so
+ // "type, then pick where it runs" is the ordinary order, and the draft
+ // follows the selection rather than staying behind in the slot they
+ // navigated away from, which read as the text being destroyed (#3408).
+ // The slots themselves stay keyed per target, so #3122's Host-scoped
+ // new-task state is unchanged.
+ if (previous === UNRESOLVED_NEW_TASK_DRAFT_KEY) {
+ const reloadIntent = readNewTaskReloadIntent();
+ const reloadTarget = reloadIntent?.draftKey;
+ const canCarryUnresolvedDraft =
+ !reloadTarget ||
+ reloadTarget === UNRESOLVED_NEW_TASK_DRAFT_KEY ||
+ reloadTarget === newTaskDraftKey;
+ if (!canCarryUnresolvedDraft) return;
+ }
+ // Assigned even when nothing is carried, so the target the user arrives at
+ // shows what they arrived with and nothing else. The swap leaves a copy
+ // under every key it passes through (the composer's draft hook re-remembers
+ // the live text under the key it is leaving), so skipping the empty case
+ // would let one of those copies surface later: send the task, come back to
+ // an empty composer, pick another target, and the text just sent would
+ // reappear as that target's own draft. Its persisted draft still wins over
+ // an empty carry — that one outlived a renderer reload rather than being
+ // left behind by this effect.
composer.setDraft(
newTaskDraftKey,
- current.length > 0
- ? current
+ carried.length > 0
+ ? carried
: (newTaskDraftPersistence.read(newTaskDraftKey) ?? ''),
);
- }, [composerRef, newTaskDraftKey]);
+ }, [composerRef, newTaskDraftKey, newTaskSendPending]);
return (
<>
diff --git a/apps/desktop/src/renderer/use-app-shell-composer-attachments.ts b/apps/desktop/src/renderer/use-app-shell-composer-attachments.ts
index bb81fb3db..2db1a37cd 100644
--- a/apps/desktop/src/renderer/use-app-shell-composer-attachments.ts
+++ b/apps/desktop/src/renderer/use-app-shell-composer-attachments.ts
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { attachmentKindFromMimeType, guessMimeFromName } from '@maka/core/attachments';
import { useUiLocale } from '@maka/ui';
-import { pendingAttachmentSourceKey, type PendingAttachment } from './app-shell-chat-actions';
+import { pendingAttachmentSourceKey, type PendingAttachment } from './app-shell-chat-actions.js';
import { getDesktopConversationCopy } from './locales/conversation-copy.js';
import { localizedShellErrorMessage } from './locales/shell-copy.js';
import {
@@ -10,7 +10,7 @@ import {
removePendingItems,
selectPending,
type PendingByKey,
-} from './app-shell-pending-attachments';
+} from './app-shell-pending-attachments.js';
type ToastApi = {
error(title: string, description?: string): void;
@@ -79,6 +79,12 @@ export function useAppShellComposerAttachments(options: {
// Live mirror of every staged item's key, for async preview arrivals to
// check before writing: state snapshots inside a .then are stale by design.
const stagedKeysRef = useRef<Set<string>>(new Set());
+ // The live staging key, for the one import that resolves long after it was
+ // started: the native file dialog. See pickAttachments.
+ const draftKeyRef = useRef(options.draftKey);
+ useEffect(() => {
+ draftKeyRef.current = options.draftKey;
+ }, [options.draftKey]);
const stagedAttachments = selectPending(pendingByKey, options.draftKey);
const pendingAttachments = useMemo(
() =>
@@ -147,10 +153,14 @@ export function useAppShellComposerAttachments(options: {
}
async function pickAttachments(): Promise<void> {
- const ownerKey = options.draftKey;
try {
const result = await window.maka.attachments.pickFiles();
if (!result.ok) return;
+ // Resolved after the dialog closes, never captured before it opens: the
+ // surface can change while a native dialog is up, and files the user just
+ // chose belong in the composer they are looking at — not in a bucket they
+ // have since left, where the files would be invisible but still sendable.
+ const ownerKey = draftKeyRef.current;
const staged = result.files.map(approvalToPending);
setPendingByKey((map) => appendPending(map, ownerKey, staged));
for (const item of staged) stagedKeysRef.current.add(item.stagingKey);
diff --git a/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts b/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts
index 7169f4281..e657fa281 100644
--- a/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts
+++ b/apps/desktop/src/renderer/use-app-shell-composer-quotes.ts
@@ -6,7 +6,7 @@ import {
removePending,
selectPending,
type PendingByKey,
-} from './app-shell-pending-attachments';
+} from './app-shell-pending-attachments.js';
/**
* Excerpts longer than this are truncated before staging. Kept equal to the
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org