From 077d26d8de77a139da7a95f87c3b15f31d16c0ca Mon Sep 17 00:00:00 2001 From: HikariLan Date: Fri, 21 Aug 2026 22:47:15 +0800 Subject: [PATCH 1/2] fix(desktop): keep the new-task composer's contents when the target changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/desktop/e2e/fixtures.ts | 31 ++++ .../desktop/e2e/new-task-draft-target.spec.ts | 63 +++++++ ...chat-composer-region-draft-handoff.test.ts | 84 +++++++++- .../__tests__/new-task-pending-carry.test.ts | 154 ++++++++++++++++++ .../renderer/app-shell-pending-attachments.ts | 21 +++ apps/desktop/src/renderer/app-shell.tsx | 6 +- .../src/renderer/chat-composer-region.tsx | 48 ++++-- .../use-app-shell-composer-attachments.ts | 10 +- .../renderer/use-app-shell-composer-quotes.ts | 10 +- .../renderer/use-new-task-pending-carry.ts | 41 +++++ 10 files changed, 447 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/e2e/new-task-draft-target.spec.ts create mode 100644 apps/desktop/src/main/__tests__/new-task-pending-carry.test.ts create mode 100644 apps/desktop/src/renderer/use-new-task-pending-carry.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 48af56b4e8..6168f5127f 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 { + 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 { 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, ): Promise { @@ -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 0000000000..b869e60049 --- /dev/null +++ b/apps/desktop/e2e/new-task-draft-target.spec.ts @@ -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 { + await page.evaluate( + () => + new Promise((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); +}); 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 8d49a8e453..30247a226c 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,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; +}> { const { document, window } = parseHTML('
'); const storage = new Map(); Object.assign(document, { @@ -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(); - markNewTaskReloadIntent(); - writeNewTaskReloadDraft(UNRESOLVED_NEW_TASK_DRAFT_KEY, 'new task draft'); const render = async (activeId: string | undefined, newTaskDraftKey: string) => { await act(async () => { @@ -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')); @@ -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'); +}); diff --git a/apps/desktop/src/main/__tests__/new-task-pending-carry.test.ts b/apps/desktop/src/main/__tests__/new-task-pending-carry.test.ts new file mode 100644 index 0000000000..30e9d4609c --- /dev/null +++ b/apps/desktop/src/main/__tests__/new-task-pending-carry.test.ts @@ -0,0 +1,154 @@ +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 { rekeyPending } 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`; attachments and quotes are + * bucketed by the same `(profile, host, project)` key and drop out of the + * composer on the same click of the workspace picker. + */ + +const PROJECT_A = '["new-task","local","host","project-a"]'; +const PROJECT_B = '["new-task","local","host","project-b"]'; + +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); +}); + +/** + * Mount one composer-staging hook and return a `render(draftKey, + * newTaskDraftKey)` for the two keys it distinguishes: the composer's ACTIVE + * key, which a Session switch changes, and the new-task target's own key, which + * only the workspace picker changes. + */ +async function mountProbe(useHook: (options: { + draftKey: string; + newTaskDraftKey: string; +}) => T): Promise<{ + latest(): T; + render(draftKey: string, newTaskDraftKey: string): Promise; +}> { + const { document, window } = parseHTML('
'); + 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; newTaskDraftKey: string }) { + latest = useHook(props); + return null; + } + + const render = async (draftKey: string, newTaskDraftKey: string) => { + await act(async () => { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(Probe, { draftKey, newTaskDraftKey }), + }), + ); + }); + }; + + return { + latest: () => { + assert.ok(latest); + return latest; + }, + render, + }; +} + +function textFile(name: string): File { + return { name, type: 'text/plain', size: 12 } as unknown as File; +} + +test('staged quotes follow the Project chosen under the composer', async () => { + const probe = await mountProbe(useAppShellComposerQuotes); + + await probe.render(PROJECT_A, PROJECT_A); + await act(() => probe.latest().addQuote({ text: 'quoted line' })); + assert.equal(probe.latest().pendingQuotes.length, 1); + + await probe.render(PROJECT_B, PROJECT_B); + assert.deepEqual( + probe.latest().pendingQuotes.map((quote) => quote.text), + ['quoted line'], + ); +}); + +test('staged attachments follow the Project chosen under the composer', async () => { + const probe = await mountProbe((options) => + useAppShellComposerAttachments({ ...options, toastApi: { error() {} } }), + ); + + await probe.render(PROJECT_A, PROJECT_A); + await act(() => probe.latest().attachFilePaths([textFile('notes.txt')])); + assert.equal(probe.latest().pendingAttachments.length, 1); + + await probe.render(PROJECT_B, PROJECT_B); + assert.deepEqual( + probe.latest().pendingAttachments.map((item) => item.displayName), + ['notes.txt'], + ); +}); + +test('a Session keeps its own staged quotes when the new-task target moves', async () => { + const probe = await mountProbe(useAppShellComposerQuotes); + + await probe.render('session-1', PROJECT_A); + await act(() => probe.latest().addQuote({ text: 'quoted from the Session' })); + + // The catalog can settle, or another surface can move the target, while the + // user is inside a Session. That must not reach the Session's own bucket… + await probe.render('session-1', PROJECT_B); + assert.equal(probe.latest().pendingQuotes.length, 1); + + // …nor hand the Session's quotes to the new-task composer on the way out. + await probe.render(PROJECT_B, PROJECT_B); + assert.equal(probe.latest().pendingQuotes.length, 0); +}); + +test('the target arrived at holds what was brought to it and nothing else', () => { + const carried = rekeyPending({ [PROJECT_A]: ['one'] }, PROJECT_A, PROJECT_B); + assert.deepEqual(carried, { [PROJECT_B]: ['one'] }); + + // Nothing staged means the destination is emptied too, so a bucket that + // outlived a send can never resurface as the next target's own staged set. + const emptied = rekeyPending({ [PROJECT_B]: ['stale'] }, PROJECT_A, PROJECT_B); + assert.deepEqual(emptied, {}); + + // Untouched keys keep the same object, so no consumer re-renders for nothing. + const unrelated = { 'session-1': ['kept'] }; + assert.equal(rekeyPending(unrelated, PROJECT_A, PROJECT_B), unrelated); +}); diff --git a/apps/desktop/src/renderer/app-shell-pending-attachments.ts b/apps/desktop/src/renderer/app-shell-pending-attachments.ts index e57b33f294..a8d1245009 100644 --- a/apps/desktop/src/renderer/app-shell-pending-attachments.ts +++ b/apps/desktop/src/renderer/app-shell-pending-attachments.ts @@ -29,6 +29,27 @@ export function removePendingItems( return { ...map, [key]: remaining }; } +/** + * Move one key's staged items to another, leaving nothing behind under the old + * one. The destination takes exactly what the source had — including nothing — + * so a bucket left under a key the composer merely passed through can never + * resurface later as that key's own staged set. + */ +export function rekeyPending( + map: PendingByKey, + from: string, + to: string, +): PendingByKey { + if (from === to) return map; + const moved = map[from] ?? []; + if (!(from in map) && !(to in map)) return map; + const next = { ...map }; + delete next[from]; + if (moved.length > 0) next[to] = moved; + else delete next[to]; + return next; +} + export function clearPending(map: PendingByKey, key: string): PendingByKey { const next = { ...map }; delete next[key]; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 4e854995c8..313a0fc196 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -412,6 +412,7 @@ function AppShellContent({ clearSubmittedAttachments, } = useAppShellComposerAttachments({ draftKey: attachmentDraftKey, + newTaskDraftKey: currentNewTaskDraftKey, toastApi, }); const { @@ -419,7 +420,10 @@ function AppShellContent({ addQuote, removeQuote, clearQuotes, - } = useAppShellComposerQuotes({ draftKey: attachmentDraftKey }); + } = useAppShellComposerQuotes({ + draftKey: attachmentDraftKey, + newTaskDraftKey: currentNewTaskDraftKey, + }); // 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); diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 710b9329c1..69423d56dd 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -84,24 +84,46 @@ export function ChatComposerRegion({ useLayoutEffect(() => { const previous = previousNewTaskDraftKey.current; 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]); 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 bb81fb3dbf..e3c4bd674b 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,8 @@ import { removePendingItems, selectPending, type PendingByKey, -} from './app-shell-pending-attachments'; +} from './app-shell-pending-attachments.js'; +import { useNewTaskPendingCarry } from './use-new-task-pending-carry.js'; type ToastApi = { error(title: string, description?: string): void; @@ -66,6 +67,8 @@ function releasePreviewUrl(url: string | undefined): void { export function useAppShellComposerAttachments(options: { draftKey: string; + /** The new-task target's own key; see useNewTaskPendingCarry. */ + newTaskDraftKey?: string; toastApi: ToastApi; }) { const uiLocale = useUiLocale(); @@ -79,6 +82,9 @@ 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>(new Set()); + // A carried bucket keeps its item objects, so every stagingKey stays live and + // the preview cleanup effect below has nothing to revoke. + useNewTaskPendingCarry(options.newTaskDraftKey, setPendingByKey); const stagedAttachments = selectPending(pendingByKey, options.draftKey); const pendingAttachments = useMemo( () => 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 7169f42814..f2e95ccab6 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,8 @@ import { removePending, selectPending, type PendingByKey, -} from './app-shell-pending-attachments'; +} from './app-shell-pending-attachments.js'; +import { useNewTaskPendingCarry } from './use-new-task-pending-carry.js'; /** * Excerpts longer than this are truncated before staging. Kept equal to the @@ -19,9 +20,14 @@ const MAX_QUOTE_CHARS = 32_000; * Quoted excerpts staged for the next send, keyed by draft key so each session * keeps its own (mirrors pending attachments). Cleared once the turn is sent. */ -export function useAppShellComposerQuotes(options: { draftKey: string }) { +export function useAppShellComposerQuotes(options: { + draftKey: string; + /** The new-task target's own key; see useNewTaskPendingCarry. */ + newTaskDraftKey: string; +}) { const [pendingByKey, setPendingByKey] = useState>({}); const pendingQuotes = selectPending(pendingByKey, options.draftKey); + useNewTaskPendingCarry(options.newTaskDraftKey, setPendingByKey); function addQuote(input: { text: string; turnId?: string; label?: string }): void { const text = input.text.slice(0, MAX_QUOTE_CHARS).trim(); diff --git a/apps/desktop/src/renderer/use-new-task-pending-carry.ts b/apps/desktop/src/renderer/use-new-task-pending-carry.ts new file mode 100644 index 0000000000..5c2b9d8359 --- /dev/null +++ b/apps/desktop/src/renderer/use-new-task-pending-carry.ts @@ -0,0 +1,41 @@ +import { useLayoutEffect, useRef, type Dispatch, type SetStateAction } from 'react'; +import { rekeyPending, type PendingByKey } from './app-shell-pending-attachments.js'; + +/** + * Carry a composer's staged items to the new-task target the user selects + * (#3408). + * + * Everything the session-less composer holds is keyed by + * `(profileId, hostId, projectId)` since #3122 — the draft text, staged + * attachments and staged quotes alike — and the workspace picker that changes + * the project part sits directly under the composer. So choosing a Project + * re-keys all three mid-composition, and what the user staged drops out of the + * composer on that click. `ChatComposerRegion` moves the draft text; this moves + * the buckets, on the same rule: the target the user arrives at holds what they + * arrived with and nothing else. + * + * Keyed on the NEW-TASK key rather than the composer's active draft key, which + * is `activeId ?? newTaskDraftKey`. A Session switch changes that active key + * too, and a Session's staged attachments must stay with the Session they were + * staged for — this must not follow the user there. + * + * A layout effect, so the move lands in the same commit that re-keyed the + * bucket and the drawer never paints a frame of "nothing staged". + * + * `undefined` for a composer that can never host a new task — the quote + * companion panel is keyed by its own panel id — where the key never changes + * and there is nothing to carry. + */ +export function useNewTaskPendingCarry( + newTaskDraftKey: string | undefined, + setPendingByKey: Dispatch>>, +): void { + const previousNewTaskDraftKey = useRef(newTaskDraftKey); + useLayoutEffect(() => { + const from = previousNewTaskDraftKey.current; + previousNewTaskDraftKey.current = newTaskDraftKey; + if (from === undefined || newTaskDraftKey === undefined) return; + if (from === newTaskDraftKey) return; + setPendingByKey((map) => rekeyPending(map, from, newTaskDraftKey)); + }, [newTaskDraftKey, setPendingByKey]); +} From dc8c226fedac54fd92ed0abdac6481673e72645f Mon Sep 17 00:00:00 2001 From: HikariLan Date: Fri, 21 Aug 2026 23:00:01 +0800 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/renderer/app-shell-pending-attachments.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell-pending-attachments.ts b/apps/desktop/src/renderer/app-shell-pending-attachments.ts index a8d1245009..52c34e751d 100644 --- a/apps/desktop/src/renderer/app-shell-pending-attachments.ts +++ b/apps/desktop/src/renderer/app-shell-pending-attachments.ts @@ -41,12 +41,14 @@ export function rekeyPending( to: string, ): PendingByKey { if (from === to) return map; - const moved = map[from] ?? []; - if (!(from in map) && !(to in map)) return map; + const hasFrom = Object.hasOwn(map, from); + const hasTo = Object.hasOwn(map, to); + if (!hasFrom && !hasTo) return map; + const moved = hasFrom ? (map[from] ?? []) : []; const next = { ...map }; - delete next[from]; + if (hasFrom) delete next[from]; if (moved.length > 0) next[to] = moved; - else delete next[to]; + else if (hasTo) delete next[to]; return next; }