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..52c34e751d 100644 --- a/apps/desktop/src/renderer/app-shell-pending-attachments.ts +++ b/apps/desktop/src/renderer/app-shell-pending-attachments.ts @@ -29,6 +29,29 @@ 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 hasFrom = Object.hasOwn(map, from); + const hasTo = Object.hasOwn(map, to); + if (!hasFrom && !hasTo) return map; + const moved = hasFrom ? (map[from] ?? []) : []; + const next = { ...map }; + if (hasFrom) delete next[from]; + if (moved.length > 0) next[to] = moved; + else if (hasTo) 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]); +}